diff --git a/.env.example b/.env.example index a8ff28bd..e7f0c5fd 100644 --- a/.env.example +++ b/.env.example @@ -151,6 +151,23 @@ OTEL_SERVICE_NAME=mosaic-api # Empty means record in-process without exporting. Export failure never stops # Mosaic. OTEL_EXPORTER_OTLP_ENDPOINT= +# Transport for the endpoint above: http/protobuf (default, usually port 4318) +# or grpc (usually port 4317). Ignored when no endpoint is set. +OTEL_EXPORTER_OTLP_PROTOCOL= +# Export headers as key1=value1,key2=value2 with percent-encoded values. This is +# where a hosted collector's token goes, so it is a secret: +# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer%20token +OTEL_EXPORTER_OTLP_HEADERS= +# Skip collector certificate verification. https:// endpoints only, for a +# private CA or self-signed certificate. +MOSAIC_OTEL_EXPORTER_TLS_SKIP_VERIFY=false +# Acknowledge a plaintext or unverified collector connection outside development +# and test. Without it, those are rejected at startup. +MOSAIC_OTEL_EXPORTER_ALLOW_INSECURE=false +# Ship log records to the collector alongside traces and metrics. Logs always +# keep going to stdout as well; set false when log volume is the cost that +# matters. Ignored when no endpoint is set. +MOSAIC_OTEL_LOGS_ENABLED=true # ============================================================================= # Object storage (S3-compatible) @@ -177,6 +194,18 @@ MOSAIC_ASSET_MAX_UPLOAD_BYTES=10485760 # Must be an absolute HTTPS URL without credentials. MOSAIC_PUBLIC_ASSET_BASE_URL=https://localhost:8443/v1/sdk/assets +# Phase 9C migration source evidence uses a separate private bucket and a +# separate versioned AES-256 keyring. Never reuse the public Asset bucket or +# MOSAIC_PROVIDER_CREDENTIAL_KEYRING. The keyring has the same JSON envelope +# shape, but must contain independently generated key material: +# {"version":1,"activeKeyId":"migration-2026-01","keys":{"migration-2026-01":""}} +MOSAIC_BILLING_MIGRATION_ENABLED=false +MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING= +MOSAIC_BILLING_MIGRATION_SOURCE_BUCKET=mosaic-migration-private +MOSAIC_BILLING_MIGRATION_SOURCE_CHUNK_BYTES=262144 +MOSAIC_BILLING_MIGRATION_SOURCE_OPERATION_TIMEOUT=5m +MOSAIC_BILLING_MIGRATION_WORKER_POLL_INTERVAL=1s + # ============================================================================= # Browser sessions # ============================================================================= diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 33490064..066f5833 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -20,6 +20,7 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" "github.com/Mujhtech/mosaic/apps/api/internal/billingdiagnostics" "github.com/Mujhtech/mosaic/apps/api/internal/billinggrant" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" "github.com/Mujhtech/mosaic/apps/api/internal/billingoperator" "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" "github.com/Mujhtech/mosaic/apps/api/internal/billingrestore" @@ -38,6 +39,9 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingdiagnosticspostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billinggrantpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingkeys" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationrepair" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationvalidation" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingoperatorpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingprojectionpostgres" @@ -108,6 +112,10 @@ func closeSchemas(readers map[protocolschema.Schema]io.ReadCloser) { } } +func repairExecutionEnabled(cfg config.Config) bool { + return cfg.Billing.Enabled && cfg.Migration.Enabled +} + func run() (runErr error) { cfg, err := config.Load() if err != nil { @@ -119,11 +127,16 @@ func run() (runErr error) { return fmt.Errorf("configure logging: %w", err) } build := buildinfo.Current() - logger = logger.With(). - Str("service", cfg.Telemetry.ServiceName). - Str("environment", cfg.Environment). - Str("version", build.Version). - Logger() + // Applied to whichever logger ends up in use, so the local stream and the + // exported records carry the same service identity. + withServiceContext := func(base zerolog.Logger) zerolog.Logger { + return base.With(). + Str("service", cfg.Telemetry.ServiceName). + Str("environment", cfg.Environment). + Str("version", build.Version). + Logger() + } + logger = withServiceContext(logger) runContext, stop := signal.NotifyContext( context.Background(), @@ -136,11 +149,30 @@ func run() (runErr error) { ServiceName: cfg.Telemetry.ServiceName, Environment: cfg.Environment, OTLPEndpoint: cfg.Telemetry.OTLPEndpoint, - Logger: logger, + OTLPProtocol: cfg.Telemetry.OTLPProtocol, + OTLPHeaders: cfg.Telemetry.OTLPHeaders, + // Startup validation has already refused an unverified collector in a + // production-like environment unless it was explicitly acknowledged. + OTLPTLSSkipVerify: cfg.Telemetry.TLSSkipVerify, + DisableLogExport: !cfg.Telemetry.ExportLogs(), + // Telemetry keeps the local-only logger: routing its own export failures + // through the exporting logger would feed the failing exporter. + Logger: logger, }) if err != nil { return fmt.Errorf("configure telemetry: %w", err) } + if cfg.Telemetry.ExportLogs() { + // Swapped in only once the logger provider exists, so every record this + // logger writes locally also reaches the collector. + exportingLogger, err := logging.NewExporting( + cfg.Log.Level, cfg.Log.Format, os.Stdout, cfg.Telemetry.ServiceName, + ) + if err != nil { + return fmt.Errorf("configure log export: %w", err) + } + logger = withServiceContext(exportingLogger) + } defer func() { // Telemetry flush has its own budget so a slow collector cannot consume // the HTTP drain budget or delay closing the database pool. @@ -276,6 +308,14 @@ func run() (runErr error) { var billingAccessService *billingaccess.Service var billingDiagnosticsService *billingdiagnostics.Service var billingGrantService *billinggrant.Service + var billingMigrationService *billingmigration.Service + var billingMigrationSourcePull *billingmigration.SourcePullService + var billingMigrationOperations *billingmigration.OperationsService + var billingMigrationRedelivery *billingmigration.RedeliveryService + var billingMigrationReads *billingmigration.OperationalReadService + var billingMigrationStabilization *billingmigration.StabilizationService + var billingMigrationRollbackReadiness *billingmigration.RollbackReadinessService + var billingMigrationRepairOnline bool var billingRestoreService *billingrestore.Service var billingCustomerService *billingcustomer.Service var billingOperatorService *billingoperator.Service @@ -287,6 +327,29 @@ func run() (runErr error) { if err != nil { return fmt.Errorf("configure billing credential encryption: %w", err) } + migrationRevenueCatClient, err := revenuecat.New(revenuecat.Config{ + BaseURL: cfg.Providers.RevenueCatBaseURL, RequestTimeout: cfg.Providers.RequestTimeout, + OperationTimeout: cfg.Providers.OperationTimeout, ConnectTimeout: cfg.Providers.ConnectTimeout, + MaxResponseBytes: cfg.Providers.MaxResponseBytes, MaxAttempts: cfg.Providers.MaxAttempts, + }) + if err != nil { + return fmt.Errorf("configure RevenueCat migration adapter: %w", err) + } + migrationRepository := billingmigrationpostgres.New(databasePool) + billingMigrationService = billingmigration.NewService( + migrationRepository, billingCipher, migrationRevenueCatClient) + // Non-repair operator controls remain readable/usable when the optional + // execution plane is disabled; repair itself fails closed through a nil + // executor and the explicit transport gate below. + billingMigrationOperations = billingmigration.NewOperationsService( + migrationRepository, migrationRepository, nil) + billingMigrationRedelivery = billingmigration.NewRedeliveryService(migrationRepository, nil) + billingMigrationReads = billingmigration.NewOperationalReadService(migrationRepository, migrationRepository) + billingMigrationStabilization = billingmigration.NewStabilizationService(migrationRepository, migrationRepository) + billingMigrationRollbackReadiness = billingmigration.NewRollbackReadinessService(migrationRepository, migrationRepository) + if cfg.Migration.Enabled { + billingMigrationSourcePull = billingmigration.NewSourcePullService(migrationRepository, nil) + } // The Apple root is compiled in, so a broken embed fails startup rather // than the first notification. verifier, err := appstorejws.NewVerifier() @@ -352,6 +415,17 @@ func run() (runErr error) { billing.WithNotificationBaseURL(cfg.Billing.NotificationBaseURL), billing.WithSeam(billingseam.New(billingCustomerService, billingAccessService), billingseam.New(billingCustomerService, billingAccessService))) + if repairExecutionEnabled(cfg) { + migrationRepairExecutor := billingmigrationrepair.NewProductionExecutor( + billingmigrationrepair.NewPostgresStore(databasePool), + billingmigrationvalidation.New(billingService), + billingProjectionService, + projectionRepository, + ) + billingMigrationOperations = billingmigration.NewOperationsService( + migrationRepository, migrationRepository, migrationRepairExecutor) + billingMigrationRepairOnline = true + } billingGrantService = billinggrant.NewService(billinggrantpostgres.New(databasePool)) // The operator surface reads through the same repositories the trusted // APIs read through, so the dashboard and an application backend see one @@ -392,35 +466,43 @@ func run() (runErr error) { TrustedProxyCIDRs: cfg.HTTP.TrustedProxyCIDRs, EnableHSTS: cfg.ProductionLike(), }, logger, httpserver.Dependencies{ - BrowserAuth: browserAuthService, - BrowserAuthConfig: browserauthhttp.Config{CookieSecure: cfg.BrowserAuth.CookieSecure, CookieDomain: cfg.BrowserAuth.CookieDomain, AllowedOrigins: cfg.HTTP.CORSAllowedOrigins, RateLimiter: authenticationLimiter}, - CloudWorkspace: workspaceService, - HostedPublishing: publishingService, - PlacementDecision: placementDecisionService, - PrincipalResolver: authn.NewBrowserSessionResolver(browserAuthService), - DeliveryLimiter: deliveryLimiter, - Analytics: analyticsService, - AnalyticsIPLimiter: analyticsIPLimiter, - AnalyticsKeyLimiter: analyticsKeyLimiter, - AnalyticsEventLimiter: analyticsEventLimiter, - Experiment: experimentService, - Billing: billingService, - BillingAccess: billingAccessService, - BillingDiagnostics: billingDiagnosticsService, - BillingGrant: billingGrantService, - BillingRestore: billingRestoreService, - BillingCustomer: billingCustomerService, - BillingOperator: billingOperatorService, - BillingWebhook: billingWebhookService, - BillingIPLimiter: billingIPLimiter, - BillingKeyLimiter: billingKeyLimiter, - EntitlementSyncLimiter: entitlementSyncLimiter, - APILimiter: apiLimiter, - DecisionLimiter: decisionLimiter, - UploadLimiter: uploadLimiter, - ExportLimiter: exportLimiter, - Readiness: readiness, - ReadinessChecker: database.HealthChecker{Pinger: databasePool}, + BrowserAuth: browserAuthService, + BrowserAuthConfig: browserauthhttp.Config{CookieSecure: cfg.BrowserAuth.CookieSecure, CookieDomain: cfg.BrowserAuth.CookieDomain, AllowedOrigins: cfg.HTTP.CORSAllowedOrigins, RateLimiter: authenticationLimiter}, + CloudWorkspace: workspaceService, + HostedPublishing: publishingService, + PlacementDecision: placementDecisionService, + PrincipalResolver: authn.NewBrowserSessionResolver(browserAuthService), + DeliveryLimiter: deliveryLimiter, + Analytics: analyticsService, + AnalyticsIPLimiter: analyticsIPLimiter, + AnalyticsKeyLimiter: analyticsKeyLimiter, + AnalyticsEventLimiter: analyticsEventLimiter, + Experiment: experimentService, + Billing: billingService, + BillingAccess: billingAccessService, + BillingDiagnostics: billingDiagnosticsService, + BillingGrant: billingGrantService, + BillingMigration: billingMigrationService, + BillingMigrationSourcePull: billingMigrationSourcePull, + BillingMigrationOperations: billingMigrationOperations, + BillingMigrationRedelivery: billingMigrationRedelivery, + BillingMigrationReads: billingMigrationReads, + BillingMigrationStabilization: billingMigrationStabilization, + BillingMigrationRollbackReadiness: billingMigrationRollbackReadiness, + BillingMigrationRepairOnline: billingMigrationRepairOnline, + BillingRestore: billingRestoreService, + BillingCustomer: billingCustomerService, + BillingOperator: billingOperatorService, + BillingWebhook: billingWebhookService, + BillingIPLimiter: billingIPLimiter, + BillingKeyLimiter: billingKeyLimiter, + EntitlementSyncLimiter: entitlementSyncLimiter, + APILimiter: apiLimiter, + DecisionLimiter: decisionLimiter, + UploadLimiter: uploadLimiter, + ExportLimiter: exportLimiter, + Readiness: readiness, + ReadinessChecker: database.HealthChecker{Pinger: databasePool}, }) server := &http.Server{ diff --git a/apps/api/cmd/api/main_test.go b/apps/api/cmd/api/main_test.go index e57cceaf..dfd7556b 100644 --- a/apps/api/cmd/api/main_test.go +++ b/apps/api/cmd/api/main_test.go @@ -4,8 +4,28 @@ import ( "os" "strings" "testing" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/config" ) +func TestRepairExecutionRequiresBillingAndMigrationOptIn(t *testing.T) { + for _, test := range []struct { + billing, migration, want bool + }{ + {billing: false, migration: false, want: false}, + {billing: true, migration: false, want: false}, + {billing: false, migration: true, want: false}, + {billing: true, migration: true, want: true}, + } { + cfg := config.Config{} + cfg.Billing.Enabled = test.billing + cfg.Migration.Enabled = test.migration + if got := repairExecutionEnabled(cfg); got != test.want { + t.Fatalf("billing=%v migration=%v enabled=%v want=%v", test.billing, test.migration, got, test.want) + } + } +} + func TestProductionWiringUsesPostgreSQLOnly(t *testing.T) { source, err := os.ReadFile("main.go") if err != nil { diff --git a/apps/api/cmd/billingdemo/demo9c.go b/apps/api/cmd/billingdemo/demo9c.go new file mode 100644 index 00000000..15b79754 --- /dev/null +++ b/apps/api/cmd/billingdemo/demo9c.go @@ -0,0 +1,331 @@ +//go:build billingdemo + +// The Phase 9C drill driver is synthetic and isolated. It exercises Mosaic's +// real migration application services and PostgreSQL repositories, but it does +// not claim live RevenueCat, Apple/Google sandbox, SDK-device, webhook-receiver, +// scale, or elapsed seven-day stabilization evidence. +package main + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "strconv" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +type demo9CAssessor struct { + now time.Time + err error +} + +func (a demo9CAssessor) AssessMigration(context.Context, string, []byte) (billingmigration.CapabilityResult, error) { + if a.err != nil { + return billingmigration.CapabilityResult{}, a.err + } + return billingmigration.CapabilityResult{ + ProviderAPIVersion: billingmigration.ProviderAPIV2, + Capabilities: []string{ + "read_customers", "read_subscriptions", "read_products", "read_entitlements", + }, + AssessedAt: a.now, + }, nil +} + +type demo9CState struct { + now time.Time + organizationID, projectID, environmentID string + applicationID, ownerID, adminID string + productID, entitlementID, customerID string + programID, mappingSetID, manifestID string + mappingDigest, manifestDigest string + service *billingmigration.Service + repository *billingmigrationpostgres.Repository + cipher providercredential.SubjectCipher +} + +var phase9C demo9CState + +func (d *demo) stages9C() []func() error { + return []func() error{ + d.stage9CSetup, + d.stage9CAssessment, + d.stage9CMapping, + d.stage9CHistoricalEvidence, + d.stage9CWorkerResume, + d.stage9CReadinessAndOutage, + d.stage9CReport, + } +} + +func (d *demo) stage9CSetup() error { + d.section("9C-0", "Isolated synthetic tenant and real PostgreSQL migration seams") + phase9C.now = time.Now().UTC().Truncate(time.Microsecond) + suffix := strconv.FormatInt(phase9C.now.UnixNano(), 36) + phase9C.organizationID = "org_demo9c_" + suffix + phase9C.projectID = "proj_demo9c_" + suffix + phase9C.environmentID = "env_demo9c_" + suffix + phase9C.applicationID = "app_demo9c_" + suffix + phase9C.ownerID = "actor_demo9c_owner_" + suffix + phase9C.adminID = "actor_demo9c_admin_" + suffix + phase9C.productID = "prd_demo9c_" + suffix + phase9C.entitlementID = "ent_demo9c_" + suffix + phase9C.customerID = "cus_demo9c_" + suffix + + _, err := d.pool.Exec(d.ctx, ` + INSERT INTO organizations(id,name,created_at,updated_at) VALUES($1,'Mosaic Phase 9C Drill',$9,$9); + INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) VALUES + ($1,$5,'owner',$9,$9),($1,$6,'admin',$9,$9); + INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES($2,$1,$10,'Phase 9C Drill','active',$9,$9); + INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) + VALUES($3,$2,'staging','Staging','staging',$9,$9); + INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES($4,$2,'Phase 9C iOS','ios',$11,$9,$9); + INSERT INTO products(id,project_id,key,internal_name,type,status,metadata_source,readiness_ready,created_at,updated_at) + VALUES($7,$2,'pro-monthly','Pro Monthly','subscription','connected','mock',true,$9,$9); + INSERT INTO entitlements(id,project_id,key,name,description,created_at,updated_at) + VALUES($8,$2,'pro','Pro','Phase 9C synthetic Entitlement',$9,$9); + INSERT INTO billing_customers(id,project_id,status,created_at,updated_at) + VALUES($12,$2,'active',$9,$9)`, + phase9C.organizationID, phase9C.projectID, phase9C.environmentID, phase9C.applicationID, + phase9C.ownerID, phase9C.adminID, phase9C.productID, phase9C.entitlementID, phase9C.now, + "demo9c-"+suffix, "com.mosaic.demo9c."+suffix, phase9C.customerID) + if err != nil { + return fmt.Errorf("seed Phase 9C drill tenant: %w", err) + } + + keyring, err := newDemoKeyring() + if err != nil { + return err + } + cipher, err := providercredential.NewAESGCMCipher(keyring, rand.Reader) + if err != nil { + return err + } + phase9C.repository = billingmigrationpostgres.New(d.pool) + phase9C.cipher = cipher + phase9C.service = billingmigration.NewService( + phase9C.repository, cipher, demo9CAssessor{now: phase9C.now}, + billingmigration.WithClock(func() time.Time { return phase9C.now }), + ) + d.note("created isolated staging tenant %s; evidence is retained for audit and the run prints its IDs", phase9C.projectID) + d.note("synthetic boundaries: local assessor result and fake credential bytes; no provider or SDK evidence is claimed") + return nil +} + +func (d *demo) stage9CAssessment() error { + d.section("9C-1", "Synthetic RevenueCat capability assessment (Drill 1 partial)") + detail, replay, err := phase9C.service.CreateProgram(d.ctx, billingmigration.Actor{ID: phase9C.ownerID}, billingmigration.CreateProgramInput{ + ProjectID: phase9C.projectID, EnvironmentID: phase9C.environmentID, + Applications: []billingmigration.ScopeItem{{ApplicationID: phase9C.applicationID, Platform: "ios"}}, + ExternalProjectID: "rc_demo9c_synthetic", Credential: []byte("synthetic-not-a-live-provider-secret"), + IdempotencyKey: "demo9c-create-program", StabilizationDays: 7, RollbackWindowDays: 7, + }) + if err != nil || replay { + return fmt.Errorf("create migration program: replay=%v: %w", replay, err) + } + phase9C.programID = detail.Program.ProgramID + if detail.SourceCapabilityAssessment == nil || detail.Program.State != billingmigration.StateMapping { + return errors.New("program did not retain its capability assessment or enter mapping") + } + d.note("program %s created in state=%s with adapter=%s/%s", phase9C.programID, detail.Program.State, detail.Program.Source.Adapter, detail.Program.Source.AdapterVersion) + d.note("capabilities=%v (synthetic assessor; customer/alias/Product counts are not claimed)", detail.SourceCapabilityAssessment.Capabilities) + + var ciphertext []byte + var currentAuthority string + var epoch int64 + if err = d.pool.QueryRow(d.ctx, `SELECT ciphertext FROM billing_migration_credentials WHERE id=$1`, detail.Program.Source.CredentialReference).Scan(&ciphertext); err != nil { + return err + } + if string(ciphertext) == "synthetic-not-a-live-provider-secret" { + return errors.New("migration credential persisted in plaintext") + } + if err = d.pool.QueryRow(d.ctx, `SELECT current_authority,current_epoch FROM billing_migration_authority_scopes WHERE project_id=$1 AND environment_id=$2 AND application_id=$3 AND platform='ios'`, phase9C.projectID, phase9C.environmentID, phase9C.applicationID).Scan(¤tAuthority, &epoch); err != nil { + return err + } + d.note("credential is AES-GCM ciphertext (%d bytes); authority remains %s epoch %d", len(ciphertext), currentAuthority, epoch) + return nil +} + +func (d *demo) stage9CMapping() error { + d.section("9C-2", "Exact immutable mapping set (Drill 2 partial)") + mapping, err := phase9C.service.CreateMappingSet(d.ctx, billingmigration.Actor{ID: phase9C.adminID}, billingmigration.CreateMappingSetInput{ + ProjectID: phase9C.projectID, ProgramID: phase9C.programID, ExpectedStateVersion: 1, Version: 1, + Entries: []billingmigration.MappingEntry{ + {SourceKind: "customer_id", SourceIdentifier: "rc_customer_alpha", TargetID: phase9C.customerID, MatchKind: "exact"}, + {SourceKind: "audited_alias", SourceIdentifier: "rc_alias_alpha", TargetID: phase9C.customerID, MatchKind: "audited_alias"}, + {SourceKind: "product", SourceIdentifier: "rc_product_monthly", TargetID: phase9C.productID, MatchKind: "exact"}, + {SourceKind: "entitlement", SourceIdentifier: "rc_entitlement_pro", TargetID: phase9C.entitlementID, MatchKind: "exact"}, + }, + }) + if err != nil { + return err + } + if err = phase9C.service.FreezeMappingSet(d.ctx, billingmigration.Actor{ID: phase9C.adminID}, phase9C.projectID, phase9C.programID, mapping.MappingSetID, 1); err != nil { + return err + } + phase9C.mappingSetID, phase9C.mappingDigest = mapping.MappingSetID, mapping.MappingDigest + if _, err = d.pool.Exec(d.ctx, `UPDATE billing_migration_mapping_entries SET target_id='forbidden-edit' WHERE mapping_set_id=$1`, mapping.MappingSetID); err == nil { + return errors.New("frozen mapping entry accepted an in-place edit") + } + d.note("mapping %s frozen with exact customer/Product/Entitlement mappings and one audited alias", mapping.MappingSetID) + d.note("an attempted in-place mapping edit was rejected by the immutable-evidence trigger") + d.note("alias-conflict detection, unmapped-Product repair, and Package/Offering preservation require a source pull and are not claimed here") + return nil +} + +func (d *demo) stage9CHistoricalEvidence() error { + d.section("9C-3", "Lower-confidence historical evidence cannot grant access (Drill 6 partial)") + manifestRaw := bytes9C(0x31) + phase9C.manifestDigest = billingmigration.FormatDigest(manifestRaw) + phase9C.manifestID = "msm_demo9c_" + strconv.FormatInt(phase9C.now.UnixNano(), 36) + if err := phase9C.repository.AppendManifest(d.ctx, 2, billingmigration.ManifestWrite{ + ProjectID: phase9C.projectID, + Manifest: billingmigration.SourceManifest{ProgramID: phase9C.programID, StateVersion: 2, + ManifestID: phase9C.manifestID, AdapterVersion: billingmigration.AdapterVersion, + ProviderAPIVersion: billingmigration.ProviderAPIV2, SchemaVersion: "demo9c-v1", + RecordCount: 1, CurrentAccessRecordCount: 0, CapturedAt: phase9C.now}, + ObjectKey: "private/demo9c/synthetic.enc", ObjectChecksum: bytes9C(0x30), ObjectSizeBytes: 128, + ManifestDigest: manifestRaw, SourceWatermark: "demo9c-historical-watermark", + }); err != nil { + return err + } + recordID := "msr_demo9c_" + strconv.FormatInt(phase9C.now.UnixNano(), 36) + _, err := d.pool.Exec(d.ctx, `INSERT INTO billing_migration_source_records( + id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,source_cursor, + record_digest,current_access,normalization_schema_version,evidence_kind,observed_at,created_at) + VALUES($1,$2,$3,$4,'transaction','expired_unrevalidatable','1','historical-cursor',$5,false, + 'demo9c-v1','historical_informational',$6,$6)`, recordID, phase9C.programID, phase9C.projectID, + phase9C.manifestID, bytes9C(0x32), phase9C.now.Add(-365*24*time.Hour)) + if err != nil { + return err + } + var current bool + var factCount, pointerCount int + if err = d.pool.QueryRow(d.ctx, `SELECT current_access FROM billing_migration_source_records WHERE id=$1`, recordID).Scan(¤t); err != nil { + return err + } + if err = d.pool.QueryRow(d.ctx, `SELECT count(*) FROM billing_transaction_facts WHERE project_id=$1`, phase9C.projectID).Scan(&factCount); err != nil { + return err + } + if err = d.pool.QueryRow(d.ctx, `SELECT count(*) FROM billing_migration_scope_current_pointers WHERE project_id=$1`, phase9C.projectID).Scan(&pointerCount); err != nil { + return err + } + if current || factCount != 0 || pointerCount != 0 { + return fmt.Errorf("historical evidence affected authority: current=%v facts=%d pointers=%d", current, factCount, pointerCount) + } + d.note("historical source record %s retained as informational evidence", recordID) + d.note("it created zero Transaction Facts and zero live migration pointers") + return nil +} + +func (d *demo) stage9CWorkerResume() error { + d.section("9C-4", "Expired import lease resumes without stale-worker settlement (Drill 16 import subset)") + var foreignPending int + if err := d.pool.QueryRow(d.ctx, `SELECT count(*) FROM billing_migration_import_batches + WHERE project_id<>$1 AND (status='pending' OR (status='running' AND lease_expires_at<=now()))`, + phase9C.projectID).Scan(&foreignPending); err != nil { + return err + } + if foreignPending != 0 { + return fmt.Errorf("isolated drill database required: found %d claimable foreign import batches", foreignPending) + } + batch, replay, err := phase9C.service.CreateImportBatch(d.ctx, billingmigration.Actor{ID: phase9C.adminID}, billingmigration.CreateImportBatchInput{ + ProjectID: phase9C.projectID, ProgramID: phase9C.programID, ManifestID: phase9C.manifestID, + MappingSetID: phase9C.mappingSetID, IdempotencyKey: "demo9c-import-batch", ExpectedStateVersion: 2, + RecordCount: 1, CursorBefore: "historical-cursor-before", + }) + if err != nil || replay { + return fmt.Errorf("create import batch: replay=%v: %w", replay, err) + } + first, leased, err := phase9C.repository.LeaseImportBatch(d.ctx, "demo9c-worker-crashed", phase9C.now, phase9C.now.Add(time.Second)) + if err != nil || !leased || first.BatchID != batch.BatchID { + return fmt.Errorf("first lease: leased=%v batch=%s err=%w", leased, first.BatchID, err) + } + second, leased, err := phase9C.repository.LeaseImportBatch(d.ctx, "demo9c-worker-resumed", phase9C.now.Add(2*time.Second), phase9C.now.Add(time.Minute)) + if err != nil || !leased || second.LeaseGeneration <= first.LeaseGeneration { + return fmt.Errorf("resumed lease: leased=%v generations=%d/%d err=%w", leased, first.LeaseGeneration, second.LeaseGeneration, err) + } + staleErr := phase9C.repository.CompleteImportBatch(d.ctx, phase9C.projectID, phase9C.programID, batch.BatchID, + "demo9c-worker-crashed", first.LeaseGeneration, "stale-cursor", 0, 0, phase9C.now.Add(3*time.Second)) + if !errors.Is(staleErr, billingmigration.ErrConflict) { + return fmt.Errorf("stale worker settlement error=%v", staleErr) + } + if err = phase9C.repository.CompleteImportBatch(d.ctx, phase9C.projectID, phase9C.programID, batch.BatchID, + "demo9c-worker-resumed", second.LeaseGeneration, "historical-cursor-after", 0, 0, phase9C.now.Add(3*time.Second)); err != nil { + return err + } + d.note("lease generation advanced %d -> %d after simulated crash/expiry", first.LeaseGeneration, second.LeaseGeneration) + d.note("the stale owner was rejected; the resumed owner committed the checkpoint once") + d.note("validation, shadow, divergence, and Repair worker crash paths are not exercised by this subset") + return nil +} + +func (d *demo) stage9CReadinessAndOutage() error { + d.section("9C-5", "Fail-closed readiness and source outage (Drills 12/18 partial)") + readyExceptClient := billingmigration.ReadinessInput{ + CurrentAccessMappingPercent: 100, CurrentAccessEvidencePercent: 100, + FinalDeltaCompleted: true, WatermarksFresh: true, SupportedVersionsAuthorityAware: false, + } + assessment, err := billingmigration.AssessReadiness(phase9C.programID, 2, readyExceptClient) + if err != nil || assessment.Ready { + return fmt.Errorf("old-client readiness gate: ready=%v err=%w", assessment.Ready, err) + } + d.note("with every other readiness input satisfied, an unsupported application version still returns ready=false") + + before := 0 + if err = d.pool.QueryRow(d.ctx, `SELECT count(*) FROM billing_migration_programs WHERE project_id=$1`, phase9C.projectID).Scan(&before); err != nil { + return err + } + outageService := billingmigration.NewService(phase9C.repository, phase9C.cipher, + demo9CAssessor{now: phase9C.now, err: billingmigration.ErrUnavailable}, + billingmigration.WithClock(func() time.Time { return phase9C.now.Add(time.Minute) })) + _, _, outageErr := outageService.CreateProgram(d.ctx, billingmigration.Actor{ID: phase9C.ownerID}, billingmigration.CreateProgramInput{ + ProjectID: phase9C.projectID, EnvironmentID: phase9C.environmentID, + Applications: []billingmigration.ScopeItem{{ApplicationID: phase9C.applicationID, Platform: "ios"}}, + ExternalProjectID: "rc_demo9c_outage", Credential: []byte("synthetic-outage-secret"), + IdempotencyKey: "demo9c-source-outage", StabilizationDays: 7, RollbackWindowDays: 7, + }) + if !errors.Is(outageErr, billingmigration.ErrUnavailable) { + return fmt.Errorf("source outage error=%v", outageErr) + } + after := 0 + var authority string + var epoch int64 + if err = d.pool.QueryRow(d.ctx, `SELECT count(*) FROM billing_migration_programs WHERE project_id=$1`, phase9C.projectID).Scan(&after); err != nil { + return err + } + if err = d.pool.QueryRow(d.ctx, `SELECT current_authority,current_epoch FROM billing_migration_authority_scopes WHERE project_id=$1 AND application_id=$2`, phase9C.projectID, phase9C.applicationID).Scan(&authority, &epoch); err != nil { + return err + } + if after != before || authority != "source" || epoch != 0 { + return fmt.Errorf("source outage changed state: programs=%d/%d authority=%s/%d", before, after, authority, epoch) + } + d.note("synthetic source assessment outage returned dependency_unavailable without creating a program or changing source authority") + d.note("cursor preservation and retry-after-recovery require a queued source-pull job and are not claimed") + return nil +} + +func (d *demo) stage9CReport() error { + d.section("9C-6", "Truthful drill coverage report") + d.note("exercised risk controls (not full drill acceptance): Drill 6 access isolation; Drill 16 import lease crash/resume") + d.note("partial only: Drill 1 synthetic assessment; Drill 2 exact immutable mappings; Drill 12 readiness gate; Drill 18 fail-closed assessment outage") + d.note("not exercised: Drills 3-5, 7-11, 13-15, 17, 19-20") + d.note("required external evidence still absent: live RevenueCat/API data, Apple/Google sandboxes, three SDK runtimes, real webhook receiver, representative scale, and elapsed stabilization/retention windows") + d.note("audit coordinates: project=%s program=%s manifest=%s mapping=%s", phase9C.projectID, phase9C.programID, phase9C.manifestID, phase9C.mappingSetID) + return nil +} + +func bytes9C(value byte) []byte { + result := make([]byte, 32) + for index := range result { + result[index] = value + } + return result +} diff --git a/apps/api/cmd/billingdemo/main.go b/apps/api/cmd/billingdemo/main.go index 77322acf..5d0bafaf 100644 --- a/apps/api/cmd/billingdemo/main.go +++ b/apps/api/cmd/billingdemo/main.go @@ -157,7 +157,7 @@ type demo struct { } func run() error { - phase := flag.String("phase", "9b", "which demonstration to run: 9a, 9b, all, or oneminute") + phase := flag.String("phase", "9b", "which demonstration to run: 9a, 9b, 9c, all, or oneminute") flag.Parse() databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) @@ -186,6 +186,8 @@ func run() error { stages = d.stages9A() case "9b": stages = d.stages9B() + case "9c": + stages = d.stages9C() case "all": stages = append(d.stages9A(), d.stages9B()...) case "oneminute": diff --git a/apps/api/cmd/keyring/main.go b/apps/api/cmd/keyring/main.go index 142b1e60..2fe8ac12 100644 --- a/apps/api/cmd/keyring/main.go +++ b/apps/api/cmd/keyring/main.go @@ -1,8 +1,10 @@ -// Command keyring operates on Mosaic's provider-credential keyring. +// Command keyring operates on Mosaic's encrypted-storage keyrings. // -// keyring validate check MOSAIC_PROVIDER_CREDENTIAL_KEYRING is usable -// keyring inspect report envelope counts per key ID -// keyring rotate re-encrypt every envelope under the active key +// keyring validate check MOSAIC_PROVIDER_CREDENTIAL_KEYRING is usable +// keyring inspect report provider envelope counts per key ID +// keyring rotate re-encrypt mutable provider envelopes under the active key +// keyring validate --category migration-source check MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING is usable +// keyring inspect --category migration-source report immutable source-object counts per key ID // // The command never prints key material, ciphertext, or decrypted credentials. // Rotation requires every key that currently seals an envelope to still be @@ -21,6 +23,8 @@ import ( "time" "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationobject" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/cloudworkspacepostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/config" @@ -28,6 +32,11 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" ) +const ( + categoryProviderCredentials = "provider" + categoryMigrationSource = "migration-source" +) + func main() { if err := run(os.Args[1:]); err != nil { fmt.Fprintf(os.Stderr, "keyring: %v\n", err) @@ -38,6 +47,7 @@ func main() { func run(args []string) error { action, flagArguments := splitArguments(args) flags := flag.NewFlagSet("keyring", flag.ContinueOnError) + category := flags.String("category", categoryProviderCredentials, "keyring category: provider or migration-source") batchSize := flags.Int("batch-size", 100, "envelopes re-encrypted per transaction") dryRun := flags.Bool("dry-run", false, "report what rotation would do without writing") timeout := flags.Duration("timeout", 30*time.Minute, "maximum duration for the whole command") @@ -45,16 +55,22 @@ func run(args []string) error { return err } if action == "" || len(flags.Args()) != 0 { - return errors.New("usage: keyring [flags]") + return errors.New("usage: keyring [--category provider|migration-source] [flags]") } if *batchSize < 1 { return errors.New("--batch-size must be at least 1") } + if err := validateCategoryAction(*category, action); err != nil { + return err + } cfg, err := config.Load() if err != nil { return fmt.Errorf("load configuration: %w", err) } + if *category == categoryMigrationSource { + return runMigrationSourceKeyring(action, cfg, *timeout) + } if cfg.Providers.CredentialKeyring == "" { return errors.New("MOSAIC_PROVIDER_CREDENTIAL_KEYRING is not set") } @@ -99,6 +115,96 @@ func run(args []string) error { } } +func validateCategoryAction(category, action string) error { + if action != "validate" && action != "inspect" && action != "rotate" { + return fmt.Errorf("unsupported keyring action %q", action) + } + switch category { + case categoryProviderCredentials: + return nil + case categoryMigrationSource: + if action == "rotate" { + return errors.New("migration source objects are immutable and cannot be resealed; add a new active key for new writes, retain old keys while objects exist, and delete retained objects before removing their keys") + } + return nil + default: + return fmt.Errorf("unsupported keyring category %q", category) + } +} + +func runMigrationSourceKeyring(action string, cfg config.Config, timeout time.Duration) error { + if cfg.Migration.SourceObjectKeyring == "" { + return errors.New("MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING is not set") + } + cipher, err := billingmigrationobject.NewKeyringCipher(cfg.Migration.SourceObjectKeyring, cfg.Migration.SourceObjectChunkBytes) + if err != nil { + return fmt.Errorf("the configured migration source-object keyring is not usable: %w", err) + } + if action == "validate" { + fmt.Printf("migration source-object keyring is valid\nactive key id: %s\nkey ids: %v\n", cipher.ActiveKeyID(), cipher.KeyIDs()) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + pool, err := database.Open(ctx, database.Config{ + URL: cfg.Database.URL, MaxConnections: cfg.Database.MaxConnections, + MinConnections: cfg.Database.MinConnections, ConnectTimeout: cfg.Database.ConnectTimeout, + StatementTimeout: cfg.Database.StatementTimeout, LockTimeout: cfg.Database.LockTimeout, + }) + if err != nil { + return fmt.Errorf("initialize database: %w", err) + } + defer pool.Close() + return inspectMigrationSourceObjects(ctx, billingmigrationpostgres.New(pool), cipher) +} + +type sourceObjectKeyInventory interface { + SourceObjectEnvelopeCountsByKeyID(context.Context) (map[string]int64, error) +} + +func inspectMigrationSourceObjects(ctx context.Context, inventory sourceObjectKeyInventory, cipher *billingmigrationobject.Cipher) error { + counts, err := inventory.SourceObjectEnvelopeCountsByKeyID(ctx) + if err != nil { + return err + } + known := make(map[string]struct{}, len(cipher.KeyIDs())) + for _, id := range cipher.KeyIDs() { + known[id] = struct{}{} + } + ids := make([]string, 0, len(counts)) + for id := range counts { + ids = append(ids, id) + } + sort.Strings(ids) + + fmt.Printf("active key id: %s\n\n", cipher.ActiveKeyID()) + fmt.Printf("%-32s %-10s %s\n", "KEY ID", "OBJECTS", "STATUS") + missing := 0 + for _, id := range ids { + status := "retired (still in keyring)" + switch { + case id == cipher.ActiveKeyID(): + status = "active" + default: + if _, ok := known[id]; !ok { + status = "MISSING FROM KEYRING" + missing++ + } + } + fmt.Printf("%-32s %-10d %s\n", id, counts[id], status) + } + for _, id := range cipher.KeyIDs() { + if _, ok := counts[id]; !ok { + fmt.Printf("%-32s %-10d %s\n", id, 0, "unused") + } + } + if missing > 0 { + return fmt.Errorf("%d key id(s) sealing retained migration source objects are absent from MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING; those immutable objects cannot be decrypted", missing) + } + return nil +} + func inspect(ctx context.Context, repository *cloudworkspacepostgres.Repository, billingRepository *billingpostgres.Repository, cipher *providercredential.AESGCMCipher) error { counts, err := repository.CredentialCountsByKeyID(ctx) if err != nil { diff --git a/apps/api/cmd/keyring/main_test.go b/apps/api/cmd/keyring/main_test.go new file mode 100644 index 00000000..11345766 --- /dev/null +++ b/apps/api/cmd/keyring/main_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "encoding/base64" + "strings" + "testing" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationobject" +) + +type sourceObjectInventoryStub map[string]int64 + +func (s sourceObjectInventoryStub) SourceObjectEnvelopeCountsByKeyID(context.Context) (map[string]int64, error) { + return s, nil +} + +func sourceObjectTestCipher(t *testing.T) *billingmigrationobject.Cipher { + t.Helper() + key := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + cipher, err := billingmigrationobject.NewKeyringCipher(`{"version":1,"activeKeyId":"current","keys":{"current":"`+key+`"}}`, 0) + if err != nil { + t.Fatal(err) + } + return cipher +} + +func TestMigrationSourceInspectFailsClosedForMissingStoredObjectKey(t *testing.T) { + err := inspectMigrationSourceObjects(context.Background(), sourceObjectInventoryStub{"retired-missing": 2}, sourceObjectTestCipher(t)) + if err == nil || !strings.Contains(err.Error(), "absent from MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING") { + t.Fatalf("missing-key error = %v", err) + } +} + +func TestMigrationSourceRotateRefusesImmutableObjectReseal(t *testing.T) { + err := validateCategoryAction(categoryMigrationSource, "rotate") + if err == nil || !strings.Contains(err.Error(), "immutable and cannot be resealed") { + t.Fatalf("rotation refusal = %v", err) + } +} diff --git a/apps/api/cmd/worker/main.go b/apps/api/cmd/worker/main.go index 5e433e23..28f2b1cc 100644 --- a/apps/api/cmd/worker/main.go +++ b/apps/api/cmd/worker/main.go @@ -24,6 +24,7 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/billing" "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" "github.com/Mujhtech/mosaic/apps/api/internal/billingrestore" "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" @@ -36,6 +37,11 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingcustomerpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingdiagnosticspostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingkeys" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationevaluation" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationobject" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationrepair" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationvalidation" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingprojectionpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingrestorepostgres" @@ -70,6 +76,30 @@ type jobFamily struct { process func(context.Context, string) (bool, error) } +// migrationSourceObjectDeleter keeps retention deletion pinned to the private +// migration bucket. The public asset store is deliberately not accepted here. +type migrationSourceObjectDeleter struct{ store *objectstoreminio.Store } + +func (d migrationSourceObjectDeleter) DeleteRawSourceObject(ctx context.Context, key string) (string, error) { + if err := d.store.Delete(ctx, key); err != nil { + return "retryable_failure", err + } + return "deleted", nil +} + +type migrationRetentionProcessor struct { + service *billingmigration.OperationsService + deleter migrationSourceObjectDeleter +} + +func (p migrationRetentionProcessor) ProcessNext(ctx context.Context, workerID string) (bool, error) { + err := p.service.RunRetention(ctx, workerID, 2*time.Minute, p.deleter) + if errors.Is(err, billingmigration.ErrNotFound) { + return false, nil + } + return true, err +} + func run() (runErr error) { cfg, err := config.Load() if err != nil { @@ -80,22 +110,46 @@ func run() (runErr error) { return fmt.Errorf("configure logging: %w", err) } build := buildinfo.Current() - logger = logger.With(). - Str("service", cfg.Telemetry.ServiceName). - Str("environment", cfg.Environment). - Str("version", build.Version). - Logger() + // Applied to whichever logger ends up in use, so the local stream and the + // exported records carry the same service identity. + withServiceContext := func(base zerolog.Logger) zerolog.Logger { + return base.With(). + Str("service", cfg.Telemetry.ServiceName). + Str("environment", cfg.Environment). + Str("version", build.Version). + Logger() + } + logger = withServiceContext(logger) runContext, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() shutdownTelemetry, err := telemetry.New(runContext, telemetry.Config{ ServiceName: cfg.Telemetry.ServiceName, Environment: cfg.Environment, OTLPEndpoint: cfg.Telemetry.OTLPEndpoint, - Logger: logger, + OTLPProtocol: cfg.Telemetry.OTLPProtocol, + OTLPHeaders: cfg.Telemetry.OTLPHeaders, + // Startup validation has already refused an unverified collector in a + // production-like environment unless it was explicitly acknowledged. + OTLPTLSSkipVerify: cfg.Telemetry.TLSSkipVerify, + DisableLogExport: !cfg.Telemetry.ExportLogs(), + // Telemetry keeps the local-only logger: routing its own export failures + // through the exporting logger would feed the failing exporter. + Logger: logger, }) if err != nil { return fmt.Errorf("configure telemetry: %w", err) } + if cfg.Telemetry.ExportLogs() { + // Swapped in only once the logger provider exists, so every record this + // logger writes locally also reaches the collector. + exportingLogger, err := logging.NewExporting( + cfg.Log.Level, cfg.Log.Format, os.Stdout, cfg.Telemetry.ServiceName, + ) + if err != nil { + return fmt.Errorf("configure log export: %w", err) + } + logger = withServiceContext(exportingLogger) + } defer func() { shutdownContext, cancel := context.WithTimeout(context.Background(), cfg.HTTP.TelemetryShutdownTimeout) defer cancel() @@ -158,6 +212,7 @@ func run() (runErr error) { var billingService *billing.Service var billingRepository *billingpostgres.Repository + var projectionRepository *billingprojectionpostgres.Repository var projectionService *billingprojection.Service var restoreService *billingrestore.Service var restoreRepository *billingrestorepostgres.Repository @@ -193,7 +248,8 @@ func run() (runErr error) { return fmt.Errorf("configure Google Play client: %w", err) } billingRepository = billingpostgres.New(pool) - projectionService = billingprojection.NewService(billingprojectionpostgres.New(pool)) + projectionRepository = billingprojectionpostgres.New(pool) + projectionService = billingprojection.NewService(projectionRepository) // The worker is where the Phase 9A→9B seam matters most: it runs the // validation job, so it is where a committed fact has to reach a Purchase // Lineage and a Billing Customer. The identity and access services are @@ -219,6 +275,66 @@ func run() (runErr error) { cfg.Billing.WebhookAllowPrivateDestinations))) } + var migrationSourcePull *billingmigration.SourcePullProcessor + var migrationSourceExecution *billingmigration.SourceExecutionProcessor + var migrationTransitionDelivery *billingmigration.TransitionDeliveryService + var migrationRetention migrationRetentionProcessor + if cfg.Migration.Enabled { + if billingService == nil { + return errors.New("billing migration execution requires billing service") + } + migrationObjectStore, err := objectstoreminio.New(objectstoreminio.Config{ + Endpoint: cfg.ObjectStore.Endpoint, AccessKey: cfg.ObjectStore.AccessKey, + SecretKey: cfg.ObjectStore.SecretKey, Bucket: cfg.Migration.SourceObjectBucket, + UseTLS: cfg.ObjectStore.UseTLS, OperationTimeout: cfg.Migration.SourceObjectOperationTimeout, + CheckTimeout: cfg.ObjectStore.CheckTimeout, + }) + if err != nil { + return fmt.Errorf("configure migration source object storage: %w", err) + } + if err = migrationObjectStore.Check(runContext); err != nil { + return fmt.Errorf("initialize migration source object storage: %w", err) + } + migrationObjectCipher, err := billingmigrationobject.NewKeyringCipher( + cfg.Migration.SourceObjectKeyring, cfg.Migration.SourceObjectChunkBytes) + if err != nil { + return fmt.Errorf("configure migration source object encryption: %w", err) + } + migrationCredentialCipher, err := providercredential.NewAESGCMCipher( + cfg.Providers.CredentialKeyring, rand.Reader) + if err != nil { + return fmt.Errorf("configure migration provider credential encryption: %w", err) + } + migrationRevenueCat, err := revenuecat.New(revenuecat.Config{ + BaseURL: cfg.Providers.RevenueCatBaseURL, RequestTimeout: cfg.Providers.RequestTimeout, + OperationTimeout: cfg.Providers.OperationTimeout, + ConnectTimeout: cfg.Providers.ConnectTimeout, MaxResponseBytes: cfg.Providers.MaxResponseBytes, + MaxAttempts: cfg.Providers.MaxAttempts, + }) + if err != nil { + return fmt.Errorf("configure RevenueCat migration source adapter: %w", err) + } + migrationRepository := billingmigrationpostgres.New(pool) + migrationRepairExecutor := billingmigrationrepair.NewProductionExecutor( + billingmigrationrepair.NewPostgresStore(pool), + billingmigrationvalidation.New(billingService), + projectionService, + projectionRepository) + migrationIngestor := billingmigration.NewSourceObjectIngestor( + migrationRepository, migrationObjectStore, migrationObjectCipher, nil) + migrationSourcePull = billingmigration.NewSourcePullProcessor( + migrationRepository, migrationRevenueCat, migrationIngestor, migrationCredentialCipher, nil) + migrationEvaluator := billingmigrationevaluation.New(pool) + migrationSourceExecution = billingmigration.NewSourceExecutionProcessor( + migrationRepository, billingmigrationvalidation.New(billingService), + migrationEvaluator, migrationEvaluator, nil) + migrationTransitionDelivery = billingmigration.NewTransitionDeliveryService(migrationRepository, nil) + migrationRetention = migrationRetentionProcessor{ + service: billingmigration.NewOperationsService(migrationRepository, migrationRepository, migrationRepairExecutor), + deleter: migrationSourceObjectDeleter{store: migrationObjectStore}, + } + } + workerID, err := os.Hostname() if err != nil || workerID == "" { workerID = "mosaic-worker" @@ -265,7 +381,7 @@ func run() (runErr error) { } } - families := make([]jobFamily, 0, 8) + families := make([]jobFamily, 0, 24) if providerService != nil { families = append(families, jobFamily{"provider_sync", providerService.ProcessNextProviderSync}) } @@ -277,6 +393,7 @@ func run() (runErr error) { // latencies are one user-visible number. families = append(families, jobFamily{"billing_validation", billingService.ProcessNextValidation}, + jobFamily{"billing_identity_binding", billingService.ProcessNextIdentityBinding}, jobFamily{"billing_projection", projectionService.ProcessNextProjection}, // A restore's outcome is only knowable once validation and // projection have moved, so it runs immediately after them: any @@ -295,6 +412,16 @@ func run() (runErr error) { jobFamily{"billing_retention", billingService.ProcessRetention}, ) } + if migrationSourcePull != nil { + families = append(families, + jobFamily{"billing_migration_source_pull", migrationSourcePull.ProcessNext}, + jobFamily{"billing_migration_import_validation", migrationSourceExecution.ProcessNextImport}, + jobFamily{"billing_migration_prepared_snapshot", migrationSourceExecution.ProcessNextRun}, + jobFamily{"billing_migration_final_delta", migrationSourceExecution.ProcessNextFinalDelta}, + jobFamily{"billing_migration_transition_delivery", migrationTransitionDelivery.ProcessOne}, + jobFamily{"billing_migration_retention", migrationRetention.ProcessNext}, + ) + } families = append(families, jobFamily{"analytics", analyticsService.ProcessNextJob}, jobFamily{"experiment_schedule", experimentService.ProcessNextSchedule}, @@ -344,6 +471,9 @@ func run() (runErr error) { if billingService != nil && cfg.Billing.WorkerPollInterval < interval { interval = cfg.Billing.WorkerPollInterval } + if migrationSourcePull != nil && cfg.Migration.WorkerPollInterval < interval { + interval = cfg.Migration.WorkerPollInterval + } select { case <-runContext.Done(): logger.Info().Msg("worker stopped gracefully") diff --git a/apps/api/go.mod b/apps/api/go.mod index c2dcc8f3..e6259e44 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -20,13 +20,20 @@ require ( github.com/rs/zerolog v1.34.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 + go.opentelemetry.io/otel/log v0.20.0 go.opentelemetry.io/otel/metric v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/sdk/log v0.20.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 golang.org/x/crypto v0.54.0 + google.golang.org/grpc v1.82.1 ) require ( @@ -66,6 +73,5 @@ require ( golang.org/x/text v0.40.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect - google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/apps/api/go.sum b/apps/api/go.sum index 8714d9af..fc37a32c 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -108,18 +108,32 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= +go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY= +go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= diff --git a/apps/api/internal/billing/migration_import.go b/apps/api/internal/billing/migration_import.go new file mode 100644 index 00000000..b5497226 --- /dev/null +++ b/apps/api/internal/billing/migration_import.go @@ -0,0 +1,112 @@ +package billing + +import ( + "context" + "encoding/json" + "strings" + "time" +) + +const ( + SourceMigrationKnownReference = "migration_known_reference" + MigrationValidationAccepted = "accepted" + MigrationValidationValidated = "validated" + MigrationValidationQuarantined = "quarantined" + + DiagnosticMigrationApplicationMismatch = "migration_expected_application_mismatch" + DiagnosticMigrationProviderProductMismatch = "migration_expected_provider_product_mismatch" + DiagnosticMigrationMosaicProductMismatch = "migration_expected_mosaic_product_mismatch" + DiagnosticMigrationMosaicProductUnresolved = "migration_expected_mosaic_product_unresolved" + DiagnosticMigrationStoreEnvironmentMismatch = "migration_expected_store_environment_mismatch" +) + +// AcceptMigrationValidation durably creates or reuses the ordinary Phase 9A +// Raw Input and validation job for a known provider reference. Acceptance does +// not imply validation; callers must read the authoritative terminal outcome. +func (s *Service) AcceptMigrationValidation(ctx context.Context, request MigrationValidationRequest) (MigrationValidationAcceptance, error) { + request.Reference = strings.TrimSpace(request.Reference) + request.ExpectedStoreProductIdentifier = strings.TrimSpace(request.ExpectedStoreProductIdentifier) + request.ExpectedStoreEnvironment = normalizeMigrationStoreEnvironment(request.ExpectedStoreEnvironment) + if request.ProgramID == "" || request.ProjectID == "" || request.EnvironmentID == "" || request.ApplicationID == "" || + request.Reference == "" || request.ExpectedStoreProductIdentifier == "" || request.ExpectedMosaicProductID == "" || request.ExpectedStoreEnvironment == "" { + return MigrationValidationAcceptance{}, ErrInvalid + } + validKind := (request.Provider == ProviderAppStore && request.ReferenceKind == ReferenceAppStoreTransactionID) || + (request.Provider == ProviderGooglePlay && (request.ReferenceKind == "google_play_purchase_token" || request.ReferenceKind == ReferenceGooglePlayOrderID)) + if !validKind { + return MigrationValidationAcceptance{}, ErrInvalid + } + mode, organizationID, err := s.repository.EnvironmentScope(ctx, request.ProjectID, request.EnvironmentID) + if err != nil { + return MigrationValidationAcceptance{}, err + } + now := s.now() + bodyRecord := map[string]string{"referenceKind": request.ReferenceKind} + var referenceDigest []byte + if request.Provider == ProviderAppStore { + bodyRecord["reference"] = request.Reference + referenceDigest = AppleTransactionKey(StoreUnclassified, request.Reference) + } else if request.ReferenceKind == "google_play_purchase_token" { + bodyRecord["purchaseToken"] = request.Reference + referenceDigest = TokenDigest(request.Reference) + } else { + bodyRecord["orderReference"] = request.Reference + referenceDigest = digestOf("mosaic-billing-google-order-v1", request.Reference) + } + body, err := json.Marshal(bodyRecord) + if err != nil { + return MigrationValidationAcceptance{}, ErrInvalid + } + bindingID, err := s.newID("bmv") + if err != nil { + return MigrationValidationAcceptance{}, ErrUnavailable + } + input := RawInput{ + ProjectID: request.ProjectID, OrganizationID: organizationID, + EnvironmentID: request.EnvironmentID, EnvironmentMode: mode, + ApplicationID: request.ApplicationID, Provider: request.Provider, + Source: SourceMigrationKnownReference, SourceAuthority: AuthorityStoreReconciliation, + IdempotencyKey: digestOf("mosaic-billing-migration-reference-v1", request.ProgramID, request.Provider, request.ReferenceKind, string(referenceDigest)), + ContentDigest: ContentDigest(body), TransactionReferenceDigest: referenceDigest, + AuthenticationResult: AuthVerifiedTransport, StoreEnvironment: StoreUnclassified, + IngestionStatus: IngestAccepted, CorrelationID: bindingID, + ReceivedAt: now, ExpiresAt: now.Add(s.retention), + } + if err := s.sealBody(&input, body); err != nil { + return MigrationValidationAcceptance{}, ErrUnavailable + } + binding := MigrationValidationBinding{ + ID: bindingID, ProgramID: request.ProgramID, ProjectID: request.ProjectID, + EnvironmentID: request.EnvironmentID, RawInputID: input.ID, Provider: request.Provider, + ReferenceKind: request.ReferenceKind, ReferenceDigest: referenceDigest, + ExpectedApplicationID: request.ApplicationID, + ExpectedStoreProductIdentifier: request.ExpectedStoreProductIdentifier, + ExpectedMosaicProductID: request.ExpectedMosaicProductID, + ExpectedStoreEnvironment: request.ExpectedStoreEnvironment, + Status: MigrationValidationAccepted, AcceptedAt: now, + } + return s.repository.PersistMigrationInput(ctx, input, binding, now) +} + +func normalizeMigrationStoreEnvironment(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case StoreProduction: + return StoreProduction + case StoreSandbox: + return StoreSandbox + default: + return "" + } +} + +func (s *Service) MigrationValidationOutcome(ctx context.Context, projectID, programID, bindingID string) (MigrationValidationBinding, error) { + return s.repository.MigrationValidationOutcome(ctx, projectID, programID, bindingID) +} + +func MigrationValidationEvidenceDigest(binding MigrationValidationBinding, attempt ValidationAttempt) []byte { + watermark := attempt.CompletedAt.UTC().Format(time.RFC3339Nano) + return digestOf("mosaic-billing-migration-validation-evidence-v1", binding.ProgramID, binding.ID, + binding.ExpectedApplicationID, binding.ExpectedStoreProductIdentifier, binding.ExpectedMosaicProductID, + binding.ExpectedStoreEnvironment, + attempt.ID, attempt.Outcome, attempt.DiagnosticCode, watermark) +} diff --git a/apps/api/internal/billing/model.go b/apps/api/internal/billing/model.go index 3b400b05..e12198c6 100644 --- a/apps/api/internal/billing/model.go +++ b/apps/api/internal/billing/model.go @@ -351,6 +351,29 @@ type RawInput struct { ProviderOccurredAt *time.Time ReceivedAt time.Time ExpiresAt time.Time + MigrationValidation *MigrationValidationBinding +} + +// MigrationValidationBinding is the immutable Phase 9C expectation attached +// to a known provider reference. It is validation context, never a Fact field. +type MigrationValidationBinding struct { + ID, ProgramID, ProjectID, EnvironmentID, RawInputID string + Provider, ReferenceKind, ExpectedApplicationID string + ExpectedStoreProductIdentifier, ExpectedMosaicProductID, ExpectedStoreEnvironment string + ReferenceDigest, EvidenceDigest []byte + Status, DiagnosticCode, ValidationAttemptID string + ProviderWatermark, AcceptedAt, CompletedAt time.Time +} + +type MigrationValidationRequest struct { + ProgramID, ProjectID, EnvironmentID, ApplicationID string + Provider, ReferenceKind, Reference string + ExpectedStoreProductIdentifier, ExpectedMosaicProductID string + ExpectedStoreEnvironment string +} + +type MigrationValidationAcceptance struct { + BindingID, RawInputID, Status string } // ValidationAttempt is one append-only record of one validation try. diff --git a/apps/api/internal/billing/repository.go b/apps/api/internal/billing/repository.go index ae9e5612..71d07adc 100644 --- a/apps/api/internal/billing/repository.go +++ b/apps/api/internal/billing/repository.go @@ -136,6 +136,21 @@ type AssociationCorrelator struct { Digest []byte } +// IdentityBindingJob is the durable, digest-only identity decision produced by +// one fact-bearing validation attempt. Its unique validation-attempt identity +// preserves new evidence from a deduplicated revalidation without repeating +// the provider call or appending another Transaction Fact. +type IdentityBindingJob struct { + ID string + ProjectID string + EnvironmentID string + ValidationAttemptID string + LeaseOwner string + AttemptCount int + MaxAttempts int + Binding FactBinding +} + // ResolutionRecord is the persisted Resolution Snapshot. type ResolutionRecord struct { ID string @@ -221,6 +236,8 @@ type Repository interface { // Intake. ResolveIntakeToken(ctx context.Context, tokenDigest []byte) (IntakeIdentity, error) PersistRawInput(ctx context.Context, input RawInput, enqueue bool, now time.Time) (PersistResult, error) + PersistMigrationInput(ctx context.Context, input RawInput, binding MigrationValidationBinding, now time.Time) (MigrationValidationAcceptance, error) + MigrationValidationOutcome(ctx context.Context, projectID, programID, bindingID string) (MigrationValidationBinding, error) RawInput(ctx context.Context, projectID, rawInputID string) (RawInput, error) // ApplicationForIdentifier maps a verified bundle id or package name onto an // Application inside the credential's scope. @@ -244,11 +261,10 @@ type Repository interface { // and no other worker can claim the job underneath it. LeaseValidationJobFor(ctx context.Context, workerID string, input RawInput, now, leaseUntil time.Time) (ValidationJob, error) CompleteAttempt(ctx context.Context, job ValidationJob, outcome AttemptOutcome, now time.Time) error - // ChainRootDigest resolves the root of the purchase chain a fact belongs to - // by walking supersession edges backwards. The seam needs it because the - // Purchase Lineage is keyed on the root, and a fact whose provider handed the - // chain a new token carries a digest that is not it. - ChainRootDigest(ctx context.Context, fact TransactionFact) ([]byte, error) + LeaseIdentityBindingJob(ctx context.Context, workerID string, now, leaseUntil time.Time) (IdentityBindingJob, bool, error) + CompleteIdentityBindingJob(ctx context.Context, job IdentityBindingJob, now time.Time) error + ParkIdentityBindingJob(ctx context.Context, job IdentityBindingJob, reason string, now time.Time) error + RetryIdentityBindingJob(ctx context.Context, job IdentityBindingJob, reason string, availableAt, now time.Time) error // ParkValidationJob returns a job to the queue without consuming an attempt, // for conditions that are expected to resolve without operator action. ParkValidationJob(ctx context.Context, job ValidationJob, reason string, now time.Time) error diff --git a/apps/api/internal/billing/service_worker.go b/apps/api/internal/billing/service_worker.go index 6136e63a..11d61236 100644 --- a/apps/api/internal/billing/service_worker.go +++ b/apps/api/internal/billing/service_worker.go @@ -22,6 +22,10 @@ import ( // validationLease bounds how long one worker may hold a validation job. const validationLease = 2 * time.Minute +// identityBindingLease is short because binding performs only PostgreSQL-backed +// identity decisions and no store-provider call. +const identityBindingLease = 60 * time.Second + // ProcessNextValidation leases and runs one validation job. It matches the // (processed, error) contract every other Mosaic job family uses so the worker // loop treats billing exactly like analytics and Experiment scheduling. @@ -61,55 +65,47 @@ func (s *Service) ProcessNextValidation(ctx context.Context, workerID string) (b if err := s.repository.CompleteAttempt(ctx, job, outcome, s.now()); err != nil { return true, safeFailure(err, "billing_attempt_write_failed") } - if err := s.bindFactIdentity(ctx, outcome); err != nil { - // The fact and its lineage are committed; only the identity decision - // failed. The job is still `processed` — re-leasing it would re-run the - // provider call and re-record an attempt for work that succeeded — but - // the error is reported so the worker's failure signal and its metrics - // see it. The lineage is left unassociated, which is exactly what - // `unresolvedLineages` on the projection-health surface counts, and the - // next fact on the same chain retries the decision. - return true, safeFailure(err, "billing_lineage_bind_failed") - } return true, nil } -// bindFactIdentity runs the identity half of the Phase 9A→9B seam. -// -// It is outside CompleteAttempt's transaction on purpose. The structural half — -// the lineage row and its projection instance — is written inside that -// transaction because it is a deterministic function of the fact and must be -// exactly as durable as it. Deciding *who owns* the lineage reads alias -// resolutions and prior evidence and can open an operator conflict, which is -// application logic rather than a write, and holding the fact's transaction open -// across it would put the ledger's hot path behind the identity module. -func (s *Service) bindFactIdentity(ctx context.Context, outcome AttemptOutcome) error { - if s.lineages == nil || outcome.Fact == nil || len(outcome.Fact.PurchaseChainDigest) == 0 { - return nil - } - fact := *outcome.Fact - root, err := s.repository.ChainRootDigest(ctx, fact) +// ProcessNextIdentityBinding runs the durable identity half of the Phase +// 9A→9B seam. Validation has already committed; failures requeue this job and +// never repeat the provider request, validation attempt, or Transaction Fact. +func (s *Service) ProcessNextIdentityBinding(ctx context.Context, workerID string) (bool, error) { + now := s.now() + job, leased, err := s.repository.LeaseIdentityBindingJob(ctx, workerID, now, now.Add(identityBindingLease)) if err != nil { - return err - } - if len(root) == 0 { - root = fact.PurchaseChainDigest - } - acquiredAt := fact.OccurredAt - if fact.PeriodStartAt != nil && fact.PeriodStartAt.Before(acquiredAt) { - acquiredAt = *fact.PeriodStartAt - } - return s.lineages.BindFact(ctx, FactBinding{ - ProjectID: fact.ProjectID, - EnvironmentID: fact.EnvironmentID, - Provider: fact.Provider, - LineageKeyDigest: root, - FactChainDigest: fact.PurchaseChainDigest, - RawInputID: fact.SourceRawInputID, - ReferenceDigests: outcome.ReferenceDigests, - Correlators: outcome.Correlators, - AcquiredAt: acquiredAt, + return false, safeFailure(err, "billing_identity_binding_lease_failed") + } + if !leased { + return false, nil + } + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: job.ID, JobKind: "billing_identity_binding", + ProjectID: job.ProjectID, EnvironmentID: job.EnvironmentID, ResourceID: job.Binding.RawInputID, }) + if s.lineages == nil { + return true, s.repository.ParkIdentityBindingJob(ctx, job, "identity_binding_unavailable", s.now()) + } + if !s.billingEnabled(ctx, job.ProjectID) { + return true, s.repository.ParkIdentityBindingJob(ctx, job, "billing_disabled", s.now()) + } + + ctx, span := s.tracer.Start(ctx, "billing.identity.bind") + defer span.End() + if err := s.lineages.BindFact(ctx, job.Binding); err != nil { + completed := s.now() + delay := time.Duration(1< 64 { return nil, ErrInvalid } @@ -200,7 +234,9 @@ func (s *Service) Check(ctx context.Context, rawKey string, environmentID string record := CheckResultRecord(request.CustomerID, scope.ProjectID, environmentID, nil, request.EntitlementKeys, issuedAt, request.CorrelationID, "provider_unavailable", "billing_disabled") - return CanonicalJSON(Envelope("entitlementCheckResult", record)) + payload, encodeErr := CanonicalJSON(Envelope("entitlementCheckResult", record)) + failed = encodeErr != nil + return payload, encodeErr } if _, err := s.repository.Customer(ctx, scope.ProjectID, request.CustomerID); err != nil { @@ -217,7 +253,9 @@ func (s *Service) Check(ctx context.Context, rawKey string, environmentID string record := CheckResultRecord(request.CustomerID, scope.ProjectID, environmentID, nil, request.EntitlementKeys, issuedAt, request.CorrelationID, "missing_fact", "no_qualifying_source") - return CanonicalJSON(Envelope("entitlementCheckResult", record)) + payload, encodeErr := CanonicalJSON(Envelope("entitlementCheckResult", record)) + failed = encodeErr != nil + return payload, encodeErr } if status, statusErr := s.repository.ProjectionStatusFor(ctx, scope.ProjectID, environmentID, request.CustomerID); statusErr == nil { view.Projection = status @@ -226,7 +264,9 @@ func (s *Service) Check(ctx context.Context, rawKey string, environmentID string span.SetAttributes(attribute.Int64("mosaic.billing.check.version", view.SnapshotVersion)) record := CheckResultRecord(request.CustomerID, scope.ProjectID, environmentID, &view, request.EntitlementKeys, issuedAt, request.CorrelationID, "", "") - return CanonicalJSON(Envelope("entitlementCheckResult", record)) + payload, encodeErr := CanonicalJSON(Envelope("entitlementCheckResult", record)) + failed = encodeErr != nil + return payload, encodeErr } // Snapshot reads a customer's current snapshot for a trusted server. The read is diff --git a/apps/api/internal/billingmigration/capability_assessment.go b/apps/api/internal/billingmigration/capability_assessment.go new file mode 100644 index 00000000..09d2ea5a --- /dev/null +++ b/apps/api/internal/billingmigration/capability_assessment.go @@ -0,0 +1,74 @@ +package billingmigration + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "time" +) + +const ( + SourceCapabilityReadCustomers = "read_customers" + SourceCapabilityReadSubscriptions = "read_subscriptions" + SourceCapabilityReadAliases = "read_aliases" + SourceCapabilityIncrementalDelta = "incremental_delta" +) + +type CapabilityAssessmentAppend struct { + ProgramID, ProjectID string + StateVersion int64 + ProviderAPIVersion string + Capabilities []string + SourceEvidenceDigest []byte + AssessmentID string + AssessmentDigest []byte + AssessedAt time.Time +} + +func SourcePullCapabilityAssessment(projectID, programID string, stateVersion int64, providerAPIVersion string, capabilities []string, sourceEvidenceDigest []byte) (CapabilityAssessmentAppend, error) { + normalized, err := NormalizeSourceCapabilities(capabilities) + if err != nil || projectID == "" || programID == "" || stateVersion < 1 || providerAPIVersion == "" || len(sourceEvidenceDigest) != sha256.Size { + return CapabilityAssessmentAppend{}, ErrInvalid + } + digest := SourceCapabilityAssessmentDigest(programID, projectID, stateVersion, providerAPIVersion, normalized, sourceEvidenceDigest) + idDigest := sha256.Sum256(append([]byte("mosaic-source-capability-assessment-id-v1\x1f"), digest...)) + return CapabilityAssessmentAppend{ProgramID: programID, ProjectID: projectID, StateVersion: stateVersion, ProviderAPIVersion: providerAPIVersion, Capabilities: normalized, SourceEvidenceDigest: append([]byte(nil), sourceEvidenceDigest...), AssessmentID: "mga_pull_" + hex.EncodeToString(idDigest[:12]), AssessmentDigest: digest}, nil +} + +func SourceCapabilityAssessmentDigest(programID, projectID string, stateVersion int64, providerAPIVersion string, capabilities []string, sourceEvidenceDigest []byte) []byte { + payload, _ := json.Marshal(struct { + Domain string + ProgramID, ProjectID, ProviderAPIVersion string + StateVersion int64 + Capabilities []string + SourceEvidenceDigest []byte + }{"mosaic-source-pull-capability-assessment-v1", programID, projectID, providerAPIVersion, stateVersion, capabilities, sourceEvidenceDigest}) + sum := sha256.Sum256(payload) + return sum[:] +} + +func NormalizeSourceCapabilities(capabilities []string) ([]string, error) { + if len(capabilities) == 0 { + return nil, ErrInvalid + } + allowed := map[string]bool{ + SourceCapabilityReadCustomers: true, + SourceCapabilityReadSubscriptions: true, + SourceCapabilityReadAliases: true, + SourceCapabilityIncrementalDelta: true, + } + result := append([]string(nil), capabilities...) + sort.Strings(result) + out := result[:0] + for _, capability := range result { + if !allowed[capability] { + return nil, ErrInvalid + } + if len(out) > 0 && capability == out[len(out)-1] { + continue + } + out = append(out, capability) + } + return out, nil +} diff --git a/apps/api/internal/billingmigration/capability_assessment_test.go b/apps/api/internal/billingmigration/capability_assessment_test.go new file mode 100644 index 00000000..a24ddd02 --- /dev/null +++ b/apps/api/internal/billingmigration/capability_assessment_test.go @@ -0,0 +1,41 @@ +package billingmigration + +import ( + "bytes" + "testing" +) + +func TestSourcePullCapabilityAssessmentIsStableAndClosed(t *testing.T) { + evidence := bytes.Repeat([]byte{0x41}, 32) + assessment, err := SourcePullCapabilityAssessment("project", "program", 3, ProviderAPIV2, []string{ + SourceCapabilityReadSubscriptions, + SourceCapabilityReadCustomers, + SourceCapabilityReadCustomers, + SourceCapabilityReadAliases, + }, evidence) + if err != nil { + t.Fatal(err) + } + if assessment.AssessmentID == "" || len(assessment.AssessmentDigest) != 32 { + t.Fatalf("assessment identity = %#v digest=%d", assessment.AssessmentID, len(assessment.AssessmentDigest)) + } + want := []string{SourceCapabilityReadAliases, SourceCapabilityReadCustomers, SourceCapabilityReadSubscriptions} + if len(assessment.Capabilities) != len(want) { + t.Fatalf("capabilities = %#v", assessment.Capabilities) + } + for i := range want { + if assessment.Capabilities[i] != want[i] { + t.Fatalf("capabilities = %#v", assessment.Capabilities) + } + } + again, err := SourcePullCapabilityAssessment("project", "program", 3, ProviderAPIV2, assessment.Capabilities, evidence) + if err != nil { + t.Fatal(err) + } + if assessment.AssessmentID != again.AssessmentID || !bytes.Equal(assessment.AssessmentDigest, again.AssessmentDigest) { + t.Fatalf("unstable assessment first=%#v second=%#v", assessment, again) + } + if _, err = SourcePullCapabilityAssessment("project", "program", 3, ProviderAPIV2, []string{"read_transfers"}, evidence); err != ErrInvalid { + t.Fatalf("unsupported capability error=%v", err) + } +} diff --git a/apps/api/internal/billingmigration/cases.go b/apps/api/internal/billingmigration/cases.go new file mode 100644 index 00000000..c5f270af --- /dev/null +++ b/apps/api/internal/billingmigration/cases.go @@ -0,0 +1,74 @@ +package billingmigration + +import ( + "context" + "time" +) + +type MigrationCase struct { + CaseID string `json:"caseId"` + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + Classification string `json:"classification"` + Status string `json:"status"` + Reason string `json:"reason"` + StateVersion int64 `json:"stateVersion"` + CaseDigest string `json:"caseDigest"` + LinkedDivergenceID string `json:"linkedDivergenceId,omitempty"` + LinkedSourceRecordID string `json:"linkedSourceRecordId,omitempty"` + OpenedAt time.Time `json:"openedAt"` + UpdatedAt time.Time `json:"updatedAt"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` +} + +type CreateCaseInput struct { + ProjectID, ProgramID, IdempotencyKey, Classification, Reason string + ExpectedStateVersion int64 + LinkedDivergenceID, LinkedSourceRecordID string +} + +type TransitionCaseInput struct { + ProjectID, ProgramID, CaseID, Status, Reason, ExpectedCaseDigest string + ExpectedStateVersion int64 +} + +func (s *OperationsService) CreateCase(ctx context.Context, actor Actor, input CreateCaseInput) (MigrationCase, bool, error) { + if input.ProjectID == "" || input.ProgramID == "" || input.IdempotencyKey == "" || len(input.IdempotencyKey) > 128 || input.ExpectedStateVersion < 1 || !validText(input.Reason, 500) || !caseClass(input.Classification) || (input.LinkedDivergenceID == "" && input.LinkedSourceRecordID == "") { + return MigrationCase{}, false, ErrInvalid + } + if _, err := s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityResolveCases); err != nil { + return MigrationCase{}, false, err + } + now := s.now() + id, err := s.newID("mca") + if err != nil { + return MigrationCase{}, false, ErrUnavailable + } + c := MigrationCase{CaseID: id, ProgramID: input.ProgramID, ProjectID: input.ProjectID, Classification: input.Classification, Status: "open", Reason: input.Reason, StateVersion: input.ExpectedStateVersion, LinkedDivergenceID: input.LinkedDivergenceID, LinkedSourceRecordID: input.LinkedSourceRecordID, OpenedAt: now, UpdatedAt: now} + c.CaseDigest = FormatDigest(digest(c)) + return s.repo.CreateCase(ctx, CaseWrite{Case: c, ActorID: actor.ID, IdempotencyKey: input.IdempotencyKey, RequestDigest: digest(input), ExpectedState: input.ExpectedStateVersion, LinkedDivergence: input.LinkedDivergenceID, LinkedRecord: input.LinkedSourceRecordID}) +} + +func (s *OperationsService) TransitionCase(ctx context.Context, actor Actor, input TransitionCaseInput) (MigrationCase, error) { + if input.ProjectID == "" || input.ProgramID == "" || input.CaseID == "" || input.ExpectedStateVersion < 1 || !validText(input.Reason, 500) || (input.Status != "in_progress" && input.Status != "resolved" && input.Status != "dismissed") { + return MigrationCase{}, ErrInvalid + } + expected, err := ParseDigest(input.ExpectedCaseDigest) + if err != nil { + return MigrationCase{}, ErrInvalid + } + if _, err = s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityResolveCases); err != nil { + return MigrationCase{}, err + } + now := s.now() + next := digest(struct { + CaseID, Status, Reason, Actor string + State int64 + At time.Time + }{input.CaseID, input.Status, input.Reason, actor.ID, input.ExpectedStateVersion, now}) + return s.repo.TransitionCase(ctx, CaseTransitionWrite{ProjectID: input.ProjectID, ProgramID: input.ProgramID, CaseID: input.CaseID, ActorID: actor.ID, Status: input.Status, Reason: input.Reason, ExpectedStateVersion: input.ExpectedStateVersion, ExpectedCaseDigest: expected, NewCaseDigest: next, At: now}) +} + +func caseClass(v string) bool { + return v == "critical" || v == "blocking" || v == "warning" || v == "informational" +} diff --git a/apps/api/internal/billingmigration/completion.go b/apps/api/internal/billingmigration/completion.go new file mode 100644 index 00000000..8b50f275 --- /dev/null +++ b/apps/api/internal/billingmigration/completion.go @@ -0,0 +1,260 @@ +package billingmigration + +import ( + "context" + "time" +) + +type CredentialRemoval struct { + RemovalID string `json:"removalId"` + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + CredentialID string `json:"credentialId"` + Reason string `json:"reason"` + ActorID string `json:"actorId"` + RemovalDigest string `json:"removalDigest"` + Early bool `json:"early"` + RemovedAt time.Time `json:"removedAt"` +} +type RemoveCredentialInput struct { + ProjectID, ProgramID, IdempotencyKey, Reason string + ExpectedStateVersion int64 + IrreversibleAcknowledged bool +} + +func (s *OperationsService) RemoveMigrationCredential(ctx context.Context, actor Actor, input RemoveCredentialInput) (CredentialRemoval, bool, error) { + if input.ProjectID == "" || input.ProgramID == "" || input.IdempotencyKey == "" || input.ExpectedStateVersion < 1 || !input.IrreversibleAcknowledged || !validText(input.Reason, 500) { + return CredentialRemoval{}, false, ErrInvalid + } + if _, err := s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityRemoveCredential); err != nil { + return CredentialRemoval{}, false, err + } + id, err := s.newID("mcr") + if err != nil { + return CredentialRemoval{}, false, ErrUnavailable + } + at := s.now() + removal := CredentialRemoval{RemovalID: id, ProgramID: input.ProgramID, ProjectID: input.ProjectID, Reason: input.Reason, ActorID: actor.ID, RemovedAt: at} + removal.RemovalDigest = FormatDigest(digest(struct { + Input RemoveCredentialInput + Actor, ID string + At time.Time + }{input, actor.ID, id, at})) + return s.repo.RemoveCredential(ctx, CredentialRemovalWrite{Removal: removal, ExpectedState: input.ExpectedStateVersion, RequestDigest: digest(input), IrreversibleAck: true, IdempotencyKey: input.IdempotencyKey}) +} + +type LegalHold struct { + HoldID string `json:"holdId"` + ProposalID string `json:"proposalId"` + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + Command string `json:"command"` + Reason string `json:"reason"` + ExternalComplianceReference string `json:"externalComplianceReference"` + ProposerActorID string `json:"proposerActorId"` + ApproverActorID string `json:"approverActorId"` + PreviousCommandID string `json:"previousCommandId,omitempty"` + CommandDigest string `json:"commandDigest"` + Production bool `json:"production"` + CommandedAt time.Time `json:"commandedAt"` +} +type LegalHoldProposal struct { + ProposalID string `json:"proposalId"` + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + Command string `json:"command"` + Reason string `json:"reason"` + ExternalComplianceReference string `json:"externalComplianceReference"` + ProposerActorID string `json:"proposerActorId"` + ExpectedPreviousCommandDigest string `json:"expectedPreviousCommandDigest,omitempty"` + ProposalDigest string `json:"proposalDigest"` + Status string `json:"status"` + ProposedAt time.Time `json:"proposedAt"` + ExpiresAt time.Time `json:"expiresAt"` +} +type ProposeLegalHoldInput struct { + ProjectID, ProgramID, IdempotencyKey, Command, Reason, ExternalComplianceReference, ExpectedPreviousCommandDigest string + ExpiresAt time.Time +} +type ApproveLegalHoldInput struct{ ProjectID, ProgramID, ProposalID, IdempotencyKey, ExpectedProposalDigest string } + +func (s *OperationsService) ProposeLegalHold(ctx context.Context, actor Actor, input ProposeLegalHoldInput) (LegalHoldProposal, bool, error) { + if input.ProjectID == "" || input.ProgramID == "" || input.IdempotencyKey == "" || (input.Command != "set" && input.Command != "release") || !validText(input.Reason, 500) || !validText(input.ExternalComplianceReference, 256) { + return LegalHoldProposal{}, false, ErrInvalid + } + if _, err := s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityManageLegalHold); err != nil { + return LegalHoldProposal{}, false, err + } + var expectedPrevious []byte + if input.ExpectedPreviousCommandDigest != "" { + var err error + expectedPrevious, err = ParseDigest(input.ExpectedPreviousCommandDigest) + if err != nil { + return LegalHoldProposal{}, false, ErrInvalid + } + } + now := s.now() + if !input.ExpiresAt.After(now) || input.ExpiresAt.After(now.Add(24*time.Hour)) { + return LegalHoldProposal{}, false, ErrInvalid + } + id, err := s.newID("mlp") + if err != nil { + return LegalHoldProposal{}, false, ErrUnavailable + } + p := LegalHoldProposal{ProposalID: id, ProgramID: input.ProgramID, ProjectID: input.ProjectID, Command: input.Command, Reason: input.Reason, ExternalComplianceReference: input.ExternalComplianceReference, ProposerActorID: actor.ID, ExpectedPreviousCommandDigest: input.ExpectedPreviousCommandDigest, Status: "pending", ProposedAt: now, ExpiresAt: input.ExpiresAt.UTC()} + p.ProposalDigest = FormatDigest(digest(p)) + return s.repo.ProposeLegalHold(ctx, LegalHoldProposalWrite{Proposal: p, IdempotencyKey: input.IdempotencyKey, RequestDigest: digest(input), ExpectedPreviousDigest: expectedPrevious}) +} +func (s *OperationsService) ApproveLegalHold(ctx context.Context, actor Actor, input ApproveLegalHoldInput) (LegalHold, bool, error) { + if input.ProjectID == "" || input.ProgramID == "" || input.ProposalID == "" || input.IdempotencyKey == "" { + return LegalHold{}, false, ErrInvalid + } + expected, err := ParseDigest(input.ExpectedProposalDigest) + if err != nil { + return LegalHold{}, false, ErrInvalid + } + if _, err = s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityManageLegalHold); err != nil { + return LegalHold{}, false, err + } + return s.repo.ApproveLegalHold(ctx, LegalHoldApprovalWrite{ProjectID: input.ProjectID, ProgramID: input.ProgramID, ProposalID: input.ProposalID, ApproverActorID: actor.ID, IdempotencyKey: input.IdempotencyKey, ExpectedProposalDigest: expected, RequestDigest: digest(input), At: s.now()}) +} + +type CompletionReport struct { + ReportID string `json:"reportId"` + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + CompletionDigest string `json:"completionDigest"` + AuthorityDigest string `json:"authorityDigest"` + StabilityEvidenceDigest string `json:"stabilityEvidenceDigest"` + PolicyDigest string `json:"policyDigest"` + StateVersion int64 `json:"stateVersion"` + CompletedAt time.Time `json:"completedAt"` + StabilizationEndedAt time.Time `json:"stabilizationEndedAt"` + RollbackWindowEndedAt time.Time `json:"rollbackWindowEndedAt"` + CredentialRemovedAt time.Time `json:"credentialRemovedAt"` + LegalHold bool `json:"legalHold"` + SourceObjectsDeleteAt *time.Time `json:"sourceObjectsDeleteAt,omitempty"` +} +type CompletionPrerequisites struct { + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + State string `json:"state"` + PolicyDigest string `json:"policyDigest"` + AuthorityDigest string `json:"authorityDigest"` + StabilityEvidenceDigest string `json:"stabilityEvidenceDigest"` + StateVersion int64 `json:"stateVersion"` + StabilizationEndsAt time.Time `json:"stabilizationEndsAt"` + RollbackWindowEndsAt time.Time `json:"rollbackWindowEndsAt"` + CredentialRemoved bool `json:"credentialRemoved"` + UnresolvedCriticalBlocking int64 `json:"unresolvedCriticalBlocking"` + AuthorityStable bool `json:"authorityStable"` + WebhookReady bool `json:"webhookReady"` + Eligible bool `json:"eligible"` +} + +func SyncObservationFresh(observedAt, completedAt time.Time, maxAgeSeconds int) bool { + return maxAgeSeconds > 0 && !observedAt.After(completedAt) && !observedAt.Before(completedAt.Add(-time.Duration(maxAgeSeconds)*time.Second)) +} + +func (s *OperationsService) InspectCompletion(ctx context.Context, actor Actor, projectID, programID string) (CompletionPrerequisites, error) { + if projectID == "" || programID == "" { + return CompletionPrerequisites{}, ErrInvalid + } + if _, err := s.auth.Authorize(ctx, actor, projectID, CapabilityCompleteMigration); err != nil { + return CompletionPrerequisites{}, err + } + return s.repo.CompletionPrerequisites(ctx, projectID, programID, s.now()) +} + +type CompleteMigrationInput struct { + ProjectID, ProgramID, IdempotencyKey string + ExpectedStateVersion int64 + ExpectedPolicyDigest, ExpectedAuthorityDigest, ExpectedStabilityEvidenceDigest string +} + +func (s *OperationsService) CompleteMigration(ctx context.Context, actor Actor, input CompleteMigrationInput) (CompletionReport, bool, error) { + if input.ProjectID == "" || input.ProgramID == "" || input.IdempotencyKey == "" || input.ExpectedStateVersion < 1 { + return CompletionReport{}, false, ErrInvalid + } + policy, err := ParseDigest(input.ExpectedPolicyDigest) + if err != nil { + return CompletionReport{}, false, ErrInvalid + } + authority, err := ParseDigest(input.ExpectedAuthorityDigest) + if err != nil { + return CompletionReport{}, false, ErrInvalid + } + stability, err := ParseDigest(input.ExpectedStabilityEvidenceDigest) + if err != nil { + return CompletionReport{}, false, ErrInvalid + } + if _, err = s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityCompleteMigration); err != nil { + return CompletionReport{}, false, err + } + reportID, err := s.newID("mco") + if err != nil { + return CompletionReport{}, false, ErrUnavailable + } + jobID, err := s.newID("mrt") + if err != nil { + return CompletionReport{}, false, ErrUnavailable + } + at := s.now() + report := CompletionReport{ReportID: reportID, ProgramID: input.ProgramID, ProjectID: input.ProjectID, StateVersion: input.ExpectedStateVersion + 1, CompletedAt: at, AuthorityDigest: input.ExpectedAuthorityDigest, StabilityEvidenceDigest: input.ExpectedStabilityEvidenceDigest, PolicyDigest: input.ExpectedPolicyDigest} + completion := digest(struct { + Input CompleteMigrationInput + Actor, ID string + At time.Time + }{input, actor.ID, reportID, at}) + report.CompletionDigest = FormatDigest(completion) + return s.repo.CompleteMigration(ctx, CompletionWrite{Report: report, ExpectedStateVersion: input.ExpectedStateVersion, ExpectedPolicyDigest: policy, AuthorityDigest: authority, StabilityDigest: stability, CompletionDigest: completion, RequestDigest: digest(input), RetentionJobID: jobID, DeletionIdentity: digest(struct{ Program, Report string }{input.ProgramID, reportID}), ActorID: actor.ID, IdempotencyKey: input.IdempotencyKey}) +} + +type RawSourceObjectDeleter interface { + DeleteRawSourceObject(context.Context, string) (string, error) +} + +func (s *OperationsService) RunRetention(ctx context.Context, workerID string, leaseFor time.Duration, deleter RawSourceObjectDeleter) error { + if workerID == "" || leaseFor < time.Second || leaseFor > 15*time.Minute || deleter == nil { + return ErrInvalid + } + lease, err := s.repo.ClaimRetention(ctx, RetentionClaim{WorkerID: workerID, Now: s.now(), LeaseFor: leaseFor}) + if err != nil { + return err + } + if lease.LegalHold { + for _, key := range lease.ObjectKeys { + keyDigest := digest(key) + if err = s.repo.SettleRetentionObject(ctx, RetentionObjectSettlement{JobID: lease.JobID, ProgramID: lease.ProgramID, ProjectID: lease.ProjectID, ObjectKey: key, Result: "legal_hold", Generation: lease.Generation, AttemptNumber: lease.AttemptNumber, ObjectKeyDigest: keyDigest, DeletionDigest: digest(struct { + Job, Result string + Key []byte + Attempt int + }{lease.JobID, "legal_hold", keyDigest, lease.AttemptNumber}), At: s.now()}); err != nil { + return err + } + } + return s.repo.FinishRetention(ctx, RetentionFinish{JobID: lease.JobID, Generation: lease.Generation, ErrorCode: "legal_hold", At: s.now()}) + } + for _, key := range lease.ObjectKeys { + result, deleteErr := deleter.DeleteRawSourceObject(ctx, key) + if deleteErr != nil { + result = "retryable_failure" + } + if result != "deleted" && result != "not_found" && result != "retryable_failure" { + return ErrInvalid + } + keyDigest := digest(key) + settle := RetentionObjectSettlement{JobID: lease.JobID, ProgramID: lease.ProgramID, ProjectID: lease.ProjectID, ObjectKey: key, Result: result, Generation: lease.Generation, AttemptNumber: lease.AttemptNumber, ObjectKeyDigest: keyDigest, DeletionDigest: digest(struct { + Job, Result string + Key []byte + Attempt int + }{lease.JobID, result, keyDigest, lease.AttemptNumber}), At: s.now()} + if err = s.repo.SettleRetentionObject(ctx, settle); err != nil { + return err + } + if deleteErr != nil || result == "retryable_failure" { + return s.repo.FinishRetention(ctx, RetentionFinish{JobID: lease.JobID, Generation: lease.Generation, ErrorCode: "object_delete_retryable", RetryAt: s.now().Add(time.Hour), At: s.now()}) + } + } + return s.repo.FinishRetention(ctx, RetentionFinish{JobID: lease.JobID, Generation: lease.Generation, At: s.now()}) +} diff --git a/apps/api/internal/billingmigration/cutover.go b/apps/api/internal/billingmigration/cutover.go new file mode 100644 index 00000000..d240c70d --- /dev/null +++ b/apps/api/internal/billingmigration/cutover.go @@ -0,0 +1,305 @@ +package billingmigration + +import ( + "crypto/sha256" + "sort" + "strings" + "time" +) + +const ( + CapabilityProposeCutover = "propose-cutover" + CapabilityApproveCutover = "approve-cutover" + CapabilityExecuteCutover = "execute-cutover" + CapabilityExecuteRollback = "execute-rollback" +) + +type PreApprovalDigests struct { + Scope, Manifest, Mapping, Policy, Evidence, Readiness, FinalWatermark, ApplicationVersion string +} + +func (d PreApprovalDigests) Parse() (ParsedDigests, error) { + values := []*[]byte{} + parsed := ParsedDigests{} + values = append(values, &parsed.Scope, &parsed.Manifest, &parsed.Mapping, &parsed.Policy, + &parsed.Evidence, &parsed.Readiness, &parsed.FinalWatermark, &parsed.ApplicationVersion) + input := []string{d.Scope, d.Manifest, d.Mapping, d.Policy, d.Evidence, d.Readiness, d.FinalWatermark, d.ApplicationVersion} + for index, value := range input { + raw, err := ParseDigest(value) + if err != nil { + return ParsedDigests{}, ErrInvalid + } + *values[index] = raw + } + return parsed, nil +} + +type ParsedDigests struct { + Scope, Manifest, Mapping, Policy, Evidence, Readiness, FinalWatermark, ApplicationVersion []byte +} + +type AuthoritativeReadiness struct { + Assessment ReadinessAssessment + SourceCapabilitiesFresh bool + WarningThreshold int64 + ApplicationVersionDigest string + CohortDigest string +} + +func FinalDeltaCohortDigest(programID, finalDeltaID string, customers []string) (string, error) { + if programID == "" || finalDeltaID == "" || len(customers) == 0 { + return "", ErrInvalid + } + copyIDs := append([]string(nil), customers...) + sort.Strings(copyIDs) + for index, id := range copyIDs { + if id == "" || (index > 0 && id == copyIDs[index-1]) { + return "", ErrInvalid + } + } + return FormatDigest(digest(struct { + ProgramID, FinalDeltaID string + Customers []string + }{programID, finalDeltaID, copyIDs})), nil +} + +func AssessAuthoritativeReadiness(programID string, stateVersion int64, base ReadinessAssessment, sourceCapabilitiesFresh bool, warningThreshold int64, applicationVersionDigest string) (AuthoritativeReadiness, error) { + if _, err := ParseDigest(applicationVersionDigest); err != nil || warningThreshold < 0 { + return AuthoritativeReadiness{}, ErrInvalid + } + base.Ready = base.Ready && sourceCapabilitiesFresh && base.Unresolved.Warning <= warningThreshold + base.ReadinessDigest = "" + base.ReadinessDigest = FormatDigest(digest(struct { + ProgramID string + StateVersion int64 + Assessment ReadinessAssessment + SourceCapabilitiesFresh bool + WarningThreshold int64 + ApplicationVersionDigest string + }{programID, stateVersion, base, sourceCapabilitiesFresh, warningThreshold, applicationVersionDigest})) + return AuthoritativeReadiness{Assessment: base, SourceCapabilitiesFresh: sourceCapabilitiesFresh, WarningThreshold: warningThreshold, ApplicationVersionDigest: applicationVersionDigest}, nil +} + +type PromoteReadyInput struct { + ProjectID, ProgramID string + ExpectedStateVersion int64 +} + +type ProposeCutoverInput struct { + ProjectID, ProgramID, IdempotencyKey, Command string + ExpectedStateVersion int64 + ExpectedDigests PreApprovalDigests + Reason string + ExpiresAt time.Time +} + +type ProposeRollbackInput struct { + ProjectID, ProgramID, IdempotencyKey, CheckpointID string + ExpectedStateVersion int64 + ExpectedCheckpointDigest string + ExpectedAuthorityDigest string + ExpectedRollbackPrerequisitesDigest string + Reason string + ExpiresAt time.Time +} + +type RollbackExpectedBinding struct { + CheckpointID, CheckpointDigest, AuthorityDigest, RollbackPrerequisitesDigest string +} + +type RollbackProposalBinding struct { + CheckpointID, CheckpointDigest, AuthorityDigest, RollbackPrerequisitesDigest, ScopeDigest string + CutoverTransitionID, CutoverTransitionDigest string + CutoverEpoch int64 + CutoverTransitionedAt, RollbackDeadline time.Time + CredentialID, CredentialStatus string + CredentialRemoved bool + CredentialRemovedAt *time.Time + CapabilityAssessmentID, CapabilityAssessmentDigest string + CapabilityAssessedAt time.Time + SourceValidationID, SourceValidationDigest string + SourceValidatedAt time.Time + ProviderValidationID, ProviderValidationDigest string + ProviderValidatedAt time.Time +} + +func (b RollbackProposalBinding) Parse() (checkpoint, authority, prerequisites, scope []byte, err error) { + checkpoint, err = ParseDigest(b.CheckpointDigest) + if err != nil { + return + } + authority, err = ParseDigest(b.AuthorityDigest) + if err != nil { + return + } + prerequisites, err = ParseDigest(b.RollbackPrerequisitesDigest) + if err != nil { + return + } + scope, err = ParseDigest(b.ScopeDigest) + return +} + +func RollbackPrerequisitesDigest(programID string, stateVersion int64, binding RollbackProposalBinding) (string, error) { + if programID == "" || stateVersion < 1 { + return "", ErrInvalid + } + for _, value := range []string{binding.CheckpointDigest, binding.AuthorityDigest, binding.ScopeDigest, binding.CutoverTransitionDigest, binding.CapabilityAssessmentDigest, binding.SourceValidationDigest, binding.ProviderValidationDigest} { + if _, err := ParseDigest(value); err != nil { + return "", ErrInvalid + } + } + if binding.CheckpointID == "" || binding.CutoverTransitionID == "" || binding.CutoverEpoch < 1 || binding.CutoverTransitionedAt.IsZero() || !binding.RollbackDeadline.After(binding.CutoverTransitionedAt) || binding.CredentialID == "" || binding.CredentialStatus == "" || binding.CapabilityAssessmentID == "" || binding.CapabilityAssessedAt.IsZero() || binding.SourceValidationID == "" || binding.SourceValidatedAt.IsZero() || binding.ProviderValidationID == "" || binding.ProviderValidatedAt.IsZero() { + return "", ErrInvalid + } + binding.CutoverTransitionedAt = binding.CutoverTransitionedAt.UTC() + binding.RollbackDeadline = binding.RollbackDeadline.UTC() + binding.CapabilityAssessedAt = binding.CapabilityAssessedAt.UTC() + binding.SourceValidatedAt = binding.SourceValidatedAt.UTC() + binding.ProviderValidatedAt = binding.ProviderValidatedAt.UTC() + if binding.CredentialRemovedAt != nil { + removedAt := binding.CredentialRemovedAt.UTC() + binding.CredentialRemovedAt = &removedAt + } + binding.RollbackPrerequisitesDigest = "" + return FormatDigest(digest(struct { + ProgramID string + StateVersion int64 + Binding RollbackProposalBinding + }{programID, stateVersion, binding})), nil +} + +func AuthoritySetDigest(programID, scopeDigest string, authorityDigests []string) (string, error) { + if programID == "" || len(authorityDigests) == 0 { + return "", ErrInvalid + } + if _, err := ParseDigest(scopeDigest); err != nil { + return "", ErrInvalid + } + values := append([]string(nil), authorityDigests...) + sort.Strings(values) + for _, value := range values { + if _, err := ParseDigest(value); err != nil { + return "", ErrInvalid + } + } + return FormatDigest(digest(struct { + ProgramID, ScopeDigest string + AuthorityDigests []string + }{programID, scopeDigest, values})), nil +} + +func ServingRequirementsDigest(programID, applicationID, platform, minimumSDKVersion string, requiredCapabilities []string) (string, error) { + if programID == "" || applicationID == "" || (platform != "ios" && platform != "android") || len(requiredCapabilities) == 0 { + return "", ErrInvalid + } + if ok, err := SemanticVersionInRange(minimumSDKVersion, minimumSDKVersion, minimumSDKVersion); err != nil || !ok { + return "", ErrInvalid + } + allowed := map[string]bool{"authority_epoch": true, "authority_scope": true, "urgent_authority_sync": true, "mosaic_authoritative_targeting": true} + capabilities := append([]string(nil), requiredCapabilities...) + sort.Strings(capabilities) + for index, capability := range capabilities { + if !allowed[capability] || (index > 0 && capability == capabilities[index-1]) { + return "", ErrInvalid + } + } + sum := sha256.Sum256([]byte(strings.Join([]string{programID, applicationID, platform, minimumSDKVersion, strings.Join(capabilities, "\x1e")}, "\x1f"))) + return FormatDigest(sum[:]), nil +} + +type CutoverProposal struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + ProposalID string `json:"proposalId"` + Command string `json:"command"` + ProposerActorID string `json:"proposerActorId"` + Reason string `json:"reason"` + Digests PreApprovalDigests `json:"-"` + RollbackBinding *RollbackProposalBinding `json:"-"` + ProposalDigest string `json:"proposalDigest"` + Status string `json:"status"` + ProposedAt time.Time `json:"proposedAt"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type ApproveCutoverInput struct { + ProjectID, ProgramID, ProposalID, IdempotencyKey string + ExpectedStateVersion int64 +} + +type MigrationApproval struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + ApprovalID string `json:"approvalId"` + Command string `json:"command"` + ProposerActorID string `json:"proposerActorId"` + ApproverActorID string `json:"approverActorId"` + ApprovalDigest string `json:"approvalDigest"` + ApprovedAt time.Time `json:"approvedAt"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type CreateCheckpointInput struct { + ProjectID, ProgramID, ApprovalID, IdempotencyKey string + ExpectedStateVersion int64 + ExpectedDigests PreApprovalDigests + ApprovalDigest string + CohortDigest string +} + +type MigrationCheckpoint struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + CheckpointID string `json:"checkpointId"` + Scope Scope `json:"scope"` + AuthorityEpoch int64 `json:"authorityEpoch"` + SourceWatermark time.Time `json:"sourceWatermark"` + ProviderWatermark time.Time `json:"providerWatermark"` + ShadowWatermark time.Time `json:"shadowWatermark"` + ManifestDigest string `json:"manifestDigest"` + MappingDigest string `json:"mappingDigest"` + PolicyDigest string `json:"policyDigest"` + ReadinessDigest string `json:"readinessDigest"` + CheckpointDigest string `json:"checkpointDigest"` + CohortDigest string `json:"cohortDigest"` + CreatedAt time.Time `json:"createdAt"` +} + +type ProposalWrite struct { + Proposal CutoverProposal + ProjectID, IdempotencyKey string + RequestDigest []byte + Digests ParsedDigests + ProposalDigest []byte + ExpectedRollback *RollbackExpectedBinding +} +type ApprovalWrite struct { + Approval MigrationApproval + ProjectID, ProposalID, IdempotencyKey string + RequestDigest, ApprovalDigest []byte +} +type CheckpointWrite struct { + Checkpoint MigrationCheckpoint + ProjectID, ApprovalID, IdempotencyKey string + RequestDigest []byte + Digests ParsedDigests + ApprovalDigest, CheckpointDigest, CohortDigest []byte +} + +func ValidateCompletionTiming(completedAt, stabilizationEndedAt, rollbackWindowEndedAt, credentialRemovedAt time.Time, legalHold bool, sourceObjectsDeleteAt *time.Time) error { + if completedAt.IsZero() || stabilizationEndedAt.IsZero() || rollbackWindowEndedAt.IsZero() || + completedAt.Before(stabilizationEndedAt) || completedAt.Before(rollbackWindowEndedAt) || credentialRemovedAt.Before(rollbackWindowEndedAt) { + return ErrInvalid + } + if legalHold { + if sourceObjectsDeleteAt != nil { + return ErrInvalid + } + return nil + } + if sourceObjectsDeleteAt == nil || !sourceObjectsDeleteAt.Equal(completedAt.Add(30*24*time.Hour)) { + return ErrInvalid + } + return nil +} diff --git a/apps/api/internal/billingmigration/cutover_repository.go b/apps/api/internal/billingmigration/cutover_repository.go new file mode 100644 index 00000000..ed41afb3 --- /dev/null +++ b/apps/api/internal/billingmigration/cutover_repository.go @@ -0,0 +1,16 @@ +package billingmigration + +import ( + "context" + "time" +) + +type CutoverRepository interface { + PromoteReady(context.Context, string, string, int64, string, time.Time) (AuthoritativeReadiness, error) + CreateProposal(context.Context, ProposalWrite) (CutoverProposal, bool, error) + Proposal(context.Context, string, string, string) (CutoverProposal, error) + ApproveProposal(context.Context, ApprovalWrite) (MigrationApproval, bool, error) + CreateCheckpoint(context.Context, CheckpointWrite) (MigrationCheckpoint, bool, error) + ExecuteCutover(context.Context, ExecuteCutoverWrite) (AuthorityExecution, bool, error) + ExecuteRollback(context.Context, ExecuteRollbackWrite) (AuthorityExecution, bool, error) +} diff --git a/apps/api/internal/billingmigration/evidence.go b/apps/api/internal/billingmigration/evidence.go new file mode 100644 index 00000000..5f41007f --- /dev/null +++ b/apps/api/internal/billingmigration/evidence.go @@ -0,0 +1,186 @@ +package billingmigration + +import ( + "encoding/hex" + "strings" + "time" +) + +const digestPrefix = "sha256:" + +func FormatDigest(value []byte) string { return digestPrefix + hex.EncodeToString(value) } + +func ParseDigest(value string) ([]byte, error) { + if len(value) != len(digestPrefix)+64 || !strings.HasPrefix(value, digestPrefix) { + return nil, ErrInvalid + } + raw, err := hex.DecodeString(strings.TrimPrefix(value, digestPrefix)) + if err != nil || FormatDigest(raw) != value { + return nil, ErrInvalid + } + return raw, nil +} + +func validSourceIdentifier(value string) bool { + if len(value) == 0 || len(value) > 512 { + return false + } + for _, character := range value { + if character <= 0x1f || character == 0x7f { + return false + } + } + return true +} + +type CreateMappingSetInput struct { + ProjectID, ProgramID string + ExpectedStateVersion int64 + Version int + Entries []MappingEntry +} + +type CreateImportBatchInput struct { + ProjectID, ProgramID, ManifestID, MappingSetID, IdempotencyKey, CursorBefore string + ExpectedStateVersion int64 + RecordCount int +} + +type QueueRunInput struct { + ProjectID, ProgramID, RunKind, IdempotencyKey string + ExpectedStateVersion int64 + ManifestDigest, MappingDigest string +} + +type Counts struct { + Critical int64 `json:"critical"` + Blocking int64 `json:"blocking"` + Warning int64 `json:"warning"` + Informational int64 `json:"informational"` +} + +type SourceManifest struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + ManifestID string `json:"manifestId"` + AdapterVersion string `json:"adapterVersion"` + ProviderAPIVersion string `json:"providerApiVersion"` + SchemaVersion string `json:"schemaVersion"` + RecordCount int64 `json:"recordCount"` + CurrentAccessRecordCount int64 `json:"currentAccessRecordCount"` + ObjectChecksum string `json:"objectChecksum"` + ManifestDigest string `json:"manifestDigest"` + CapturedAt time.Time `json:"capturedAt"` +} + +type MappingEntry struct { + SourceKind string `json:"sourceKind"` + SourceIdentifier string `json:"sourceIdentifier"` + TargetID string `json:"targetId"` + MatchKind string `json:"matchKind"` +} + +type MappingSet struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + MappingSetID string `json:"mappingSetId"` + Version int `json:"version"` + Status string `json:"status"` + Entries []MappingEntry `json:"entries"` + MappingDigest string `json:"mappingDigest"` +} + +type ImportBatch struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + BatchID string `json:"batchId"` + IdempotencyKey string `json:"idempotencyKey"` + Status string `json:"status"` + RecordCount int `json:"recordCount"` + ValidatedCount int `json:"validatedCount"` + QuarantinedCount int `json:"quarantinedCount"` + LeaseGeneration int64 `json:"-"` +} + +type RunJob struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + RunJobID string `json:"runJobId"` + RunKind string `json:"runKind"` + Status string `json:"status"` + ResultRunID string `json:"resultRunId,omitempty"` +} + +type MigrationRun struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + RunID string `json:"runId"` + RunKind string `json:"runKind"` + ManifestDigest string `json:"manifestDigest"` + MappingDigest string `json:"mappingDigest"` + PolicyDigest string `json:"policyDigest"` + Divergences Counts `json:"divergences"` + CompletedAt time.Time `json:"completedAt"` +} + +type Divergence struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + DivergenceID string `json:"divergenceId"` + Classification string `json:"classification"` + Reason string `json:"reason"` + ObservedAt time.Time `json:"observedAt"` + ClassificationRuleVersion string `json:"classificationRuleVersion"` +} + +type ReadinessInput struct { + CurrentAccessMappingPercent float64 + CurrentAccessEvidencePercent float64 + Unresolved Counts + FinalDeltaCompleted bool + WatermarksFresh bool + SupportedVersionsAuthorityAware bool +} + +type ReadinessAssessment struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + Ready bool `json:"ready"` + CurrentAccessMappingPercent float64 `json:"currentAccessMappingPercent"` + CurrentAccessEvidencePercent float64 `json:"currentAccessEvidencePercent"` + Unresolved Counts `json:"unresolved"` + FinalDeltaCompleted bool `json:"finalDeltaCompleted"` + WatermarksFresh bool `json:"watermarksFresh"` + SupportedVersionsAuthorityAware bool `json:"supportedVersionsAuthorityAware"` + ReadinessDigest string `json:"readinessDigest"` +} + +// AssessReadiness implements OD-9C-5 as a deterministic calculation. Warning +// and informational divergences remain visible but do not silently become +// blockers; their thresholds belong to the separately frozen program policy. +func AssessReadiness(programID string, stateVersion int64, input ReadinessInput) (ReadinessAssessment, error) { + if programID == "" || stateVersion < 1 || input.CurrentAccessMappingPercent < 0 || + input.CurrentAccessMappingPercent > 100 || input.CurrentAccessEvidencePercent < 0 || + input.CurrentAccessEvidencePercent > 100 || input.Unresolved.Critical < 0 || + input.Unresolved.Blocking < 0 || input.Unresolved.Warning < 0 || input.Unresolved.Informational < 0 { + return ReadinessAssessment{}, ErrInvalid + } + assessment := ReadinessAssessment{ + ProgramID: programID, StateVersion: stateVersion, + CurrentAccessMappingPercent: input.CurrentAccessMappingPercent, + CurrentAccessEvidencePercent: input.CurrentAccessEvidencePercent, + Unresolved: input.Unresolved, FinalDeltaCompleted: input.FinalDeltaCompleted, + WatermarksFresh: input.WatermarksFresh, + SupportedVersionsAuthorityAware: input.SupportedVersionsAuthorityAware, + } + assessment.Ready = assessment.CurrentAccessMappingPercent == 100 && + assessment.CurrentAccessEvidencePercent == 100 && assessment.Unresolved.Critical == 0 && + assessment.Unresolved.Blocking == 0 && assessment.FinalDeltaCompleted && + assessment.WatermarksFresh && assessment.SupportedVersionsAuthorityAware + assessment.ReadinessDigest = FormatDigest(digest(struct { + ProgramID string + StateVersion int64 + Input ReadinessInput + }{programID, stateVersion, input})) + return assessment, nil +} diff --git a/apps/api/internal/billingmigration/evidence_repository.go b/apps/api/internal/billingmigration/evidence_repository.go new file mode 100644 index 00000000..696dbf68 --- /dev/null +++ b/apps/api/internal/billingmigration/evidence_repository.go @@ -0,0 +1,82 @@ +package billingmigration + +import ( + "context" + "time" +) + +type ManifestWrite struct { + Manifest SourceManifest + ProjectID string + ObjectKey string + ObjectChecksum []byte + ObjectSizeBytes int64 + ManifestDigest []byte + SourceWatermark string +} + +type MappingSetWrite struct { + MappingSet MappingSet + ProjectID string + MappingDigest []byte + ActorID string + CreatedAt time.Time +} + +type ImportBatchWrite struct { + Batch ImportBatch + ProjectID, ManifestID, MappingSetID string + RequestDigest []byte + CursorBefore string + CreatedAt time.Time +} + +type RunWrite struct { + Run MigrationRun + ProjectID string + ManifestDigest, MappingDigest, PolicyDigest, RunDigest []byte + SourceWatermark, ProviderWatermark, ShadowWatermark string + Divergences []DivergenceWrite +} + +type DivergenceWrite struct { + Divergence Divergence + EvidenceDigest []byte +} + +type ReadinessWrite struct { + Assessment ReadinessAssessment + ProjectID string + ReadinessDigest []byte + AssessedAt time.Time +} + +type RunJobWrite struct { + Job RunJob + ProjectID, IdempotencyKey string + RequestDigest []byte + ManifestDigest, MappingDigest, PolicyDigest []byte + CreatedAt time.Time +} + +type EvidenceRepository interface { + AppendManifest(ctx context.Context, expectedStateVersion int64, write ManifestWrite) error + ListManifests(ctx context.Context, projectID, programID string, limit int) ([]SourceManifest, error) + CreateMappingSet(ctx context.Context, expectedStateVersion int64, write MappingSetWrite) error + FreezeMappingSet(ctx context.Context, projectID, programID, mappingSetID string, expectedStateVersion int64, at time.Time) error + ListMappingSets(ctx context.Context, projectID, programID string, limit int) ([]MappingSet, error) + CreateImportBatch(ctx context.Context, expectedStateVersion int64, write ImportBatchWrite) (bool, error) + ListImportBatches(ctx context.Context, projectID, programID string, limit int) ([]ImportBatch, error) + ImportBatch(ctx context.Context, projectID, programID, batchID string) (ImportBatch, error) + ImportBatchByIdempotency(ctx context.Context, projectID, programID, key string) (ImportBatch, error) + LeaseImportBatch(ctx context.Context, workerID string, now, leaseUntil time.Time) (ImportBatch, bool, error) + CompleteImportBatch(ctx context.Context, projectID, programID, batchID, workerID string, leaseGeneration int64, cursorAfter string, validated, quarantined int, now time.Time) error + QueueRun(ctx context.Context, expectedStateVersion int64, write RunJobWrite) (bool, error) + RunJob(ctx context.Context, projectID, programID, jobID string) (RunJob, error) + RunJobByIdempotency(ctx context.Context, projectID, programID, key string) (RunJob, error) + RecordRun(ctx context.Context, expectedStateVersion int64, write RunWrite) error + ListDivergences(ctx context.Context, projectID, programID string, limit int) ([]Divergence, error) + ReadinessInput(ctx context.Context, projectID, programID string) (ReadinessInput, error) + RecordReadiness(ctx context.Context, expectedStateVersion int64, write ReadinessWrite) error + LatestReadiness(ctx context.Context, projectID, programID string) (ReadinessAssessment, error) +} diff --git a/apps/api/internal/billingmigration/execution.go b/apps/api/internal/billingmigration/execution.go new file mode 100644 index 00000000..fdf2798b --- /dev/null +++ b/apps/api/internal/billingmigration/execution.go @@ -0,0 +1,209 @@ +package billingmigration + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "sort" + "time" +) + +type CutoverCommandDigests struct { + Scope, Manifest, Mapping, Policy, Evidence, Readiness, FinalWatermark, ApplicationVersion, Approval string +} + +func (d CutoverCommandDigests) Parse() (ParsedCutoverCommandDigests, error) { + values := []string{d.Scope, d.Manifest, d.Mapping, d.Policy, d.Evidence, d.Readiness, d.FinalWatermark, d.ApplicationVersion, d.Approval} + parsed := ParsedCutoverCommandDigests{} + targets := []*[]byte{&parsed.Scope, &parsed.Manifest, &parsed.Mapping, &parsed.Policy, &parsed.Evidence, &parsed.Readiness, &parsed.FinalWatermark, &parsed.ApplicationVersion, &parsed.Approval} + for index, value := range values { + raw, err := ParseDigest(value) + if err != nil { + return parsed, ErrInvalid + } + *targets[index] = raw + } + return parsed, nil +} + +type ParsedCutoverCommandDigests struct{ Scope, Manifest, Mapping, Policy, Evidence, Readiness, FinalWatermark, ApplicationVersion, Approval []byte } + +type RollbackCommandDigests struct{ Checkpoint, Authority, RollbackPrerequisites, Approval string } +type ParsedRollbackCommandDigests struct{ Checkpoint, Authority, RollbackPrerequisites, Approval []byte } + +func (d RollbackCommandDigests) Parse() (ParsedRollbackCommandDigests, error) { + values := []string{d.Checkpoint, d.Authority, d.RollbackPrerequisites, d.Approval} + parsed := ParsedRollbackCommandDigests{} + targets := []*[]byte{&parsed.Checkpoint, &parsed.Authority, &parsed.RollbackPrerequisites, &parsed.Approval} + for index, value := range values { + raw, err := ParseDigest(value) + if err != nil { + return parsed, ErrInvalid + } + *targets[index] = raw + } + return parsed, nil +} + +type ExecuteCutoverInput struct { + ProjectID string + ProgramID string + IdempotencyKey string + ExpectedStateVersion int64 + ExpectedDigests CutoverCommandDigests + Reason string + Scope Scope + CheckpointID string + ApprovalID string + ExpectedAuthorityEpoch int64 +} + +type ExecuteRollbackInput struct { + ProjectID string + ProgramID string + IdempotencyKey string + ExpectedStateVersion int64 + ExpectedDigests RollbackCommandDigests + Reason string + Scope Scope + CheckpointID string + ApprovalID string + ExpectedAuthorityEpoch int64 +} + +type AuthorityExecution struct { + ProgramID string `json:"programId"` + ExecutionID string `json:"executionId"` + Command string `json:"command"` + State string `json:"state"` + StateVersion int64 `json:"stateVersion"` + AuthorityEpoch int64 `json:"authorityEpoch"` + TransitionIDs []string `json:"transitionIds"` + ExecutedAt time.Time `json:"executedAt"` +} + +type ExecuteCutoverWrite struct { + Input ExecuteCutoverInput + ActorID string + Digests ParsedCutoverCommandDigests + RequestDigest []byte + ExecutionID string + ExecutedAt time.Time +} +type ExecuteRollbackWrite struct { + Input ExecuteRollbackInput + ActorID string + Digests ParsedRollbackCommandDigests + RequestDigest []byte + ExecutionID string + ExecutedAt time.Time +} + +func canonicalScope(scope Scope) (Scope, error) { + if scope.ProjectID == "" || scope.EnvironmentID == "" || len(scope.Applications) == 0 { + return Scope{}, ErrInvalid + } + result := scope + result.Applications = append([]ScopeItem(nil), scope.Applications...) + sort.Slice(result.Applications, func(i, j int) bool { + if result.Applications[i].ApplicationID == result.Applications[j].ApplicationID { + return result.Applications[i].Platform < result.Applications[j].Platform + } + return result.Applications[i].ApplicationID < result.Applications[j].ApplicationID + }) + for index, item := range result.Applications { + if item.ApplicationID == "" || (item.Platform != "ios" && item.Platform != "android") || (index > 0 && item == result.Applications[index-1]) { + return Scope{}, ErrInvalid + } + } + return result, nil +} + +func deterministicExecutionID(command, programID string, requestDigest []byte) string { + sum := sha256.Sum256(append(append([]byte(command+"\x1f"+programID+"\x1f"), requestDigest...), 0)) + return "mex_" + hex.EncodeToString(sum[:12]) +} + +func DeterministicTransitionID(executionID, authorityScopeID string) string { + sum := sha256.Sum256([]byte(executionID + "\x1f" + authorityScopeID)) + return "mat_" + hex.EncodeToString(sum[:12]) +} + +func CanonicalAuthorityDigest(projectID, environmentID, applicationID, platform, authority string, epoch int64, programID string) []byte { + return digest(struct { + ProjectID, EnvironmentID, ApplicationID, Platform, Authority string + Epoch int64 + ProgramID string + }{projectID, environmentID, applicationID, platform, authority, epoch, programID}) +} + +func CanonicalTransitionDigest(programID, scopeID, from, to string, fromEpoch, toEpoch int64, kind string, at time.Time) []byte { + return digest(struct { + ProgramID, ScopeID, From, To string + FromEpoch, ToEpoch int64 + Kind string + At time.Time + }{programID, scopeID, from, to, fromEpoch, toEpoch, kind, at.UTC()}) +} + +func (s *Service) ExecuteCutover(ctx context.Context, actor Actor, input ExecuteCutoverInput) (AuthorityExecution, bool, error) { + if s.cutover == nil || input.ProjectID == "" || input.ProgramID == "" || input.CheckpointID == "" || input.ApprovalID == "" || input.ExpectedStateVersion < 1 || input.ExpectedAuthorityEpoch < 0 || input.IdempotencyKey == "" || len(input.IdempotencyKey) > 128 || !validText(input.Reason, 500) { + return AuthorityExecution{}, false, ErrInvalid + } + scope, err := canonicalScope(input.Scope) + if err != nil || scope.ProjectID != input.ProjectID { + return AuthorityExecution{}, false, ErrInvalid + } + input.Scope = scope + digests, err := input.ExpectedDigests.Parse() + if err != nil { + return AuthorityExecution{}, false, err + } + if _, err = s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityExecuteCutover); err != nil { + return AuthorityExecution{}, false, err + } + payload := struct { + ProgramID, IdempotencyKey string + ExpectedStateVersion int64 + ExpectedDigests CutoverCommandDigests + Reason, Command string + Scope Scope + CheckpointID, ApprovalID string + ExpectedAuthorityEpoch int64 + }{input.ProgramID, input.IdempotencyKey, input.ExpectedStateVersion, input.ExpectedDigests, input.Reason, "cutover", input.Scope, input.CheckpointID, input.ApprovalID, input.ExpectedAuthorityEpoch} + requestDigest := digest(payload) + at := s.now() + executionID := deterministicExecutionID("cutover", input.ProgramID, requestDigest) + return s.cutover.ExecuteCutover(ctx, ExecuteCutoverWrite{Input: input, ActorID: actor.ID, Digests: digests, RequestDigest: requestDigest, ExecutionID: executionID, ExecutedAt: at}) +} + +func (s *Service) ExecuteRollback(ctx context.Context, actor Actor, input ExecuteRollbackInput) (AuthorityExecution, bool, error) { + if s.cutover == nil || input.ProjectID == "" || input.ProgramID == "" || input.CheckpointID == "" || input.ApprovalID == "" || input.ExpectedStateVersion < 1 || input.ExpectedAuthorityEpoch < 1 || input.IdempotencyKey == "" || len(input.IdempotencyKey) > 128 || !validText(input.Reason, 500) { + return AuthorityExecution{}, false, ErrInvalid + } + scope, err := canonicalScope(input.Scope) + if err != nil || scope.ProjectID != input.ProjectID { + return AuthorityExecution{}, false, ErrInvalid + } + input.Scope = scope + digests, err := input.ExpectedDigests.Parse() + if err != nil { + return AuthorityExecution{}, false, err + } + if _, err = s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityExecuteRollback); err != nil { + return AuthorityExecution{}, false, err + } + payload := struct { + ProgramID, IdempotencyKey string + ExpectedStateVersion int64 + ExpectedDigests RollbackCommandDigests + Reason, Command string + Scope Scope + CheckpointID, ApprovalID string + ExpectedAuthorityEpoch int64 + }{input.ProgramID, input.IdempotencyKey, input.ExpectedStateVersion, input.ExpectedDigests, input.Reason, "rollback", input.Scope, input.CheckpointID, input.ApprovalID, input.ExpectedAuthorityEpoch} + requestDigest := digest(payload) + at := s.now() + executionID := deterministicExecutionID("rollback", input.ProgramID, requestDigest) + return s.cutover.ExecuteRollback(ctx, ExecuteRollbackWrite{Input: input, ActorID: actor.ID, Digests: digests, RequestDigest: requestDigest, ExecutionID: executionID, ExecutedAt: at}) +} diff --git a/apps/api/internal/billingmigration/operational_json_test.go b/apps/api/internal/billingmigration/operational_json_test.go new file mode 100644 index 00000000..269944d6 --- /dev/null +++ b/apps/api/internal/billingmigration/operational_json_test.go @@ -0,0 +1,42 @@ +package billingmigration + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// The operator API and generated dashboard client share these exact wire +// names. This catches the Go default of exporting PascalCase fields or hiding +// evidence digests while OpenAPI promises lower-camel, closed records. +func TestOperationalRecordsUseClosedLowerCamelJSON(t *testing.T) { + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + values := []any{ + CutoverProposal{ProposalID: "proposal", ProposedAt: now, ExpiresAt: now.Add(time.Hour)}, + MigrationCheckpoint{CheckpointID: "checkpoint", CheckpointDigest: FormatDigest(make([]byte, 32)), CohortDigest: FormatDigest(make([]byte, 32)), CreatedAt: now}, + CompletionPrerequisites{ProgramID: "program", ProjectID: "private-project", StabilizationEndsAt: now, RollbackWindowEndsAt: now}, + CompletionReport{ReportID: "report", ProgramID: "program", ProjectID: "private-project", CompletedAt: now}, + CredentialRemoval{RemovalID: "removal", ProjectID: "private-project", RemovedAt: now}, + LegalHoldProposal{ProposalID: "hold-proposal", ProjectID: "private-project", ProposedAt: now, ExpiresAt: now.Add(time.Hour)}, + LegalHold{HoldID: "hold", ProjectID: "private-project", CommandedAt: now}, + Redelivery{ID: "redelivery", IdempotencyKey: "private-key", CreatedAt: now}, + RepairPreview{PreviewID: "repair-preview", CreatedAt: now, ExpiresAt: now.Add(time.Hour)}, + RepairExecution{ExecutionID: "repair-execution", Status: "pending", ExecutedAt: now}, + } + encoded, err := json.Marshal(values) + if err != nil { + t.Fatalf("marshal operational records: %v", err) + } + wire := string(encoded) + for _, forbidden := range []string{`"ProposedAt"`, `"ExpiresAt"`, `"CheckpointDigest"`, `"ProjectID"`, `"IdempotencyKey"`, "private-project", "private-key"} { + if strings.Contains(wire, forbidden) { + t.Fatalf("wire contains forbidden field/value %q: %s", forbidden, wire) + } + } + for _, required := range []string{`"proposedAt"`, `"expiresAt"`, `"checkpointDigest"`, `"cohortDigest"`, `"stabilizationEndsAt"`, `"reportId"`, `"removalId"`, `"holdId"`, `"redeliveryId"`, `"previewId"`, `"executionStatus":"pending"`} { + if !strings.Contains(wire, required) { + t.Fatalf("wire missing required field %q: %s", required, wire) + } + } +} diff --git a/apps/api/internal/billingmigration/operational_read_models.go b/apps/api/internal/billingmigration/operational_read_models.go new file mode 100644 index 00000000..b1ec0b68 --- /dev/null +++ b/apps/api/internal/billingmigration/operational_read_models.go @@ -0,0 +1,115 @@ +package billingmigration + +import "time" + +// The operational read models deliberately exclude request digests, +// idempotency keys, encrypted credential material, provider references, and +// customer data. They retain the actors and immutable evidence bindings an +// operator needs to audit a migration. +type RepairPreviewRecord struct { + RepairPreview + CreatedByActorID string `json:"createdByActorId"` +} + +type RepairExecutionRecord struct { + ExecutionID string `json:"executionId"` + PreviewID string `json:"previewId"` + ProgramID string `json:"programId"` + ExecutionStatus string `json:"executionStatus"` + Result string `json:"result,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + BeforeDigest string `json:"beforeDigest"` + AfterDigest string `json:"afterDigest,omitempty"` + ResultDigest string `json:"resultDigest,omitempty"` + AttemptNumber int `json:"attemptNumber"` + ExecutedByActorID string `json:"executedByActorId"` + ReservedAt time.Time `json:"reservedAt"` + ExecutedAt *time.Time `json:"executedAt,omitempty"` +} + +type RedeliveryRecord struct { + RedeliveryID string `json:"redeliveryId"` + ProgramID string `json:"programId"` + EventID string `json:"eventId"` + DestinationID string `json:"destinationId"` + DeliveryID string `json:"deliveryId"` + Reason string `json:"reason"` + ActorID string `json:"actorId"` + ExpectedStateVersion int64 `json:"stateVersion"` + ExpectedEventDigest string `json:"expectedEventDigest"` + CreatedAt time.Time `json:"createdAt"` +} + +type CredentialRemovalRecord struct { + RemovalID string `json:"removalId"` + ProgramID string `json:"programId"` + CredentialID string `json:"credentialId"` + Reason string `json:"reason"` + ActorID string `json:"actorId"` + RemovalDigest string `json:"removalDigest"` + StateVersion int64 `json:"stateVersion"` + Early bool `json:"early"` + RemovedAt time.Time `json:"removedAt"` +} + +type LegalHoldProposalRecord struct { + ProposalID string `json:"proposalId"` + ProgramID string `json:"programId"` + Command string `json:"command"` + Reason string `json:"reason"` + ExternalComplianceReference string `json:"externalComplianceReference"` + ProposerActorID string `json:"proposerActorId"` + ExpectedPreviousCommandDigest string `json:"expectedPreviousCommandDigest,omitempty"` + ProposalDigest string `json:"proposalDigest"` + Status string `json:"status"` + ProposedAt time.Time `json:"proposedAt"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type LegalHoldRecord struct { + HoldID string `json:"holdId"` + ProposalID string `json:"proposalId"` + ProgramID string `json:"programId"` + Command string `json:"command"` + Reason string `json:"reason"` + ExternalComplianceReference string `json:"externalComplianceReference"` + ProposerActorID string `json:"proposerActorId"` + ApproverActorID string `json:"approverActorId"` + PreviousCommandID string `json:"previousCommandId,omitempty"` + CommandDigest string `json:"commandDigest"` + Production bool `json:"production"` + CommandedAt time.Time `json:"commandedAt"` +} + +type CompletionReportRecord struct { + CompletionReport + CompletedByActorID string `json:"completedByActorId"` +} + +type StabilizationPolicyRecord struct { + StabilizationPolicy + FrozenByActorID string `json:"frozenByActorId"` +} + +type StabilizationObservationRecord struct { + StabilizationObservation +} + +type RollbackReadinessAssessmentRecord struct { + RollbackReadinessAssessment + SourceHealthDigest string `json:"sourceHealthDigest"` + SourceCurrentAccessDigest string `json:"sourceCurrentAccessDigest"` + LatestDeltaDigest string `json:"latestDeltaDigest"` + CustomerImpactDigest string `json:"customerImpactDigest"` + ApplicationCompatibilityDigest string `json:"applicationCompatibilityDigest"` + LimitationReportDigest string `json:"limitationReportDigest"` + AuditDigest string `json:"auditDigest"` + StabilizationHealthy bool `json:"stabilizationHealthy"` + AssessedByActorID string `json:"assessedByActorId"` + SourceCurrentAccessAt time.Time `json:"sourceCurrentAccessAt"` +} + +type RollbackReadinessCheckpointRecord struct { + RollbackReadinessCheckpoint + CreatedByActorID string `json:"createdByActorId"` +} diff --git a/apps/api/internal/billingmigration/operational_read_service_cde.go b/apps/api/internal/billingmigration/operational_read_service_cde.go new file mode 100644 index 00000000..6a234d8f --- /dev/null +++ b/apps/api/internal/billingmigration/operational_read_service_cde.go @@ -0,0 +1,154 @@ +package billingmigration + +import "context" + +func (s *OperationalReadService) Cases(ctx context.Context, actor Actor, projectID, programID, cursor string, limit int) (CasePage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return CasePage{}, err + } + return s.repo.ListCases(ctx, projectID, programID, cursor, limit) +} +func (s *OperationalReadService) Case(ctx context.Context, actor Actor, projectID, programID, id string) (MigrationCase, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return MigrationCase{}, err + } + return s.repo.ReadCase(ctx, projectID, programID, id) +} +func (s *OperationalReadService) CaseActions(ctx context.Context, actor Actor, projectID, programID, caseID, cursor string, limit int) (CaseActionPage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return CaseActionPage{}, err + } + return s.repo.ListCaseActions(ctx, projectID, programID, caseID, cursor, limit) +} +func (s *OperationalReadService) RepairPreviews(ctx context.Context, actor Actor, projectID, programID, cursor string, limit int) (RepairPreviewPage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return RepairPreviewPage{}, err + } + return s.repo.ListRepairPreviews(ctx, projectID, programID, cursor, limit) +} +func (s *OperationalReadService) RepairPreview(ctx context.Context, actor Actor, projectID, programID, id string) (RepairPreviewRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return RepairPreviewRecord{}, err + } + return s.repo.ReadRepairPreview(ctx, projectID, programID, id) +} +func (s *OperationalReadService) RepairExecutions(ctx context.Context, actor Actor, projectID, programID, cursor string, limit int) (RepairExecutionPage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return RepairExecutionPage{}, err + } + return s.repo.ListRepairExecutions(ctx, projectID, programID, cursor, limit) +} +func (s *OperationalReadService) RepairExecution(ctx context.Context, actor Actor, projectID, programID, id string) (RepairExecutionRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return RepairExecutionRecord{}, err + } + return s.repo.ReadRepairExecution(ctx, projectID, programID, id) +} +func (s *OperationalReadService) Redeliveries(ctx context.Context, actor Actor, projectID, programID, cursor string, limit int) (RedeliveryPage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return RedeliveryPage{}, err + } + return s.repo.ListRedeliveries(ctx, projectID, programID, cursor, limit) +} +func (s *OperationalReadService) Redelivery(ctx context.Context, actor Actor, projectID, programID, id string) (RedeliveryRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return RedeliveryRecord{}, err + } + return s.repo.ReadRedelivery(ctx, projectID, programID, id) +} +func (s *OperationalReadService) CredentialRemovals(ctx context.Context, actor Actor, projectID, programID, cursor string, limit int) (CredentialRemovalPage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return CredentialRemovalPage{}, err + } + return s.repo.ListCredentialRemovals(ctx, projectID, programID, cursor, limit) +} +func (s *OperationalReadService) CredentialRemoval(ctx context.Context, actor Actor, projectID, programID, id string) (CredentialRemovalRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return CredentialRemovalRecord{}, err + } + return s.repo.ReadCredentialRemoval(ctx, projectID, programID, id) +} +func (s *OperationalReadService) CurrentCredentialRemoval(ctx context.Context, actor Actor, projectID, programID string) (CredentialRemovalRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return CredentialRemovalRecord{}, err + } + return s.repo.CurrentCredentialRemoval(ctx, projectID, programID) +} +func (s *OperationalReadService) LegalHoldProposals(ctx context.Context, actor Actor, projectID, programID, cursor string, limit int) (LegalHoldProposalPage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return LegalHoldProposalPage{}, err + } + return s.repo.ListLegalHoldProposals(ctx, projectID, programID, cursor, limit) +} +func (s *OperationalReadService) LegalHoldProposal(ctx context.Context, actor Actor, projectID, programID, id string) (LegalHoldProposalRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return LegalHoldProposalRecord{}, err + } + return s.repo.ReadLegalHoldProposal(ctx, projectID, programID, id) +} +func (s *OperationalReadService) LegalHolds(ctx context.Context, actor Actor, projectID, programID, cursor string, limit int) (LegalHoldPage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return LegalHoldPage{}, err + } + return s.repo.ListLegalHolds(ctx, projectID, programID, cursor, limit) +} +func (s *OperationalReadService) LegalHold(ctx context.Context, actor Actor, projectID, programID, id string) (LegalHoldRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return LegalHoldRecord{}, err + } + return s.repo.ReadLegalHold(ctx, projectID, programID, id) +} +func (s *OperationalReadService) CurrentLegalHold(ctx context.Context, actor Actor, projectID, programID string) (LegalHoldRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return LegalHoldRecord{}, err + } + return s.repo.CurrentLegalHold(ctx, projectID, programID) +} +func (s *OperationalReadService) CompletionReports(ctx context.Context, actor Actor, projectID, programID, cursor string, limit int) (CompletionReportPage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return CompletionReportPage{}, err + } + return s.repo.ListCompletionReports(ctx, projectID, programID, cursor, limit) +} +func (s *OperationalReadService) CompletionReport(ctx context.Context, actor Actor, projectID, programID, id string) (CompletionReportRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return CompletionReportRecord{}, err + } + return s.repo.ReadCompletionReport(ctx, projectID, programID, id) +} +func (s *OperationalReadService) CurrentStabilizationPolicy(ctx context.Context, actor Actor, projectID, programID string) (StabilizationPolicyRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return StabilizationPolicyRecord{}, err + } + return s.repo.CurrentStabilizationPolicy(ctx, projectID, programID) +} +func (s *OperationalReadService) StabilizationObservations(ctx context.Context, actor Actor, projectID, programID, cursor string, limit int) (StabilizationObservationPage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return StabilizationObservationPage{}, err + } + return s.repo.ListStabilizationObservations(ctx, projectID, programID, cursor, limit) +} +func (s *OperationalReadService) LatestStabilizationObservation(ctx context.Context, actor Actor, projectID, programID string) (StabilizationObservationRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return StabilizationObservationRecord{}, err + } + return s.repo.LatestStabilizationObservation(ctx, projectID, programID) +} +func (s *OperationalReadService) RollbackReadinessAssessments(ctx context.Context, actor Actor, projectID, programID, cursor string, limit int) (RollbackReadinessAssessmentPage, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return RollbackReadinessAssessmentPage{}, err + } + return s.repo.ListRollbackReadinessAssessments(ctx, projectID, programID, cursor, limit) +} +func (s *OperationalReadService) LatestRollbackReadinessAssessment(ctx context.Context, actor Actor, projectID, programID string) (RollbackReadinessAssessmentRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return RollbackReadinessAssessmentRecord{}, err + } + return s.repo.LatestRollbackReadinessAssessment(ctx, projectID, programID) +} +func (s *OperationalReadService) LatestRollbackReadinessCheckpoint(ctx context.Context, actor Actor, projectID, programID string) (RollbackReadinessCheckpointRecord, error) { + if err := s.view(ctx, actor, projectID); err != nil { + return RollbackReadinessCheckpointRecord{}, err + } + return s.repo.LatestRollbackReadinessCheckpoint(ctx, projectID, programID) +} diff --git a/apps/api/internal/billingmigration/operational_reads.go b/apps/api/internal/billingmigration/operational_reads.go new file mode 100644 index 00000000..62ec3051 --- /dev/null +++ b/apps/api/internal/billingmigration/operational_reads.go @@ -0,0 +1,257 @@ +package billingmigration + +import ( + "context" + "time" +) + +type SourcePullJobPage struct { + Items []SourcePullJob `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type ProposalPage struct { + Items []CutoverProposal `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type ApprovalPage struct { + Items []MigrationApproval `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type CheckpointPage struct { + Items []MigrationCheckpoint `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type AuthorityExecutionPage struct { + Items []AuthorityExecution `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type CasePage struct { + Items []MigrationCase `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type CaseAction struct { + ID string `json:"actionId"` + CaseID string `json:"caseId"` + ProgramID string `json:"programId"` + ActorID string `json:"actorId"` + Action string `json:"action"` + BeforeDigest string `json:"beforeDigest"` + AfterDigest string `json:"afterDigest"` + CreatedAt time.Time `json:"createdAt"` +} +type CaseActionPage struct { + Items []CaseAction `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type RepairPreviewPage struct { + Items []RepairPreviewRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type RepairExecutionPage struct { + Items []RepairExecutionRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type RedeliveryPage struct { + Items []RedeliveryRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type CredentialRemovalPage struct { + Items []CredentialRemovalRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type LegalHoldProposalPage struct { + Items []LegalHoldProposalRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type LegalHoldPage struct { + Items []LegalHoldRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type CompletionReportPage struct { + Items []CompletionReportRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type StabilizationPolicyPage struct { + Items []StabilizationPolicyRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type StabilizationObservationPage struct { + Items []StabilizationObservationRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type RollbackReadinessAssessmentPage struct { + Items []RollbackReadinessAssessmentRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} +type RollbackReadinessCheckpointPage struct { + Items []RollbackReadinessCheckpointRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} + +type OperationalReadService struct { + auth Repository + repo TypedOperationalReadRepository +} + +func NewOperationalReadService(auth Repository, repo TypedOperationalReadRepository) *OperationalReadService { + return &OperationalReadService{auth: auth, repo: repo} +} +func (s *OperationalReadService) typed() (TypedOperationalReadRepository, error) { + if s == nil || s.repo == nil { + return nil, ErrUnavailable + } + return s.repo, nil +} +func (s *OperationalReadService) view(ctx context.Context, a Actor, p string) error { + _, e := s.auth.Authorize(ctx, a, p, CapabilityView) + return e +} +func (s *OperationalReadService) SourcePulls(ctx context.Context, a Actor, p, program, cursor string, limit int) (SourcePullJobPage, error) { + if e := s.view(ctx, a, p); e != nil { + return SourcePullJobPage{}, e + } + r, e := s.typed() + if e != nil { + return SourcePullJobPage{}, e + } + return r.ListSourcePullJobs(ctx, p, program, cursor, limit) +} +func (s *OperationalReadService) SourcePull(ctx context.Context, a Actor, p, program, id string) (SourcePullJob, error) { + if e := s.view(ctx, a, p); e != nil { + return SourcePullJob{}, e + } + r, e := s.typed() + if e != nil { + return SourcePullJob{}, e + } + return r.SourcePullJob(ctx, p, program, id) +} +func (s *OperationalReadService) Proposals(ctx context.Context, a Actor, p, program, cursor string, limit int) (ProposalPage, error) { + if e := s.view(ctx, a, p); e != nil { + return ProposalPage{}, e + } + r, e := s.typed() + if e != nil { + return ProposalPage{}, e + } + return r.ListProposals(ctx, p, program, cursor, limit) +} +func (s *OperationalReadService) Proposal(ctx context.Context, a Actor, p, program, id string) (CutoverProposal, error) { + if e := s.view(ctx, a, p); e != nil { + return CutoverProposal{}, e + } + r, e := s.typed() + if e != nil { + return CutoverProposal{}, e + } + return r.ReadProposal(ctx, p, program, id) +} +func (s *OperationalReadService) Approvals(ctx context.Context, a Actor, p, program, cursor string, limit int) (ApprovalPage, error) { + if e := s.view(ctx, a, p); e != nil { + return ApprovalPage{}, e + } + r, e := s.typed() + if e != nil { + return ApprovalPage{}, e + } + return r.ListApprovals(ctx, p, program, cursor, limit) +} +func (s *OperationalReadService) Approval(ctx context.Context, a Actor, p, program, id string) (MigrationApproval, error) { + if e := s.view(ctx, a, p); e != nil { + return MigrationApproval{}, e + } + r, e := s.typed() + if e != nil { + return MigrationApproval{}, e + } + return r.Approval(ctx, p, program, id) +} +func (s *OperationalReadService) Checkpoints(ctx context.Context, a Actor, p, program, cursor string, limit int) (CheckpointPage, error) { + if e := s.view(ctx, a, p); e != nil { + return CheckpointPage{}, e + } + r, e := s.typed() + if e != nil { + return CheckpointPage{}, e + } + return r.ListCheckpoints(ctx, p, program, cursor, limit) +} +func (s *OperationalReadService) Checkpoint(ctx context.Context, a Actor, p, program, id string) (MigrationCheckpoint, error) { + if e := s.view(ctx, a, p); e != nil { + return MigrationCheckpoint{}, e + } + r, e := s.typed() + if e != nil { + return MigrationCheckpoint{}, e + } + return r.Checkpoint(ctx, p, program, id) +} +func (s *OperationalReadService) LatestCheckpoint(ctx context.Context, a Actor, p, program string) (MigrationCheckpoint, error) { + if e := s.view(ctx, a, p); e != nil { + return MigrationCheckpoint{}, e + } + r, e := s.typed() + if e != nil { + return MigrationCheckpoint{}, e + } + return r.LatestCheckpoint(ctx, p, program) +} +func (s *OperationalReadService) AuthorityExecutions(ctx context.Context, a Actor, p, program, cursor string, limit int) (AuthorityExecutionPage, error) { + if e := s.view(ctx, a, p); e != nil { + return AuthorityExecutionPage{}, e + } + r, e := s.typed() + if e != nil { + return AuthorityExecutionPage{}, e + } + return r.ListAuthorityExecutions(ctx, p, program, cursor, limit) +} +func (s *OperationalReadService) AuthorityExecution(ctx context.Context, a Actor, p, program, id string) (AuthorityExecution, error) { + if e := s.view(ctx, a, p); e != nil { + return AuthorityExecution{}, e + } + r, e := s.typed() + if e != nil { + return AuthorityExecution{}, e + } + return r.AuthorityExecution(ctx, p, program, id) +} + +type TypedOperationalReadRepository interface { + ListSourcePullJobs(context.Context, string, string, string, int) (SourcePullJobPage, error) + SourcePullJob(context.Context, string, string, string) (SourcePullJob, error) + ListProposals(context.Context, string, string, string, int) (ProposalPage, error) + ReadProposal(context.Context, string, string, string) (CutoverProposal, error) + ListApprovals(context.Context, string, string, string, int) (ApprovalPage, error) + Approval(context.Context, string, string, string) (MigrationApproval, error) + ListCheckpoints(context.Context, string, string, string, int) (CheckpointPage, error) + Checkpoint(context.Context, string, string, string) (MigrationCheckpoint, error) + LatestCheckpoint(context.Context, string, string) (MigrationCheckpoint, error) + ListAuthorityExecutions(context.Context, string, string, string, int) (AuthorityExecutionPage, error) + AuthorityExecution(context.Context, string, string, string) (AuthorityExecution, error) + ListCases(context.Context, string, string, string, int) (CasePage, error) + ReadCase(context.Context, string, string, string) (MigrationCase, error) + ListCaseActions(context.Context, string, string, string, string, int) (CaseActionPage, error) + ListRepairPreviews(context.Context, string, string, string, int) (RepairPreviewPage, error) + ReadRepairPreview(context.Context, string, string, string) (RepairPreviewRecord, error) + ListRepairExecutions(context.Context, string, string, string, int) (RepairExecutionPage, error) + ReadRepairExecution(context.Context, string, string, string) (RepairExecutionRecord, error) + ListRedeliveries(context.Context, string, string, string, int) (RedeliveryPage, error) + ReadRedelivery(context.Context, string, string, string) (RedeliveryRecord, error) + ListCredentialRemovals(context.Context, string, string, string, int) (CredentialRemovalPage, error) + ReadCredentialRemoval(context.Context, string, string, string) (CredentialRemovalRecord, error) + CurrentCredentialRemoval(context.Context, string, string) (CredentialRemovalRecord, error) + ListLegalHoldProposals(context.Context, string, string, string, int) (LegalHoldProposalPage, error) + ReadLegalHoldProposal(context.Context, string, string, string) (LegalHoldProposalRecord, error) + ListLegalHolds(context.Context, string, string, string, int) (LegalHoldPage, error) + ReadLegalHold(context.Context, string, string, string) (LegalHoldRecord, error) + CurrentLegalHold(context.Context, string, string) (LegalHoldRecord, error) + ListCompletionReports(context.Context, string, string, string, int) (CompletionReportPage, error) + ReadCompletionReport(context.Context, string, string, string) (CompletionReportRecord, error) + CurrentStabilizationPolicy(context.Context, string, string) (StabilizationPolicyRecord, error) + ListStabilizationObservations(context.Context, string, string, string, int) (StabilizationObservationPage, error) + LatestStabilizationObservation(context.Context, string, string) (StabilizationObservationRecord, error) + ListRollbackReadinessAssessments(context.Context, string, string, string, int) (RollbackReadinessAssessmentPage, error) + LatestRollbackReadinessAssessment(context.Context, string, string) (RollbackReadinessAssessmentRecord, error) + LatestRollbackReadinessCheckpoint(context.Context, string, string) (RollbackReadinessCheckpointRecord, error) +} diff --git a/apps/api/internal/billingmigration/operations_repository.go b/apps/api/internal/billingmigration/operations_repository.go new file mode 100644 index 00000000..a4ee6aa0 --- /dev/null +++ b/apps/api/internal/billingmigration/operations_repository.go @@ -0,0 +1,157 @@ +package billingmigration + +import ( + "context" + "time" +) + +const ( + CapabilityResolveCases = "resolve-cases" + CapabilityExecuteRepair = "execute-repair" + CapabilityDeleteSource = "delete-source" + CapabilityRemoveCredential = "remove-credential" + CapabilityManageLegalHold = "manage-legal-hold" + CapabilityCompleteMigration = "complete-migration" +) + +type OperationsRepository interface { + CreateCase(context.Context, CaseWrite) (MigrationCase, bool, error) + TransitionCase(context.Context, CaseTransitionWrite) (MigrationCase, error) + CreateRepairPreview(context.Context, RepairPreviewWrite) (RepairPreview, bool, error) + PrepareRepair(context.Context, RepairExecutionWrite) (PreparedRepair, error) + SettleRepair(context.Context, RepairSettlement) (RepairExecution, error) + RemoveCredential(context.Context, CredentialRemovalWrite) (CredentialRemoval, bool, error) + ProposeLegalHold(context.Context, LegalHoldProposalWrite) (LegalHoldProposal, bool, error) + ApproveLegalHold(context.Context, LegalHoldApprovalWrite) (LegalHold, bool, error) + CompletionPrerequisites(context.Context, string, string, time.Time) (CompletionPrerequisites, error) + CompleteMigration(context.Context, CompletionWrite) (CompletionReport, bool, error) + ClaimRetention(context.Context, RetentionClaim) (RetentionLease, error) + SettleRetentionObject(context.Context, RetentionObjectSettlement) error + FinishRetention(context.Context, RetentionFinish) error +} + +type CaseWrite struct { + Case MigrationCase + ActorID string + IdempotencyKey string + RequestDigest []byte + ExpectedState int64 + LinkedDivergence string + LinkedRecord string +} + +type CaseTransitionWrite struct { + ProjectID, ProgramID, CaseID, ActorID, Status, Reason string + ExpectedStateVersion int64 + ExpectedCaseDigest, NewCaseDigest []byte + At time.Time +} + +type RepairPreviewWrite struct { + Preview RepairPreview + ActorID string + IdempotencyKey string + RequestDigest []byte + CaseDigest []byte + PolicyDigest []byte + ScopeDigest []byte +} + +type RepairExecutionWrite struct { + ProjectID, ProgramID, PreviewID, ActorID, IdempotencyKey string + ExpectedStateVersion int64 + ExpectedPreviewDigest, ExpectedCaseDigest []byte + ExpectedPolicyDigest, ExpectedScopeDigest []byte + RequestDigest []byte + At time.Time +} + +type PreparedRepair struct { + ExecutionID, RepairKind, CaseID string + ScopeReferences []string + AttemptNumber int + PreviewBeforeDigest []byte + State string + SettledExecution *RepairExecution +} + +const ( + RepairPreparationNew = "new" + RepairPreparationUnsettled = "unsettled" + RepairPreparationSettled = "settled" +) + +type RepairSettlement struct { + ExecutionID, ProgramID, ProjectID, Result, ErrorCode string + AttemptNumber int + ActualBeforeDigest, ActualAfterDigest, ResultDigest []byte + Invalidations []RepairInvalidation + At time.Time +} + +type RepairInvalidation struct { + Kind, ReferenceID string + Digest []byte +} + +type CredentialRemovalWrite struct { + Removal CredentialRemoval + ExpectedState int64 + RequestDigest []byte + IrreversibleAck bool + IdempotencyKey string +} + +type LegalHoldProposalWrite struct { + Proposal LegalHoldProposal + IdempotencyKey string + RequestDigest, ExpectedPreviousDigest []byte +} +type LegalHoldApprovalWrite struct { + ProjectID, ProgramID, ProposalID, ApproverActorID, IdempotencyKey string + ExpectedProposalDigest, RequestDigest []byte + At time.Time +} + +type CompletionWrite struct { + Report CompletionReport + ExpectedStateVersion int64 + ExpectedPolicyDigest []byte + AuthorityDigest []byte + StabilityDigest []byte + CompletionDigest []byte + RequestDigest []byte + RetentionJobID string + DeletionIdentity []byte + ActorID string + IdempotencyKey string +} + +type RetentionClaim struct { + WorkerID string + Now time.Time + LeaseFor time.Duration +} + +type RetentionLease struct { + JobID, ProgramID, ProjectID string + Generation int64 + AttemptNumber, MaxAttempts int + ObjectKeys []string + LegalHold bool +} + +type RetentionObjectSettlement struct { + JobID, ProgramID, ProjectID, ObjectKey, Result string + Generation int64 + AttemptNumber int + ObjectKeyDigest, DeletionDigest []byte + At time.Time +} + +type RetentionFinish struct { + JobID, ErrorCode string + Generation int64 + RetryAt time.Time + At time.Time +} diff --git a/apps/api/internal/billingmigration/operations_test.go b/apps/api/internal/billingmigration/operations_test.go new file mode 100644 index 00000000..194d3cb6 --- /dev/null +++ b/apps/api/internal/billingmigration/operations_test.go @@ -0,0 +1,224 @@ +package billingmigration + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +type operationsAuth struct{ err error } + +func (a operationsAuth) Authorize(context.Context, Actor, string, string) (Authorization, error) { + return Authorization{Role: "owner"}, a.err +} +func (operationsAuth) Idempotency(context.Context, string, string) (StoredIdempotency, error) { + return StoredIdempotency{}, ErrNotFound +} +func (operationsAuth) CreateProgram(context.Context, CreateProgramCommand) (ProgramDetail, error) { + return ProgramDetail{}, ErrUnavailable +} +func (operationsAuth) ListPrograms(context.Context, string, int) ([]ProgramDetail, error) { + return nil, ErrUnavailable +} +func (operationsAuth) Program(context.Context, string, string) (ProgramDetail, error) { + return ProgramDetail{}, ErrUnavailable +} + +type operationsRepoStub struct { + prepared PreparedRepair + settlement RepairSettlement + settled bool + finish RetentionFinish + lease RetentionLease + proposal LegalHoldProposalWrite +} + +func (*operationsRepoStub) CreateCase(context.Context, CaseWrite) (MigrationCase, bool, error) { + return MigrationCase{}, false, ErrUnavailable +} +func (*operationsRepoStub) TransitionCase(context.Context, CaseTransitionWrite) (MigrationCase, error) { + return MigrationCase{}, ErrUnavailable +} +func (*operationsRepoStub) CreateRepairPreview(context.Context, RepairPreviewWrite) (RepairPreview, bool, error) { + return RepairPreview{}, false, ErrUnavailable +} +func (r *operationsRepoStub) PrepareRepair(context.Context, RepairExecutionWrite) (PreparedRepair, error) { + return r.prepared, nil +} +func (r *operationsRepoStub) SettleRepair(_ context.Context, s RepairSettlement) (RepairExecution, error) { + r.settlement = s + r.settled = true + return RepairExecution{ExecutionID: s.ExecutionID, Result: s.Result}, nil +} +func (*operationsRepoStub) RemoveCredential(context.Context, CredentialRemovalWrite) (CredentialRemoval, bool, error) { + return CredentialRemoval{}, false, ErrUnavailable +} +func (r *operationsRepoStub) ProposeLegalHold(_ context.Context, w LegalHoldProposalWrite) (LegalHoldProposal, bool, error) { + r.proposal = w + return w.Proposal, false, nil +} +func (*operationsRepoStub) ApproveLegalHold(context.Context, LegalHoldApprovalWrite) (LegalHold, bool, error) { + return LegalHold{}, false, ErrUnavailable +} +func (*operationsRepoStub) CompletionPrerequisites(context.Context, string, string, time.Time) (CompletionPrerequisites, error) { + return CompletionPrerequisites{}, ErrUnavailable +} +func (*operationsRepoStub) CompleteMigration(context.Context, CompletionWrite) (CompletionReport, bool, error) { + return CompletionReport{}, false, ErrUnavailable +} +func (r *operationsRepoStub) ClaimRetention(context.Context, RetentionClaim) (RetentionLease, error) { + return r.lease, nil +} +func (*operationsRepoStub) SettleRetentionObject(context.Context, RetentionObjectSettlement) error { + return nil +} +func (r *operationsRepoStub) FinishRetention(_ context.Context, f RetentionFinish) error { + r.finish = f + return nil +} + +type repairExecutorStub struct { + calls int + executionID string + result RepairResult + err error +} + +func (*repairExecutorStub) Preview(context.Context, RepairRequest) (RepairImpact, error) { + return RepairImpact{}, ErrUnavailable +} +func (e *repairExecutorStub) Execute(_ context.Context, request RepairRequest) (RepairResult, error) { + e.calls++ + e.executionID = request.ExecutionID + if e.result.BeforeDigest != nil || e.result.AfterDigest != nil || e.err != nil { + return e.result, e.err + } + return RepairResult{BeforeDigest: make([]byte, 32), AfterDigest: bytesFilled(1)}, nil +} +func bytesFilled(v byte) []byte { + b := make([]byte, 32) + for i := range b { + b[i] = v + } + return b +} + +func TestExecuteRepairRerunsUnsettledReservationWithStableIdentity(t *testing.T) { + repo := &operationsRepoStub{prepared: PreparedRepair{ExecutionID: "mre_stable", RepairKind: RepairRevalidateProviderReference, CaseID: "case", ScopeReferences: []string{"provider-ref"}, AttemptNumber: 1, PreviewBeforeDigest: make([]byte, 32), State: RepairPreparationUnsettled}} + executor := &repairExecutorStub{} + service := NewOperationsService(operationsAuth{}, repo, executor, WithClock(func() time.Time { return time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) })) + d := FormatDigest(make([]byte, 32)) + result, replay, err := service.ExecuteRepair(context.Background(), Actor{ID: "owner"}, ExecuteRepairInput{ProjectID: "project", ProgramID: "program", PreviewID: "preview", IdempotencyKey: "execute", ExpectedStateVersion: 1, ExpectedPreviewDigest: d, ExpectedCaseDigest: d, ExpectedPolicyDigest: d, ExpectedScopeDigest: d}) + if err != nil || !replay || executor.calls != 1 || result.ExecutionID != "mre_stable" { + t.Fatalf("result=%#v replay=%v calls=%d err=%v", result, replay, executor.calls, err) + } + if executor.executionID != "mre_stable" { + t.Fatalf("executor execution ID = %q", executor.executionID) + } + if repo.settlement.Result == "" || len(repo.settlement.ActualAfterDigest) != 32 { + t.Fatalf("empty crash-window settlement: %#v", repo.settlement) + } +} + +func TestExecuteRepairLeavesPendingValidationReservationUnsettled(t *testing.T) { + repo := &operationsRepoStub{prepared: PreparedRepair{ExecutionID: "mre_pending", RepairKind: RepairRevalidateProviderReference, CaseID: "case", ScopeReferences: []string{"provider-ref"}, AttemptNumber: 1, PreviewBeforeDigest: make([]byte, 32), State: RepairPreparationUnsettled}} + executor := &repairExecutorStub{result: RepairResult{BeforeDigest: make([]byte, 32), AfterDigest: make([]byte, 32), ErrorCode: "provider_validation_pending"}, err: ErrValidationPending} + service := NewOperationsService(operationsAuth{}, repo, executor, WithClock(func() time.Time { return time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) })) + d := FormatDigest(make([]byte, 32)) + result, replay, err := service.ExecuteRepair(context.Background(), Actor{ID: "owner"}, ExecuteRepairInput{ProjectID: "project", ProgramID: "program", PreviewID: "preview", IdempotencyKey: "execute", ExpectedStateVersion: 1, ExpectedPreviewDigest: d, ExpectedCaseDigest: d, ExpectedPolicyDigest: d, ExpectedScopeDigest: d}) + if err != nil || !replay || result.ExecutionID != "mre_pending" || result.Status != "pending" || result.Result != "" { + t.Fatalf("result=%#v replay=%v err=%v", result, replay, err) + } + if result.AfterDigest != "" || result.ResultDigest != "" { + t.Fatalf("pending validation claimed terminal digests: %#v", result) + } + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err = json.Unmarshal(encoded, &fields); err != nil { + t.Fatal(err) + } + if _, exists := fields["afterDigest"]; exists { + t.Fatalf("pending validation serialized terminal afterDigest: %s", encoded) + } + if _, exists := fields["resultDigest"]; exists { + t.Fatalf("pending validation serialized terminal resultDigest: %s", encoded) + } + retried, replay, err := service.ExecuteRepair(context.Background(), Actor{ID: "owner"}, ExecuteRepairInput{ProjectID: "project", ProgramID: "program", PreviewID: "preview", IdempotencyKey: "execute", ExpectedStateVersion: 1, ExpectedPreviewDigest: d, ExpectedCaseDigest: d, ExpectedPolicyDigest: d, ExpectedScopeDigest: d}) + if err != nil || !replay || retried.ExecutionID != result.ExecutionID || retried.Status != "pending" || retried.Result != "" || executor.calls != 2 { + t.Fatalf("retry result=%#v replay=%v calls=%d err=%v", retried, replay, executor.calls, err) + } + if repo.settled { + t.Fatalf("pending validation settled reservation: %#v", repo.settlement) + } +} + +func TestExecuteRepairReturnsSettledReplayWithoutExecutorCall(t *testing.T) { + stored := RepairExecution{ExecutionID: "mre_settled", ProgramID: "program", Result: "succeeded", AttemptNumber: 1} + repo := &operationsRepoStub{prepared: PreparedRepair{ExecutionID: stored.ExecutionID, State: RepairPreparationSettled, SettledExecution: &stored}} + executor := &repairExecutorStub{} + service := NewOperationsService(operationsAuth{}, repo, executor) + d := FormatDigest(make([]byte, 32)) + result, replay, err := service.ExecuteRepair(context.Background(), Actor{ID: "owner"}, ExecuteRepairInput{ProjectID: "project", ProgramID: "program", PreviewID: "preview", IdempotencyKey: "execute", ExpectedStateVersion: 1, ExpectedPreviewDigest: d, ExpectedCaseDigest: d, ExpectedPolicyDigest: d, ExpectedScopeDigest: d}) + if err != nil || !replay || executor.calls != 0 || result.ExecutionID != stored.ExecutionID || result.Status != "completed" { + t.Fatalf("result=%#v replay=%v calls=%d err=%v", result, replay, executor.calls, err) + } +} + +type retryableDeleter struct{} + +func (retryableDeleter) DeleteRawSourceObject(context.Context, string) (string, error) { + return "retryable_failure", nil +} +func TestRetentionRetriesExplicitRetryableResultWithoutError(t *testing.T) { + repo := &operationsRepoStub{lease: RetentionLease{JobID: "job", ProgramID: "program", ProjectID: "project", Generation: 2, AttemptNumber: 1, MaxAttempts: 8, ObjectKeys: []string{"private/object"}}} + service := NewOperationsService(operationsAuth{}, repo, nil, WithClock(func() time.Time { return time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) })) + if err := service.RunRetention(context.Background(), "worker", time.Minute, retryableDeleter{}); err != nil { + t.Fatal(err) + } + if repo.finish.ErrorCode != "object_delete_retryable" || repo.finish.RetryAt.IsZero() { + t.Fatalf("finish=%#v", repo.finish) + } +} + +func TestLegalHoldProposalUsesAuthenticatedActor(t *testing.T) { + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + repo := &operationsRepoStub{} + service := NewOperationsService(operationsAuth{}, repo, nil, WithClock(func() time.Time { return now })) + proposal, _, err := service.ProposeLegalHold(context.Background(), Actor{ID: "actual_owner"}, ProposeLegalHoldInput{ProjectID: "project", ProgramID: "program", IdempotencyKey: "hold", Command: "set", Reason: "Regulatory preservation request", ExternalComplianceReference: "LEGAL-1", ExpiresAt: now.Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + if proposal.ProposerActorID != "actual_owner" || repo.proposal.Proposal.ProposerActorID != "actual_owner" { + t.Fatalf("proposal actor=%q write actor=%q", proposal.ProposerActorID, repo.proposal.Proposal.ProposerActorID) + } +} + +func TestOperationsServiceEnforcesCapabilityAuthorization(t *testing.T) { + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + repo := &operationsRepoStub{} + service := NewOperationsService(operationsAuth{err: ErrForbidden}, repo, nil, WithClock(func() time.Time { return now })) + _, _, err := service.ProposeLegalHold(context.Background(), Actor{ID: "admin"}, ProposeLegalHoldInput{ProjectID: "project", ProgramID: "program", IdempotencyKey: "hold", Command: "set", Reason: "Regulatory preservation request", ExternalComplianceReference: "LEGAL-1", ExpiresAt: now.Add(time.Hour)}) + if err != ErrForbidden { + t.Fatalf("authorization error=%v", err) + } + if repo.proposal.Proposal.ProposalID != "" { + t.Fatal("repository called after authorization denial") + } +} + +func TestCompletionSyncFreshnessWindow(t *testing.T) { + completed := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + if !SyncObservationFresh(completed.Add(-5*time.Minute), completed, 600) { + t.Fatal("fresh current-epoch observation rejected") + } + if SyncObservationFresh(completed.Add(-11*time.Minute), completed, 600) { + t.Fatal("stale observation accepted") + } + if SyncObservationFresh(completed.Add(time.Second), completed, 600) { + t.Fatal("future observation accepted") + } +} diff --git a/apps/api/internal/billingmigration/redelivery.go b/apps/api/internal/billingmigration/redelivery.go new file mode 100644 index 00000000..804616ac --- /dev/null +++ b/apps/api/internal/billingmigration/redelivery.go @@ -0,0 +1,86 @@ +package billingmigration + +import ( + "context" + "crypto/sha256" + "encoding/json" + "strings" + "time" +) + +const CapabilityRedeliverWebhook = "redeliver-webhook" + +type RedeliveryInput struct { + ProjectID, ProgramID, EventID, DestinationID string + IdempotencyKey, ExpectedEventDigest, Reason string + ExpectedStateVersion int64 +} + +type Redelivery struct { + ID string `json:"redeliveryId"` + ProgramID string `json:"programId"` + EventID string `json:"eventId"` + DestinationID string `json:"destinationId"` + DeliveryID string `json:"deliveryId"` + IdempotencyKey string `json:"-"` + Reason string `json:"reason"` + ExpectedStateVersion int64 `json:"stateVersion"` + CreatedAt time.Time `json:"createdAt"` +} + +type RedeliveryWrite struct { + Input RedeliveryInput + ActorID string + RequestDigest []byte + EventDigest []byte + CreatedAt time.Time + RedeliveryID string +} + +type RedeliveryRepository interface { + AuthorizeRedelivery(ctx context.Context, actor Actor, projectID, programID string) error + RedeliverWebhook(ctx context.Context, write RedeliveryWrite) (Redelivery, bool, error) +} + +type RedeliveryService struct { + repository RedeliveryRepository + now func() time.Time +} + +func NewRedeliveryService(repository RedeliveryRepository, now func() time.Time) *RedeliveryService { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + return &RedeliveryService{repository: repository, now: now} +} + +func (s *RedeliveryService) Redeliver(ctx context.Context, actor Actor, input RedeliveryInput) (Redelivery, bool, error) { + if strings.TrimSpace(input.ProjectID) == "" || strings.TrimSpace(input.ProgramID) == "" || + strings.TrimSpace(input.EventID) == "" || strings.TrimSpace(input.DestinationID) == "" || + strings.TrimSpace(input.IdempotencyKey) == "" || strings.TrimSpace(input.Reason) == "" || + len(input.Reason) > 500 || input.ExpectedStateVersion < 1 { + return Redelivery{}, false, ErrInvalid + } + eventDigest, err := ParseDigest(input.ExpectedEventDigest) + if err != nil { + return Redelivery{}, false, ErrInvalid + } + if err := s.repository.AuthorizeRedelivery(ctx, actor, input.ProjectID, input.ProgramID); err != nil { + return Redelivery{}, false, err + } + encoded, _ := json.Marshal(input) + requestDigest := sha256.Sum256(encoded) + id := sha256.Sum256([]byte("mosaic-migration-redelivery\x00" + input.ProgramID + "\x00" + input.IdempotencyKey)) + return s.repository.RedeliverWebhook(ctx, RedeliveryWrite{Input: input, ActorID: actor.ID, + RequestDigest: requestDigest[:], EventDigest: eventDigest, CreatedAt: s.now(), + RedeliveryID: "mwr_" + fmtHex(id[:16])}) +} + +func fmtHex(value []byte) string { + const alphabet = "0123456789abcdef" + result := make([]byte, len(value)*2) + for i, b := range value { + result[i*2], result[i*2+1] = alphabet[b>>4], alphabet[b&15] + } + return string(result) +} diff --git a/apps/api/internal/billingmigration/repairs.go b/apps/api/internal/billingmigration/repairs.go new file mode 100644 index 00000000..43fa7ee9 --- /dev/null +++ b/apps/api/internal/billingmigration/repairs.go @@ -0,0 +1,248 @@ +package billingmigration + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "io" + "time" +) + +const ( + RepairRevalidateProviderReference = "provider_revalidate" + RepairReplayFactRange = "projection_replay" + RepairAttachProvenAlias = "attach_proven_alias" + RepairReplaceMappingSet = "replace_mapping_set" + RepairRetryQuarantinedRecord = "retry_quarantined_record" +) + +type OperationsService struct { + auth Repository + repo OperationsRepository + repairs RepairExecutor + now func() time.Time + random io.Reader +} + +func NewOperationsService(auth Repository, repo OperationsRepository, repairs RepairExecutor, options ...Option) *OperationsService { + s := &OperationsService{auth: auth, repo: repo, repairs: repairs, now: func() time.Time { return time.Now().UTC() }, random: rand.Reader} + base := &Service{} + for _, o := range options { + o(base) + } + if base.now != nil { + s.now = base.now + } + if base.random != nil { + s.random = base.random + } + return s +} +func (s *OperationsService) newID(prefix string) (string, error) { + b := make([]byte, 12) + if _, err := io.ReadFull(s.random, b); err != nil { + return "", err + } + return prefix + "_" + FormatDigest(b)[7:31], nil +} + +type RepairPreview struct { + PreviewID string `json:"previewId"` + CaseID string `json:"caseId"` + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + RepairKind string `json:"repairKind"` + ScopeKind string `json:"scopeKind"` + Reason string `json:"reason"` + ScopeReferences []string `json:"scopeReferences"` + AffectedCount int `json:"affectedCount"` + ExpectedStateVersion int64 `json:"stateVersion"` + BeforeDigest string `json:"beforeDigest"` + AfterDigest string `json:"afterDigest"` + PreviewDigest string `json:"previewDigest"` + CaseDigest string `json:"caseDigest"` + PolicyDigest string `json:"policyDigest"` + ScopeDigest string `json:"scopeDigest"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` +} +type PreviewRepairInput struct { + ProjectID, ProgramID, CaseID, IdempotencyKey, RepairKind, ScopeKind, Reason string + ScopeReferences []string + ExpectedStateVersion int64 + ExpectedCaseDigest, ExpectedPolicyDigest, ExpectedScopeDigest string + ExpiresAt time.Time +} +type ExecuteRepairInput struct { + ProjectID, ProgramID, PreviewID, IdempotencyKey string + ExpectedStateVersion int64 + ExpectedPreviewDigest, ExpectedCaseDigest, ExpectedPolicyDigest, ExpectedScopeDigest string +} +type RepairExecution struct { + ExecutionID string `json:"executionId"` + PreviewID string `json:"previewId"` + ProgramID string `json:"programId"` + Status string `json:"executionStatus"` + Result string `json:"result,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + BeforeDigest string `json:"beforeDigest"` + AfterDigest string `json:"afterDigest,omitempty"` + ResultDigest string `json:"resultDigest,omitempty"` + AttemptNumber int `json:"attemptNumber"` + ExecutedAt time.Time `json:"executedAt"` +} +type RepairRequest struct { + Kind, ProgramID, CaseID, ExecutionID string + ScopeReferences []string +} +type RepairImpact struct { + AffectedCount int + BeforeDigest, AfterDigest []byte +} +type RepairResult struct { + BeforeDigest, AfterDigest []byte + ErrorCode string + Invalidations []RepairInvalidation +} +type RepairExecutor interface { + Preview(context.Context, RepairRequest) (RepairImpact, error) + Execute(context.Context, RepairRequest) (RepairResult, error) +} + +func (s *OperationsService) PreviewRepair(ctx context.Context, actor Actor, input PreviewRepairInput) (RepairPreview, bool, error) { + if s.repairs == nil || input.ProjectID == "" || input.ProgramID == "" || input.CaseID == "" || input.IdempotencyKey == "" || len(input.IdempotencyKey) > 128 || input.ExpectedStateVersion < 1 || !validText(input.Reason, 500) || !repairKind(input.RepairKind) || !repairScope(input.RepairKind, input.ScopeKind) || len(input.ScopeReferences) < 1 || len(input.ScopeReferences) > 100 { + return RepairPreview{}, false, ErrInvalid + } + seen := make(map[string]struct{}, len(input.ScopeReferences)) + for _, reference := range input.ScopeReferences { + if !validText(reference, 512) { + return RepairPreview{}, false, ErrInvalid + } + if _, exists := seen[reference]; exists { + return RepairPreview{}, false, ErrInvalid + } + seen[reference] = struct{}{} + } + caseDigest, err := ParseDigest(input.ExpectedCaseDigest) + if err != nil { + return RepairPreview{}, false, ErrInvalid + } + policy, err := ParseDigest(input.ExpectedPolicyDigest) + if err != nil { + return RepairPreview{}, false, ErrInvalid + } + scope, err := ParseDigest(input.ExpectedScopeDigest) + if err != nil { + return RepairPreview{}, false, ErrInvalid + } + if _, err = s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityExecuteRepair); err != nil { + return RepairPreview{}, false, err + } + now := s.now() + if !input.ExpiresAt.After(now) || input.ExpiresAt.After(now.Add(time.Hour)) { + return RepairPreview{}, false, ErrInvalid + } + impact, err := s.repairs.Preview(ctx, RepairRequest{Kind: input.RepairKind, ProgramID: input.ProgramID, CaseID: input.CaseID, ScopeReferences: append([]string(nil), input.ScopeReferences...)}) + if err != nil { + return RepairPreview{}, false, err + } + if impact.AffectedCount < 0 || impact.AffectedCount > 1000 { + return RepairPreview{}, false, ErrInvalid + } + if len(impact.BeforeDigest) != 32 || len(impact.AfterDigest) != 32 { + return RepairPreview{}, false, ErrInvalid + } + id, err := s.newID("mrp") + if err != nil { + return RepairPreview{}, false, ErrUnavailable + } + p := RepairPreview{PreviewID: id, CaseID: input.CaseID, ProgramID: input.ProgramID, ProjectID: input.ProjectID, RepairKind: input.RepairKind, ScopeKind: input.ScopeKind, Reason: input.Reason, ScopeReferences: append([]string(nil), input.ScopeReferences...), AffectedCount: impact.AffectedCount, ExpectedStateVersion: input.ExpectedStateVersion, BeforeDigest: FormatDigest(impact.BeforeDigest), AfterDigest: FormatDigest(impact.AfterDigest), CaseDigest: input.ExpectedCaseDigest, PolicyDigest: input.ExpectedPolicyDigest, ScopeDigest: input.ExpectedScopeDigest, CreatedAt: now, ExpiresAt: input.ExpiresAt.UTC()} + p.PreviewDigest = FormatDigest(digest(p)) + return s.repo.CreateRepairPreview(ctx, RepairPreviewWrite{Preview: p, ActorID: actor.ID, IdempotencyKey: input.IdempotencyKey, RequestDigest: digest(input), CaseDigest: caseDigest, PolicyDigest: policy, ScopeDigest: scope}) +} + +func (s *OperationsService) ExecuteRepair(ctx context.Context, actor Actor, input ExecuteRepairInput) (RepairExecution, bool, error) { + if s.repairs == nil || input.ProjectID == "" || input.ProgramID == "" || input.PreviewID == "" || input.IdempotencyKey == "" || input.ExpectedStateVersion < 1 { + return RepairExecution{}, false, ErrInvalid + } + preview, err := ParseDigest(input.ExpectedPreviewDigest) + if err != nil { + return RepairExecution{}, false, ErrInvalid + } + caseD, err := ParseDigest(input.ExpectedCaseDigest) + if err != nil { + return RepairExecution{}, false, ErrInvalid + } + policy, err := ParseDigest(input.ExpectedPolicyDigest) + if err != nil { + return RepairExecution{}, false, ErrInvalid + } + scope, err := ParseDigest(input.ExpectedScopeDigest) + if err != nil { + return RepairExecution{}, false, ErrInvalid + } + if _, err = s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityExecuteRepair); err != nil { + return RepairExecution{}, false, err + } + now := s.now() + prepared, err := s.repo.PrepareRepair(ctx, RepairExecutionWrite{ProjectID: input.ProjectID, ProgramID: input.ProgramID, PreviewID: input.PreviewID, ActorID: actor.ID, IdempotencyKey: input.IdempotencyKey, ExpectedStateVersion: input.ExpectedStateVersion, ExpectedPreviewDigest: preview, ExpectedCaseDigest: caseD, ExpectedPolicyDigest: policy, ExpectedScopeDigest: scope, RequestDigest: digest(input), At: now}) + if err != nil { + return RepairExecution{}, false, err + } + if prepared.State == RepairPreparationSettled { + if prepared.SettledExecution == nil { + return RepairExecution{}, false, ErrConflict + } + prepared.SettledExecution.Status = "completed" + return *prepared.SettledExecution, true, nil + } + if prepared.State != RepairPreparationNew && prepared.State != RepairPreparationUnsettled { + return RepairExecution{}, false, ErrConflict + } + replayed := prepared.State == RepairPreparationUnsettled + result, runErr := s.repairs.Execute(ctx, RepairRequest{Kind: prepared.RepairKind, ProgramID: input.ProgramID, CaseID: prepared.CaseID, ExecutionID: prepared.ExecutionID, ScopeReferences: prepared.ScopeReferences}) + if len(result.BeforeDigest) != 32 { + result.BeforeDigest = append([]byte(nil), prepared.PreviewBeforeDigest...) + } + if len(result.AfterDigest) != 32 { + result.AfterDigest = append([]byte(nil), result.BeforeDigest...) + } + if errors.Is(runErr, ErrValidationPending) { + return RepairExecution{ + ExecutionID: prepared.ExecutionID, + PreviewID: input.PreviewID, + ProgramID: input.ProgramID, + Status: "pending", + ErrorCode: result.ErrorCode, + BeforeDigest: FormatDigest(result.BeforeDigest), + AttemptNumber: prepared.AttemptNumber, + ExecutedAt: s.now(), + }, replayed, nil + } + status, errorCode := "succeeded", "" + if runErr != nil { + status = "failed" + errorCode = result.ErrorCode + if errorCode == "" { + errorCode = "repair_dependency_failed" + } + } else if bytes.Equal(result.BeforeDigest, result.AfterDigest) { + status = "no_change" + } + settled, settleErr := s.repo.SettleRepair(ctx, RepairSettlement{ExecutionID: prepared.ExecutionID, ProgramID: input.ProgramID, ProjectID: input.ProjectID, Result: status, ErrorCode: errorCode, AttemptNumber: prepared.AttemptNumber, ActualBeforeDigest: result.BeforeDigest, ActualAfterDigest: result.AfterDigest, ResultDigest: digest(result), Invalidations: result.Invalidations, At: s.now()}) + if settleErr != nil { + return RepairExecution{}, false, settleErr + } + settled.Status = "completed" + if runErr != nil { + return settled, replayed, runErr + } + return settled, replayed, nil +} +func repairKind(v string) bool { + return v == RepairRevalidateProviderReference || v == RepairReplayFactRange || v == RepairAttachProvenAlias || v == RepairReplaceMappingSet || v == RepairRetryQuarantinedRecord +} +func repairScope(k, s string) bool { + return (k == RepairRevalidateProviderReference && s == "provider_reference") || (k == RepairReplayFactRange && s == "fact_range") || (k == RepairAttachProvenAlias && s == "audited_alias") || (k == RepairReplaceMappingSet && s == "mapping_set") || (k == RepairRetryQuarantinedRecord && s == "source_record") +} diff --git a/apps/api/internal/billingmigration/repairs_test.go b/apps/api/internal/billingmigration/repairs_test.go new file mode 100644 index 00000000..1566e50d --- /dev/null +++ b/apps/api/internal/billingmigration/repairs_test.go @@ -0,0 +1,48 @@ +package billingmigration + +import "testing" + +func TestRepairAllowlistAndScopePairing(t *testing.T) { + allowed := map[string]string{ + RepairRevalidateProviderReference: "provider_reference", + RepairReplayFactRange: "fact_range", + RepairAttachProvenAlias: "audited_alias", + RepairReplaceMappingSet: "mapping_set", + RepairRetryQuarantinedRecord: "source_record", + } + for kind, scope := range allowed { + if !repairKind(kind) || !repairScope(kind, scope) { + t.Fatalf("allowlisted repair %q/%q rejected", kind, scope) + } + if repairScope(kind, "arbitrary_row") { + t.Fatalf("repair %q accepted arbitrary scope", kind) + } + } + for _, kind := range []string{"arbitrary_sql", "grant_forever", "mutate_fact", "mutate_snapshot", "mutate_live_pointer"} { + if repairKind(kind) { + t.Fatalf("unsafe repair %q accepted", kind) + } + } +} + +func TestRepairKindsMatchBillingMigrationOperationsV1(t *testing.T) { + got := []string{ + RepairRevalidateProviderReference, + RepairReplayFactRange, + RepairAttachProvenAlias, + RepairReplaceMappingSet, + RepairRetryQuarantinedRecord, + } + want := []string{ + "provider_revalidate", + "projection_replay", + "attach_proven_alias", + "replace_mapping_set", + "retry_quarantined_record", + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("repair kind %d = %q, want protocol value %q", i, got[i], want[i]) + } + } +} diff --git a/apps/api/internal/billingmigration/repository.go b/apps/api/internal/billingmigration/repository.go new file mode 100644 index 00000000..0466b64c --- /dev/null +++ b/apps/api/internal/billingmigration/repository.go @@ -0,0 +1,51 @@ +package billingmigration + +import ( + "context" + "errors" + "fmt" +) + +var ( + ErrInvalid = errors.New("invalid billing migration request") + ErrUnauthenticated = errors.New("billing migration authentication required") + ErrForbidden = errors.New("billing migration capability denied") + ErrNotFound = errors.New("billing migration resource not found") + ErrConflict = errors.New("billing migration state conflict") + ErrUnavailable = errors.New("billing migration dependency unavailable") + ErrValidationPending = errors.New("billing migration provider validation is pending") + ErrStaleCheckpoint = fmt.Errorf("stale migration checkpoint: %w", ErrConflict) + ErrStaleAuthority = fmt.Errorf("stale migration authority: %w", ErrConflict) + ErrStaleRollbackPrerequisites = fmt.Errorf("stale rollback prerequisites: %w", ErrConflict) + ErrIdempotencyConflict = fmt.Errorf("billing migration idempotency conflict: %w", ErrConflict) + ErrStaleState = fmt.Errorf("stale billing migration state: %w", ErrConflict) + ErrStaleDigest = fmt.Errorf("stale billing migration digest: %w", ErrConflict) + ErrExpiredApproval = fmt.Errorf("expired billing migration approval: %w", ErrConflict) + ErrAuthorityEpoch = fmt.Errorf("billing migration authority epoch conflict: %w", ErrConflict) + ErrPointerCoverage = fmt.Errorf("billing migration pointer coverage conflict: %w", ErrConflict) + ErrRollbackWindow = fmt.Errorf("billing migration rollback window closed: %w", ErrConflict) + ErrRollbackPrerequisite = fmt.Errorf("billing migration rollback prerequisite failed: %w", ErrConflict) + ErrConcurrentTransition = fmt.Errorf("concurrent billing migration transition: %w", ErrConflict) +) + +type Repository interface { + Authorize(ctx context.Context, actor Actor, projectID, capability string) (Authorization, error) + Idempotency(ctx context.Context, projectID, key string) (StoredIdempotency, error) + CreateProgram(ctx context.Context, command CreateProgramCommand) (ProgramDetail, error) + ListPrograms(ctx context.Context, projectID string, limit int) ([]ProgramDetail, error) + Program(ctx context.Context, projectID, programID string) (ProgramDetail, error) +} + +type OperatorCapabilityRepository interface { + AllowedCapabilities(ctx context.Context, actor Actor, projectID string) ([]string, error) +} + +type CapabilityAssessmentRepository interface { + AppendCapabilityAssessment(ctx context.Context, command CapabilityAssessmentAppend) (CapabilityAssessment, bool, error) +} + +// RevenueCatAssessor performs read-only provider checks before persistence. +// Implementations must not retain or log Credential. +type RevenueCatAssessor interface { + AssessMigration(ctx context.Context, externalProjectID string, credential []byte) (CapabilityResult, error) +} diff --git a/apps/api/internal/billingmigration/rollback_readiness.go b/apps/api/internal/billingmigration/rollback_readiness.go new file mode 100644 index 00000000..828cd24e --- /dev/null +++ b/apps/api/internal/billingmigration/rollback_readiness.go @@ -0,0 +1,76 @@ +package billingmigration + +import ( + "context" + "time" +) + +type AssessRollbackReadinessInput struct { + ProjectID, ProgramID, ObservationID, IdempotencyKey string + ExpectedStateVersion, ExpectedAuthorityEpoch int64 + ExpectedObservationDigest string +} + +type RollbackReadinessAssessment struct { + ID string `json:"assessmentId"` + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + ObservationID string `json:"observationId"` + LatestDeltaID string `json:"latestDeltaId"` + ReadinessDigest string `json:"readinessDigest"` + StateVersion int64 `json:"stateVersion"` + SourceSupportAvailable bool `json:"sourceSupportAvailable"` + SourceHealthy bool `json:"sourceHealthy"` + ApplicationCompatible bool `json:"applicationCompatible"` + LimitationsBlocking bool `json:"limitationsBlocking"` + Ready bool `json:"ready"` + CustomerImpactCount int64 `json:"customerImpactCount"` + AssessedAt time.Time `json:"assessedAt"` +} + +type RollbackReadinessCheckpoint struct { + ID string `json:"checkpointId"` + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + AssessmentID string `json:"assessmentId"` + AuthorityDigest string `json:"authorityDigest"` + PolicyDigest string `json:"policyDigest"` + EvidenceDigest string `json:"evidenceDigest"` + ReadinessDigest string `json:"readinessDigest"` + CheckpointDigest string `json:"checkpointDigest"` + StateVersion int64 `json:"stateVersion"` + AuthorityEpoch int64 `json:"authorityEpoch"` + CreatedAt time.Time `json:"createdAt"` +} + +type RollbackReadinessRepository interface { + AssessRollbackReadiness(context.Context, AssessRollbackReadinessCommand) (RollbackReadinessAssessment, RollbackReadinessCheckpoint, bool, error) +} +type AssessRollbackReadinessCommand struct { + Input AssessRollbackReadinessInput + ActorID string + RequestDigest, ExpectedObservationDigest []byte +} + +type RollbackReadinessService struct { + auth Repository + repo RollbackReadinessRepository +} + +func NewRollbackReadinessService(auth Repository, repo RollbackReadinessRepository) *RollbackReadinessService { + return &RollbackReadinessService{auth: auth, repo: repo} +} +func (s *RollbackReadinessService) Assess(ctx context.Context, actor Actor, input AssessRollbackReadinessInput) (RollbackReadinessAssessment, RollbackReadinessCheckpoint, bool, error) { + if s == nil || s.auth == nil || s.repo == nil || input.ProjectID == "" || input.ProgramID == "" || input.ObservationID == "" || input.IdempotencyKey == "" || input.ExpectedStateVersion < 1 || input.ExpectedAuthorityEpoch < 1 { + return RollbackReadinessAssessment{}, RollbackReadinessCheckpoint{}, false, ErrInvalid + } + observation, err := ParseDigest(input.ExpectedObservationDigest) + if err != nil { + return RollbackReadinessAssessment{}, RollbackReadinessCheckpoint{}, false, ErrInvalid + } + if _, err := s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityExecuteRollback); err != nil { + return RollbackReadinessAssessment{}, RollbackReadinessCheckpoint{}, false, err + } + cmd := AssessRollbackReadinessCommand{Input: input, ActorID: actor.ID, RequestDigest: digest(input), ExpectedObservationDigest: observation} + return s.repo.AssessRollbackReadiness(ctx, cmd) +} diff --git a/apps/api/internal/billingmigration/rollback_readiness_test.go b/apps/api/internal/billingmigration/rollback_readiness_test.go new file mode 100644 index 00000000..c8ca3aee --- /dev/null +++ b/apps/api/internal/billingmigration/rollback_readiness_test.go @@ -0,0 +1,33 @@ +package billingmigration + +import ( + "context" + "testing" +) + +type readinessAuth struct{ Repository } + +func (readinessAuth) Authorize(context.Context, Actor, string, string) (Authorization, error) { + return Authorization{}, nil +} + +type readinessRepo struct { + input AssessRollbackReadinessCommand +} + +func (r *readinessRepo) AssessRollbackReadiness(_ context.Context, c AssessRollbackReadinessCommand) (RollbackReadinessAssessment, RollbackReadinessCheckpoint, bool, error) { + r.input = c + return RollbackReadinessAssessment{}, RollbackReadinessCheckpoint{}, false, nil +} + +func TestRollbackReadinessInputContainsOnlyExpectedEvidenceBindings(t *testing.T) { + repo := &readinessRepo{} + service := NewRollbackReadinessService(readinessAuth{}, repo) + input := AssessRollbackReadinessInput{ProjectID: "project", ProgramID: "program", ObservationID: "observation", IdempotencyKey: "key", ExpectedStateVersion: 7, ExpectedAuthorityEpoch: 2, ExpectedObservationDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111"} + if _, _, _, err := service.Assess(context.Background(), Actor{ID: "owner"}, input); err != nil { + t.Fatal(err) + } + if len(repo.input.ExpectedObservationDigest) != 32 { + t.Fatal("observation digest was not bound") + } +} diff --git a/apps/api/internal/billingmigration/semver.go b/apps/api/internal/billingmigration/semver.go new file mode 100644 index 00000000..a0ff9707 --- /dev/null +++ b/apps/api/internal/billingmigration/semver.go @@ -0,0 +1,157 @@ +package billingmigration + +import ( + "strings" +) + +type semanticVersion struct { + core [3]string + pre []string +} + +func parseSemanticVersion(value string) (semanticVersion, error) { + parsed := semanticVersion{core: [3]string{"0", "0", "0"}} + if value == "" || strings.TrimSpace(value) != value { + return parsed, ErrInvalid + } + withoutBuild := value + if plus := strings.IndexByte(value, '+'); plus >= 0 { + withoutBuild = value[:plus] + if !validIdentifiers(value[plus+1:], false) { + return parsed, ErrInvalid + } + } + coreText := withoutBuild + if dash := strings.IndexByte(withoutBuild, '-'); dash >= 0 { + coreText = withoutBuild[:dash] + pre := withoutBuild[dash+1:] + if !validIdentifiers(pre, true) { + return parsed, ErrInvalid + } + parsed.pre = strings.Split(pre, ".") + } + parts := strings.Split(coreText, ".") + if len(parts) < 1 || len(parts) > 3 { + return parsed, ErrInvalid + } + for index, part := range parts { + if !numericIdentifier(part, true) { + return parsed, ErrInvalid + } + parsed.core[index] = part + } + return parsed, nil +} + +func validIdentifiers(value string, prerelease bool) bool { + if value == "" { + return false + } + for _, identifier := range strings.Split(value, ".") { + if identifier == "" { + return false + } + numeric := true + for _, character := range identifier { + if character < '0' || character > '9' { + numeric = false + } + if !((character >= '0' && character <= '9') || (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') || character == '-') { + return false + } + } + if prerelease && numeric && !numericIdentifier(identifier, true) { + return false + } + } + return true +} + +func numericIdentifier(value string, rejectLeadingZero bool) bool { + if value == "" || (rejectLeadingZero && len(value) > 1 && value[0] == '0') { + return false + } + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + return true +} + +func compareSemanticVersions(left, right semanticVersion) int { + for index := 0; index < 3; index++ { + if comparison := compareNumericIdentifiers(left.core[index], right.core[index]); comparison != 0 { + return comparison + } + } + if len(left.pre) == 0 && len(right.pre) == 0 { + return 0 + } + if len(left.pre) == 0 { + return 1 + } + if len(right.pre) == 0 { + return -1 + } + limit := len(left.pre) + if len(right.pre) < limit { + limit = len(right.pre) + } + for index := 0; index < limit; index++ { + l, r := left.pre[index], right.pre[index] + ln, rn := numericIdentifier(l, false), numericIdentifier(r, false) + if ln && rn { + if comparison := compareNumericIdentifiers(l, r); comparison != 0 { + return comparison + } + } else if ln { + return -1 + } else if rn { + return 1 + } else if l < r { + return -1 + } else if l > r { + return 1 + } + } + if len(left.pre) < len(right.pre) { + return -1 + } + if len(left.pre) > len(right.pre) { + return 1 + } + return 0 +} + +func compareNumericIdentifiers(left, right string) int { + if len(left) < len(right) { + return -1 + } + if len(left) > len(right) { + return 1 + } + if left < right { + return -1 + } + if left > right { + return 1 + } + return 0 +} + +func SemanticVersionInRange(value, minimum, maximum string) (bool, error) { + v, err := parseSemanticVersion(value) + if err != nil { + return false, err + } + min, err := parseSemanticVersion(minimum) + if err != nil { + return false, err + } + max, err := parseSemanticVersion(maximum) + if err != nil { + return false, err + } + return compareSemanticVersions(v, min) >= 0 && compareSemanticVersions(v, max) <= 0, nil +} diff --git a/apps/api/internal/billingmigration/semver_test.go b/apps/api/internal/billingmigration/semver_test.go new file mode 100644 index 00000000..44cfbb28 --- /dev/null +++ b/apps/api/internal/billingmigration/semver_test.go @@ -0,0 +1,28 @@ +package billingmigration + +import "testing" + +func TestSemanticVersionV1RangeUsesPrecedenceNotLexicalOrder(t *testing.T) { + tests := []struct { + value, min, max string + want bool + }{ + {"2.10.0", "2.9.9", "2.10.1", true}, + {"2.9.9", "2.10.0", "3.0.0", false}, + {"2.10.0-rc.1", "2.10.0-rc.0", "2.10.0", true}, + {"2.10.0+build.9", "2.10.0+build.1", "2.10.0+build.2", true}, + {"2.10.0-rc.1+build.9", "2.10.0", "3.0.0", false}, + {"184467440737095516160.1.0", "184467440737095516159.9.9", "184467440737095516161", true}, + } + for _, test := range tests { + got, err := SemanticVersionInRange(test.value, test.min, test.max) + if err != nil || got != test.want { + t.Fatalf("%s in [%s,%s]=%v err=%v", test.value, test.min, test.max, got, err) + } + } + for _, invalid := range []string{"02.1.0", "1.0.0-01", "1.0.0+", "1.2.3.4"} { + if _, err := parseSemanticVersion(invalid); err == nil { + t.Fatalf("accepted invalid semantic version %q", invalid) + } + } +} diff --git a/apps/api/internal/billingmigration/service.go b/apps/api/internal/billingmigration/service.go new file mode 100644 index 00000000..e3f176b9 --- /dev/null +++ b/apps/api/internal/billingmigration/service.go @@ -0,0 +1,626 @@ +package billingmigration + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "io" + "sort" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +const migrationCredentialClass = "revenuecat_migration_api_key" + +type Service struct { + repository Repository + evidence EvidenceRepository + cutover CutoverRepository + cipher providercredential.SubjectCipher + assessor RevenueCatAssessor + now func() time.Time + random io.Reader + tracer trace.Tracer +} + +type Option func(*Service) + +func WithClock(now func() time.Time) Option { + return func(service *Service) { + if now != nil { + service.now = now + } + } +} + +func WithRandom(random io.Reader) Option { + return func(service *Service) { + if random != nil { + service.random = random + } + } +} + +func NewService(repository Repository, cipher providercredential.SubjectCipher, assessor RevenueCatAssessor, options ...Option) *Service { + service := &Service{ + repository: repository, + cipher: cipher, + assessor: assessor, + now: func() time.Time { return time.Now().UTC() }, + random: rand.Reader, + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/billingmigration"), + } + service.evidence, _ = repository.(EvidenceRepository) + service.cutover, _ = repository.(CutoverRepository) + for _, option := range options { + option(service) + } + return service +} + +func (s *Service) PromoteReady(ctx context.Context, actor Actor, input PromoteReadyInput) (AuthoritativeReadiness, error) { + if s.cutover == nil || input.ProjectID == "" || input.ProgramID == "" || input.ExpectedStateVersion < 1 { + return AuthoritativeReadiness{}, ErrInvalid + } + if _, err := s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityAssessReadiness); err != nil { + return AuthoritativeReadiness{}, err + } + return s.cutover.PromoteReady(ctx, input.ProjectID, input.ProgramID, input.ExpectedStateVersion, actor.ID, s.now()) +} + +func (s *Service) ProposeCutover(ctx context.Context, actor Actor, input ProposeCutoverInput) (CutoverProposal, bool, error) { + if s.cutover == nil || input.ProjectID == "" || input.ProgramID == "" || input.Command != "cutover" || input.ExpectedStateVersion < 1 || input.IdempotencyKey == "" || len(input.IdempotencyKey) > 128 || !validText(input.Reason, 500) { + return CutoverProposal{}, false, ErrInvalid + } + if _, err := s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityProposeCutover); err != nil { + return CutoverProposal{}, false, err + } + digests, err := input.ExpectedDigests.Parse() + if err != nil { + return CutoverProposal{}, false, err + } + now := s.now() + if !input.ExpiresAt.After(now) || input.ExpiresAt.After(now.Add(24*time.Hour)) { + return CutoverProposal{}, false, ErrInvalid + } + id, err := s.newID("mcp") + if err != nil { + return CutoverProposal{}, false, ErrUnavailable + } + proposal := CutoverProposal{ProgramID: input.ProgramID, StateVersion: input.ExpectedStateVersion, ProposalID: id, Command: input.Command, ProposerActorID: actor.ID, Reason: input.Reason, Digests: input.ExpectedDigests, Status: "pending", ProposedAt: now, ExpiresAt: input.ExpiresAt.UTC()} + proposalRaw := digest(struct { + ProgramID, Command, Proposer, Reason string + StateVersion int64 + Digests PreApprovalDigests + ExpiresAt time.Time + }{input.ProgramID, input.Command, actor.ID, input.Reason, input.ExpectedStateVersion, input.ExpectedDigests, proposal.ExpiresAt}) + proposal.ProposalDigest = FormatDigest(proposalRaw) + return s.cutover.CreateProposal(ctx, ProposalWrite{Proposal: proposal, ProjectID: input.ProjectID, IdempotencyKey: input.IdempotencyKey, RequestDigest: digest(input), Digests: digests, ProposalDigest: proposalRaw}) +} + +func (s *Service) ProposeRollback(ctx context.Context, actor Actor, input ProposeRollbackInput) (CutoverProposal, bool, error) { + if s.cutover == nil || input.ProjectID == "" || input.ProgramID == "" || input.CheckpointID == "" || input.ExpectedStateVersion < 1 || input.IdempotencyKey == "" || len(input.IdempotencyKey) > 128 || !validText(input.Reason, 500) { + return CutoverProposal{}, false, ErrInvalid + } + for _, value := range []string{input.ExpectedCheckpointDigest, input.ExpectedAuthorityDigest, input.ExpectedRollbackPrerequisitesDigest} { + if _, err := ParseDigest(value); err != nil { + return CutoverProposal{}, false, ErrInvalid + } + } + if _, err := s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityProposeCutover); err != nil { + return CutoverProposal{}, false, err + } + now := s.now() + if !input.ExpiresAt.After(now) || input.ExpiresAt.After(now.Add(24*time.Hour)) { + return CutoverProposal{}, false, ErrInvalid + } + id, err := s.newID("mcp") + if err != nil { + return CutoverProposal{}, false, ErrUnavailable + } + proposal := CutoverProposal{ProgramID: input.ProgramID, StateVersion: input.ExpectedStateVersion, ProposalID: id, Command: "rollback", ProposerActorID: actor.ID, Reason: input.Reason, Status: "pending", ProposedAt: now, ExpiresAt: input.ExpiresAt.UTC()} + raw := digest(input) + proposal.ProposalDigest = FormatDigest(raw) + expected := &RollbackExpectedBinding{CheckpointID: input.CheckpointID, CheckpointDigest: input.ExpectedCheckpointDigest, AuthorityDigest: input.ExpectedAuthorityDigest, RollbackPrerequisitesDigest: input.ExpectedRollbackPrerequisitesDigest} + return s.cutover.CreateProposal(ctx, ProposalWrite{Proposal: proposal, ProjectID: input.ProjectID, IdempotencyKey: input.IdempotencyKey, RequestDigest: digest(input), ProposalDigest: raw, ExpectedRollback: expected}) +} + +func (s *Service) ApproveCutover(ctx context.Context, actor Actor, input ApproveCutoverInput) (MigrationApproval, bool, error) { + if s.cutover == nil || input.ProjectID == "" || input.ProgramID == "" || input.ProposalID == "" || input.IdempotencyKey == "" || input.ExpectedStateVersion < 1 { + return MigrationApproval{}, false, ErrInvalid + } + if _, err := s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityApproveCutover); err != nil { + return MigrationApproval{}, false, err + } + proposal, err := s.cutover.Proposal(ctx, input.ProjectID, input.ProgramID, input.ProposalID) + if err != nil { + return MigrationApproval{}, false, err + } + if proposal.StateVersion != input.ExpectedStateVersion { + return MigrationApproval{}, false, ErrConflict + } + now := s.now() + id, err := s.newID("map") + if err != nil { + return MigrationApproval{}, false, ErrUnavailable + } + approval := MigrationApproval{ProgramID: input.ProgramID, StateVersion: input.ExpectedStateVersion, ApprovalID: id, Command: proposal.Command, ProposerActorID: proposal.ProposerActorID, ApproverActorID: actor.ID, ApprovedAt: now, ExpiresAt: proposal.ExpiresAt} + raw := digest(struct { + Approval MigrationApproval + ProposalID string + Digests PreApprovalDigests + RollbackBinding *RollbackProposalBinding + }{approval, proposal.ProposalID, proposal.Digests, proposal.RollbackBinding}) + approval.ApprovalDigest = FormatDigest(raw) + return s.cutover.ApproveProposal(ctx, ApprovalWrite{Approval: approval, ProjectID: input.ProjectID, ProposalID: input.ProposalID, IdempotencyKey: input.IdempotencyKey, RequestDigest: digest(input), ApprovalDigest: raw}) +} + +func (s *Service) CreateCheckpoint(ctx context.Context, actor Actor, input CreateCheckpointInput) (MigrationCheckpoint, bool, error) { + if s.cutover == nil || input.ProjectID == "" || input.ProgramID == "" || input.ApprovalID == "" || input.IdempotencyKey == "" || input.ExpectedStateVersion < 1 { + return MigrationCheckpoint{}, false, ErrInvalid + } + if _, err := s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityApproveCutover); err != nil { + return MigrationCheckpoint{}, false, err + } + digests, err := input.ExpectedDigests.Parse() + if err != nil { + return MigrationCheckpoint{}, false, err + } + approvalRaw, err := ParseDigest(input.ApprovalDigest) + if err != nil { + return MigrationCheckpoint{}, false, ErrInvalid + } + cohortRaw, err := ParseDigest(input.CohortDigest) + if err != nil { + return MigrationCheckpoint{}, false, ErrInvalid + } + id, err := s.newID("mck") + if err != nil { + return MigrationCheckpoint{}, false, ErrUnavailable + } + now := s.now() + checkpoint := MigrationCheckpoint{ProgramID: input.ProgramID, StateVersion: input.ExpectedStateVersion, CheckpointID: id, ManifestDigest: input.ExpectedDigests.Manifest, MappingDigest: input.ExpectedDigests.Mapping, PolicyDigest: input.ExpectedDigests.Policy, ReadinessDigest: input.ExpectedDigests.Readiness, CohortDigest: input.CohortDigest, CreatedAt: now} + raw := digest(struct { + Input CreateCheckpointInput + ID string + At time.Time + }{input, id, now}) + checkpoint.CheckpointDigest = FormatDigest(raw) + return s.cutover.CreateCheckpoint(ctx, CheckpointWrite{Checkpoint: checkpoint, ProjectID: input.ProjectID, ApprovalID: input.ApprovalID, IdempotencyKey: input.IdempotencyKey, RequestDigest: digest(input), Digests: digests, ApprovalDigest: approvalRaw, CheckpointDigest: raw, CohortDigest: cohortRaw}) +} + +func validText(value string, max int) bool { + if strings.TrimSpace(value) == "" || len(value) > max { + return false + } + for _, character := range value { + if character <= 0x1f || character == 0x7f { + return false + } + } + return true +} + +func (s *Service) ListManifests(ctx context.Context, actor Actor, projectID, programID string, limit int) ([]SourceManifest, error) { + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityView); err != nil { + return nil, err + } + limit, err := boundedLimit(limit) + if err != nil { + return nil, err + } + return s.evidence.ListManifests(ctx, projectID, programID, limit) +} + +func (s *Service) CreateMappingSet(ctx context.Context, actor Actor, input CreateMappingSetInput) (MappingSet, error) { + if s.evidence == nil || input.ProjectID == "" || input.ProgramID == "" || input.ExpectedStateVersion < 1 || input.Version < 1 || len(input.Entries) == 0 || len(input.Entries) > 10000 { + return MappingSet{}, ErrInvalid + } + if _, err := s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityManageMappings); err != nil { + return MappingSet{}, err + } + entries := append([]MappingEntry(nil), input.Entries...) + for _, e := range entries { + if !validSourceIdentifier(e.SourceIdentifier) || e.TargetID == "" || + (e.SourceKind != "customer_id" && e.SourceKind != "original_customer_id" && e.SourceKind != "audited_alias" && e.SourceKind != "product" && e.SourceKind != "entitlement") || + (e.MatchKind != "exact" && e.MatchKind != "audited_alias") { + return MappingSet{}, ErrInvalid + } + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].SourceKind == entries[j].SourceKind { + return entries[i].SourceIdentifier < entries[j].SourceIdentifier + } + return entries[i].SourceKind < entries[j].SourceKind + }) + id, err := s.newID("mms") + if err != nil { + return MappingSet{}, ErrUnavailable + } + raw := digest(entries) + mapping := MappingSet{ProgramID: input.ProgramID, StateVersion: input.ExpectedStateVersion, MappingSetID: id, Version: input.Version, Status: "draft", Entries: entries, MappingDigest: FormatDigest(raw)} + err = s.evidence.CreateMappingSet(ctx, input.ExpectedStateVersion, MappingSetWrite{MappingSet: mapping, ProjectID: input.ProjectID, MappingDigest: raw, ActorID: actor.ID, CreatedAt: s.now()}) + return mapping, err +} + +func (s *Service) FreezeMappingSet(ctx context.Context, actor Actor, projectID, programID, mappingSetID string, expected int64) error { + if expected < 1 { + return ErrInvalid + } + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityManageMappings); err != nil { + return err + } + return s.evidence.FreezeMappingSet(ctx, projectID, programID, mappingSetID, expected, s.now()) +} +func (s *Service) ListMappingSets(ctx context.Context, actor Actor, projectID, programID string, limit int) ([]MappingSet, error) { + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityView); err != nil { + return nil, err + } + limit, err := boundedLimit(limit) + if err != nil { + return nil, err + } + return s.evidence.ListMappingSets(ctx, projectID, programID, limit) +} + +func (s *Service) CreateImportBatch(ctx context.Context, actor Actor, input CreateImportBatchInput) (ImportBatch, bool, error) { + if input.ExpectedStateVersion < 1 || input.RecordCount < 0 || input.RecordCount > 1000 || input.ManifestID == "" || input.MappingSetID == "" || input.IdempotencyKey == "" || len(input.IdempotencyKey) > 128 { + return ImportBatch{}, false, ErrInvalid + } + if _, err := s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityRunImport); err != nil { + return ImportBatch{}, false, err + } + id, err := s.newID("mib") + if err != nil { + return ImportBatch{}, false, ErrUnavailable + } + requestDigest := digest(input) + batch := ImportBatch{ProgramID: input.ProgramID, StateVersion: input.ExpectedStateVersion, BatchID: id, IdempotencyKey: input.IdempotencyKey, Status: "pending", RecordCount: input.RecordCount} + replay, err := s.evidence.CreateImportBatch(ctx, input.ExpectedStateVersion, ImportBatchWrite{Batch: batch, ProjectID: input.ProjectID, ManifestID: input.ManifestID, MappingSetID: input.MappingSetID, RequestDigest: requestDigest, CursorBefore: input.CursorBefore, CreatedAt: s.now()}) + if replay && err == nil { + item, readErr := s.evidence.ImportBatchByIdempotency(ctx, input.ProjectID, input.ProgramID, input.IdempotencyKey) + return item, true, readErr + } + return batch, replay, err +} +func (s *Service) ListImportBatches(ctx context.Context, actor Actor, projectID, programID string, limit int) ([]ImportBatch, error) { + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityView); err != nil { + return nil, err + } + limit, err := boundedLimit(limit) + if err != nil { + return nil, err + } + return s.evidence.ListImportBatches(ctx, projectID, programID, limit) +} +func (s *Service) ImportBatch(ctx context.Context, actor Actor, projectID, programID, batchID string) (ImportBatch, error) { + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityView); err != nil { + return ImportBatch{}, err + } + return s.evidence.ImportBatch(ctx, projectID, programID, batchID) +} + +func (s *Service) QueueRun(ctx context.Context, actor Actor, input QueueRunInput) (RunJob, bool, error) { + if input.ExpectedStateVersion < 1 || (input.RunKind != "dry_run" && input.RunKind != "shadow") || input.IdempotencyKey == "" || len(input.IdempotencyKey) > 128 { + return RunJob{}, false, ErrInvalid + } + manifestDigest, err := ParseDigest(input.ManifestDigest) + if err != nil { + return RunJob{}, false, ErrInvalid + } + mappingDigest, err := ParseDigest(input.MappingDigest) + if err != nil { + return RunJob{}, false, ErrInvalid + } + if _, err = s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityRunImport); err != nil { + return RunJob{}, false, err + } + program, err := s.repository.Program(ctx, input.ProjectID, input.ProgramID) + if err != nil { + return RunJob{}, false, err + } + policyDigest, err := ParseDigest(program.Program.PolicyDigest) + if err != nil { + return RunJob{}, false, ErrConflict + } + id, err := s.newID("mrj") + if err != nil { + return RunJob{}, false, ErrUnavailable + } + job := RunJob{ProgramID: input.ProgramID, StateVersion: input.ExpectedStateVersion, RunJobID: id, RunKind: input.RunKind, Status: "pending"} + requestDigest := digest(input) + replay, err := s.evidence.QueueRun(ctx, input.ExpectedStateVersion, RunJobWrite{Job: job, ProjectID: input.ProjectID, IdempotencyKey: input.IdempotencyKey, RequestDigest: requestDigest, ManifestDigest: manifestDigest, MappingDigest: mappingDigest, PolicyDigest: policyDigest, CreatedAt: s.now()}) + if replay && err == nil { + item, readErr := s.evidence.RunJobByIdempotency(ctx, input.ProjectID, input.ProgramID, input.IdempotencyKey) + return item, true, readErr + } + return job, replay, err +} +func (s *Service) RunJob(ctx context.Context, actor Actor, projectID, programID, jobID string) (RunJob, error) { + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityView); err != nil { + return RunJob{}, err + } + return s.evidence.RunJob(ctx, projectID, programID, jobID) +} +func (s *Service) ListDivergences(ctx context.Context, actor Actor, projectID, programID string, limit int) ([]Divergence, error) { + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityView); err != nil { + return nil, err + } + limit, err := boundedLimit(limit) + if err != nil { + return nil, err + } + return s.evidence.ListDivergences(ctx, projectID, programID, limit) +} +func (s *Service) AssessCurrentReadiness(ctx context.Context, actor Actor, projectID, programID string, expected int64) (ReadinessAssessment, error) { + if expected < 1 { + return ReadinessAssessment{}, ErrInvalid + } + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityAssessReadiness); err != nil { + return ReadinessAssessment{}, err + } + input, err := s.evidence.ReadinessInput(ctx, projectID, programID) + if err != nil { + return ReadinessAssessment{}, err + } + assessment, err := AssessReadiness(programID, expected, input) + if err != nil { + return assessment, err + } + raw, err := ParseDigest(assessment.ReadinessDigest) + if err != nil { + return assessment, ErrUnavailable + } + err = s.evidence.RecordReadiness(ctx, expected, ReadinessWrite{Assessment: assessment, ProjectID: projectID, ReadinessDigest: raw, AssessedAt: s.now()}) + if errors.Is(err, ErrConflict) { + latest, readErr := s.evidence.LatestReadiness(ctx, projectID, programID) + if readErr == nil && latest.ReadinessDigest == assessment.ReadinessDigest { + return latest, nil + } + } + return assessment, err +} +func (s *Service) LatestReadiness(ctx context.Context, actor Actor, projectID, programID string) (ReadinessAssessment, error) { + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityView); err != nil { + return ReadinessAssessment{}, err + } + return s.evidence.LatestReadiness(ctx, projectID, programID) +} + +func boundedLimit(limit int) (int, error) { + if limit <= 0 { + return 50, nil + } + if limit > 100 { + return 0, ErrInvalid + } + return limit, nil +} + +func (s *Service) CreateProgram(ctx context.Context, actor Actor, input CreateProgramInput) (ProgramDetail, bool, error) { + ctx, span := s.tracer.Start(ctx, "billing.migration.program.create") + defer span.End() + if err := normalizeCreateInput(&input); err != nil { + return ProgramDetail{}, false, err + } + authorization, err := s.repository.Authorize(ctx, actor, input.ProjectID, CapabilityManageSource) + if err != nil { + return ProgramDetail{}, false, err + } + requestDigest := digest(struct { + ProjectID, EnvironmentID, ExternalProjectID string + Applications []ScopeItem + CredentialDigest [32]byte + StabilizationDays, RollbackWindowDays int + }{input.ProjectID, input.EnvironmentID, input.ExternalProjectID, input.Applications, + sha256.Sum256(input.Credential), input.StabilizationDays, input.RollbackWindowDays}) + existing, err := s.repository.Idempotency(ctx, input.ProjectID, input.IdempotencyKey) + if err == nil { + if !bytes.Equal(existing.RequestDigest, requestDigest) { + return ProgramDetail{}, false, ErrConflict + } + program, readErr := s.repository.Program(ctx, input.ProjectID, existing.ProgramID) + if readErr == nil { + readErr = s.attachOperatorCapabilities(ctx, actor, input.ProjectID, &program) + } + return program, true, readErr + } + if err != nil && err != ErrNotFound { + return ProgramDetail{}, false, err + } + if s.cipher == nil || s.assessor == nil { + return ProgramDetail{}, false, ErrUnavailable + } + // Provider I/O happens before CreateProgram opens its transaction. + assessment, err := s.assessor.AssessMigration(ctx, input.ExternalProjectID, input.Credential) + if errors.Is(err, ErrInvalid) { + return ProgramDetail{}, false, ErrInvalid + } + if err != nil || len(assessment.Capabilities) == 0 { + return ProgramDetail{}, false, ErrUnavailable + } + sort.Strings(assessment.Capabilities) + programID, err := s.newID("mig") + if err != nil { + return ProgramDetail{}, false, ErrUnavailable + } + credentialID, err := s.newID("mgc") + if err != nil { + return ProgramDetail{}, false, ErrUnavailable + } + assessmentID, err := s.newID("mga") + if err != nil { + return ProgramDetail{}, false, ErrUnavailable + } + now := s.now() + envelope, err := s.cipher.EncryptSubject(input.Credential, providercredential.SubjectScope{ + OrganizationID: authorization.OrganizationID, + ProjectID: input.ProjectID, + SubjectKind: providercredential.SubjectBillingMigrationCredential, + SubjectID: credentialID, + CredentialClass: migrationCredentialClass, + }) + if err != nil { + return ProgramDetail{}, false, ErrUnavailable + } + scope := Scope{ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, Applications: input.Applications} + scopeDigest := digest(scope) + policyDigest := digest(struct{ StabilizationDays, RollbackWindowDays int }{input.StabilizationDays, input.RollbackWindowDays}) + capability := CapabilityAssessment{ + ProgramID: programID, StateVersion: 1, Adapter: AdapterRevenueCat, + ProviderAPIVersion: assessment.ProviderAPIVersion, + Capabilities: assessment.Capabilities, AssessedAt: assessment.AssessedAt.UTC(), + } + if capability.AssessedAt.IsZero() { + capability.AssessedAt = now + } + program := Program{ + ProgramID: programID, StateVersion: 1, State: StateMapping, Scope: scope, + Source: Source{Adapter: AdapterRevenueCat, AdapterVersion: AdapterVersion, CredentialReference: credentialID}, + AuthorityEpochBefore: 0, StabilizationDays: input.StabilizationDays, + RollbackWindowDays: input.RollbackWindowDays, + ScopeDigest: FormatDigest(scopeDigest), PolicyDigest: FormatDigest(policyDigest), + CreatedAt: now, UpdatedAt: now, + } + created, err := s.repository.CreateProgram(ctx, CreateProgramCommand{ + OrganizationID: authorization.OrganizationID, + Program: program, + Credential: SealedCredential{ID: credentialID, ProjectID: input.ProjectID, + ExternalProjectID: input.ExternalProjectID, EnvelopeVersion: envelope.Version, + Algorithm: envelope.Algorithm, KeyID: envelope.KeyID, Nonce: envelope.Nonce, + Ciphertext: envelope.Ciphertext, Fingerprint: envelope.Fingerprint, + CreatedByActorID: actor.ID, CreatedAt: now}, + AssessmentID: assessmentID, Assessment: capability, AssessmentDigest: digest(capability), + RequestDigest: requestDigest, ScopeDigest: scopeDigest, PolicyDigest: policyDigest, + ActorID: actor.ID, IdempotencyKey: input.IdempotencyKey, Now: now, + }) + if errors.Is(err, ErrConflict) { + stored, replayErr := s.repository.Idempotency(ctx, input.ProjectID, input.IdempotencyKey) + if replayErr == nil && bytes.Equal(stored.RequestDigest, requestDigest) { + program, readErr := s.repository.Program(ctx, input.ProjectID, stored.ProgramID) + if readErr == nil { + readErr = s.attachOperatorCapabilities(ctx, actor, input.ProjectID, &program) + } + return program, true, readErr + } + } + if err != nil { + return ProgramDetail{}, false, err + } + span.SetAttributes(attribute.String("mosaic.billing.migration.program.id", programID)) + if err := s.attachOperatorCapabilities(ctx, actor, input.ProjectID, &created); err != nil { + return ProgramDetail{}, false, err + } + return created, false, nil +} + +func (s *Service) ListPrograms(ctx context.Context, actor Actor, projectID string, limit int) ([]ProgramDetail, error) { + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityView); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + if limit > 100 { + return nil, ErrInvalid + } + items, err := s.repository.ListPrograms(ctx, projectID, limit) + if err != nil { + return nil, err + } + capabilities, err := s.allowedOperatorCapabilities(ctx, actor, projectID) + if err != nil { + return nil, err + } + for index := range items { + items[index].OperatorCapabilities = append([]string(nil), capabilities...) + } + return items, nil +} + +func (s *Service) Program(ctx context.Context, actor Actor, projectID, programID string) (ProgramDetail, error) { + if _, err := s.repository.Authorize(ctx, actor, projectID, CapabilityView); err != nil { + return ProgramDetail{}, err + } + detail, err := s.repository.Program(ctx, projectID, programID) + if err == nil { + err = s.attachOperatorCapabilities(ctx, actor, projectID, &detail) + } + return detail, err +} + +func (s *Service) allowedOperatorCapabilities(ctx context.Context, actor Actor, projectID string) ([]string, error) { + repository, ok := s.repository.(OperatorCapabilityRepository) + if !ok { + return nil, ErrUnavailable + } + return repository.AllowedCapabilities(ctx, actor, projectID) +} +func (s *Service) attachOperatorCapabilities(ctx context.Context, actor Actor, projectID string, detail *ProgramDetail) error { + capabilities, err := s.allowedOperatorCapabilities(ctx, actor, projectID) + if err != nil { + return err + } + detail.OperatorCapabilities = append([]string(nil), capabilities...) + return nil +} + +func normalizeCreateInput(input *CreateProgramInput) error { + if strings.TrimSpace(input.ProjectID) == "" || strings.TrimSpace(input.EnvironmentID) == "" || + input.ExternalProjectID == "" || len(input.ExternalProjectID) > 256 || len(input.Credential) == 0 || + len(input.Credential) > 4096 || strings.TrimSpace(input.IdempotencyKey) == "" || + len(input.IdempotencyKey) > 128 || len(input.Applications) == 0 || len(input.Applications) > 100 { + return ErrInvalid + } + if input.StabilizationDays == 0 { + input.StabilizationDays = 7 + } + if input.RollbackWindowDays == 0 { + input.RollbackWindowDays = 7 + } + if input.StabilizationDays < 1 || input.StabilizationDays > 30 || input.RollbackWindowDays < 1 || input.RollbackWindowDays > 30 { + return ErrInvalid + } + seen := make(map[string]struct{}, len(input.Applications)) + for _, item := range input.Applications { + if strings.TrimSpace(item.ApplicationID) == "" || item.Platform != "ios" && item.Platform != "android" { + return ErrInvalid + } + key := item.ApplicationID + "\x00" + item.Platform + if _, duplicate := seen[key]; duplicate { + return ErrInvalid + } + seen[key] = struct{}{} + } + sort.Slice(input.Applications, func(i, j int) bool { + if input.Applications[i].ApplicationID == input.Applications[j].ApplicationID { + return input.Applications[i].Platform < input.Applications[j].Platform + } + return input.Applications[i].ApplicationID < input.Applications[j].ApplicationID + }) + return nil +} + +func digest(value any) []byte { + encoded, _ := json.Marshal(value) + sum := sha256.Sum256(encoded) + return sum[:] +} + +func (s *Service) newID(prefix string) (string, error) { + buffer := make([]byte, 16) + if _, err := io.ReadFull(s.random, buffer); err != nil { + return "", err + } + return prefix + "_" + base64.RawURLEncoding.EncodeToString(buffer), nil +} diff --git a/apps/api/internal/billingmigration/service_test.go b/apps/api/internal/billingmigration/service_test.go new file mode 100644 index 00000000..c4217a49 --- /dev/null +++ b/apps/api/internal/billingmigration/service_test.go @@ -0,0 +1,352 @@ +package billingmigration + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + "time" + + "github.com/dlclark/regexp2" + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +type testRepository struct { + command CreateProgramCommand + detail ProgramDetail + hasIdempotency bool +} + +func (repository *testRepository) Authorize(context.Context, Actor, string, string) (Authorization, error) { + return Authorization{OrganizationID: "org_one", Role: "owner"}, nil +} +func (repository *testRepository) AllowedCapabilities(context.Context, Actor, string) ([]string, error) { + return []string{CapabilityView, CapabilityManageSource}, nil +} +func (repository *testRepository) Idempotency(context.Context, string, string) (StoredIdempotency, error) { + if !repository.hasIdempotency { + return StoredIdempotency{}, ErrNotFound + } + return StoredIdempotency{ProgramID: repository.detail.Program.ProgramID, RequestDigest: repository.command.RequestDigest}, nil +} +func (repository *testRepository) CreateProgram(_ context.Context, command CreateProgramCommand) (ProgramDetail, error) { + repository.command = command + repository.detail = ProgramDetail{Program: command.Program, SourceCapabilityAssessment: &command.Assessment} + repository.hasIdempotency = true + return repository.detail, nil +} +func (repository *testRepository) ListPrograms(context.Context, string, int) ([]ProgramDetail, error) { + return nil, nil +} +func (repository *testRepository) Program(context.Context, string, string) (ProgramDetail, error) { + return repository.detail, nil +} + +type testCipher struct { + scope providercredential.SubjectScope + calls int +} + +func (cipher *testCipher) EncryptSubject(_ []byte, scope providercredential.SubjectScope) (providercredential.Envelope, error) { + cipher.calls++ + cipher.scope = scope + return providercredential.Envelope{Version: 1, Algorithm: "AES-256-GCM", KeyID: "key_one", + Nonce: make([]byte, 12), Ciphertext: make([]byte, 16), Fingerprint: make([]byte, 32), + CredentialClass: scope.CredentialClass}, nil +} +func (*testCipher) DecryptSubject(providercredential.Envelope, providercredential.SubjectScope) ([]byte, error) { + return nil, errors.New("unused") +} +func (*testCipher) ActiveKeyID() string { return "key_one" } + +type testAssessor struct{ calls int } + +func (assessor *testAssessor) AssessMigration(_ context.Context, projectID string, credential []byte) (CapabilityResult, error) { + assessor.calls++ + if projectID != "rc_project" || string(credential) != "secret-value" { + return CapabilityResult{}, ErrInvalid + } + return CapabilityResult{ProviderAPIVersion: "v2", Capabilities: []string{"read_customers"}, + AssessedAt: time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)}, nil +} + +func TestCreateProgramSealsSeparateCredentialAndReplaysIdempotently(t *testing.T) { + repository := &testRepository{} + cipher := &testCipher{} + assessor := &testAssessor{} + service := NewService(repository, cipher, assessor, + WithRandom(zeroReader{}), WithClock(func() time.Time { return time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) })) + input := CreateProgramInput{ + ProjectID: "project_one", EnvironmentID: "environment_one", ExternalProjectID: "rc_project", + Credential: []byte("secret-value"), IdempotencyKey: "create-1", + Applications: []ScopeItem{{ApplicationID: "app_b", Platform: "android"}, {ApplicationID: "app_a", Platform: "ios"}}, + } + created, replayed, err := service.CreateProgram(context.Background(), Actor{ID: "actor_one"}, input) + if err != nil { + t.Fatal(err) + } + if replayed { + t.Fatal("first command was reported as an idempotent replay") + } + if cipher.scope.SubjectKind != providercredential.SubjectBillingMigrationCredential { + t.Fatalf("credential subject kind = %q", cipher.scope.SubjectKind) + } + if cipher.scope.SubjectID != created.Program.Source.CredentialReference { + t.Fatal("credential envelope was not bound to the returned credential reference") + } + if got := created.Program.Scope.Applications[0].ApplicationID; got != "app_a" { + t.Fatalf("scope was not canonicalized: first application = %q", got) + } + encoded, _ := json.Marshal(created) + if string(encoded) == "" || contains(string(encoded), "secret-value") { + t.Fatal("public program representation exposed the migration credential") + } + + replayedProgram, replayed, err := service.CreateProgram(context.Background(), Actor{ID: "actor_one"}, input) + if err != nil { + t.Fatal(err) + } + if !replayed || replayedProgram.Program.ProgramID != created.Program.ProgramID { + t.Fatal("same idempotency key and request did not return the original program") + } + if assessor.calls != 1 || cipher.calls != 1 { + t.Fatalf("idempotent replay repeated side effects: assessments=%d seals=%d", assessor.calls, cipher.calls) + } +} + +func TestAssessReadinessBlocksAnyCurrentAccessOrCriticalGap(t *testing.T) { + ready, err := AssessReadiness("migration_one", 7, ReadinessInput{ + CurrentAccessMappingPercent: 100, CurrentAccessEvidencePercent: 100, + FinalDeltaCompleted: true, WatermarksFresh: true, SupportedVersionsAuthorityAware: true, + }) + if err != nil || !ready.Ready || ready.ReadinessDigest == "" { + t.Fatalf("ready assessment = %#v err=%v", ready, err) + } + blocked, err := AssessReadiness("migration_one", 7, ReadinessInput{ + CurrentAccessMappingPercent: 100, CurrentAccessEvidencePercent: 100, + Unresolved: Counts{Critical: 1}, FinalDeltaCompleted: true, + WatermarksFresh: true, SupportedVersionsAuthorityAware: true, + }) + if err != nil { + t.Fatal(err) + } + if blocked.Ready { + t.Fatal("critical divergence did not block readiness") + } + if blocked.ReadinessDigest == ready.ReadinessDigest { + t.Fatal("readiness digest did not bind blocker state") + } +} + +func TestAuthoritativeReadinessBindsSourceWarningAndApplicationVersionGates(t *testing.T) { + base, err := AssessReadiness("migration_one", 7, ReadinessInput{ + CurrentAccessMappingPercent: 100, + CurrentAccessEvidencePercent: 100, + FinalDeltaCompleted: true, + WatermarksFresh: true, + SupportedVersionsAuthorityAware: true, + }) + if err != nil { + t.Fatal(err) + } + applicationDigest := make([]byte, 32) + for i := range applicationDigest { + applicationDigest[i] = 0x71 + } + blocked, err := AssessAuthoritativeReadiness("migration_one", 7, base, false, 0, FormatDigest(applicationDigest)) + if err != nil || blocked.Assessment.Ready { + t.Fatalf("missing source capability gate = %#v err=%v", blocked, err) + } + ready, err := AssessAuthoritativeReadiness("migration_one", 7, base, true, 0, FormatDigest(applicationDigest)) + if err != nil || !ready.Assessment.Ready { + t.Fatalf("authoritative readiness = %#v err=%v", ready, err) + } + if blocked.Assessment.ReadinessDigest == ready.Assessment.ReadinessDigest { + t.Fatal("authoritative readiness digest did not bind the source capability gate") + } +} + +func TestCompletionTimingWaitsForRollbackAndUsesExactRetentionDeadline(t *testing.T) { + completed := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + stabilization := completed.Add(-24 * time.Hour) + rollback := completed + deleteAt := completed.Add(30 * 24 * time.Hour) + if err := ValidateCompletionTiming(completed, stabilization, rollback, rollback, false, &deleteAt); err != nil { + t.Fatal(err) + } + tooEarly := rollback.Add(-time.Second) + if err := ValidateCompletionTiming(completed, stabilization, rollback, tooEarly, false, &deleteAt); err != ErrInvalid { + t.Fatalf("early removal error=%v", err) + } + if err := ValidateCompletionTiming(completed, completed.Add(time.Second), rollback, rollback, false, &deleteAt); err != ErrInvalid { + t.Fatalf("completion before stabilization error=%v", err) + } + if err := ValidateCompletionTiming(completed, stabilization, completed.Add(time.Second), completed.Add(time.Second), false, &deleteAt); err != ErrInvalid { + t.Fatalf("completion before rollback end error=%v", err) + } + wrongDelete := deleteAt.Add(time.Second) + if err := ValidateCompletionTiming(completed, stabilization, rollback, rollback, false, &wrongDelete); err != ErrInvalid { + t.Fatalf("wrong deletion deadline error=%v", err) + } +} + +func TestDigestCompatibilityUsesFrozenSHA256Encoding(t *testing.T) { + value := FormatDigest(make([]byte, 32)) + if value != "sha256:0000000000000000000000000000000000000000000000000000000000000000" { + t.Fatalf("digest = %q", value) + } + if _, err := ParseDigest(value); err != nil { + t.Fatalf("parse emitted digest: %v", err) + } + for _, invalid := range []string{value[7:], "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "SHA256:" + value[7:]} { + if _, err := ParseDigest(invalid); err != ErrInvalid { + t.Fatalf("invalid digest %q error = %v", invalid, err) + } + } +} + +func TestCreateMappingSetRejectsControlCharactersAndEmitsFrozenDigest(t *testing.T) { + repository := &evidenceValidationRepository{} + service := NewService(repository, nil, nil, WithRandom(zeroReader{})) + input := CreateMappingSetInput{ProjectID: "project_one", ProgramID: "migration_one", ExpectedStateVersion: 1, Version: 1, + Entries: []MappingEntry{{SourceKind: "customer_id", SourceIdentifier: "bad\nidentifier", TargetID: "customer_one", MatchKind: "exact"}}} + if _, err := service.CreateMappingSet(context.Background(), Actor{ID: "owner_one"}, input); err != ErrInvalid { + t.Fatalf("control-character source identifier error = %v", err) + } + input.Entries[0].SourceIdentifier = "$RCAnonymousID:opaque" + mapping, err := service.CreateMappingSet(context.Background(), Actor{ID: "owner_one"}, input) + if err != nil { + t.Fatal(err) + } + if _, err := ParseDigest(mapping.MappingDigest); err != nil { + t.Fatalf("mapping digest is not frozen sha256 form: %q", mapping.MappingDigest) + } +} + +func TestEmittedEvidenceRecordsMatchFrozenContract(t *testing.T) { + schemaFile, err := os.Open("../../../../protocol/schema/billing-migration-operations/v1/contract.schema.json") + if err != nil { + t.Fatal(err) + } + defer schemaFile.Close() + var schemaDocument any + if err := json.NewDecoder(schemaFile).Decode(&schemaDocument); err != nil { + t.Fatal(err) + } + compiler := jsonschema.NewCompiler() + compiler.UseRegexpEngine(func(value string) (jsonschema.Regexp, error) { + compiled, err := regexp2.Compile(value, regexp2.ECMAScript) + return (*testECMARegexp)(compiled), err + }) + if err := compiler.AddResource("billing-migration-v1", schemaDocument); err != nil { + t.Fatal(err) + } + schema, err := compiler.Compile("billing-migration-v1") + if err != nil { + t.Fatal(err) + } + records := []any{ + ContractRecord[MappingSet]{BillingMigrationOperationsContractVersion: ContractVersion, RecordType: "mappingSet", Payload: MappingSet{ProgramID: "migration_one", StateVersion: 1, MappingSetID: "mapping_one", Version: 1, Status: "draft", Entries: []MappingEntry{{SourceKind: "customer_id", SourceIdentifier: "$RCAnonymousID:opaque", TargetID: "customer_one", MatchKind: "exact"}}, MappingDigest: FormatDigest(make([]byte, 32))}}, + ContractRecord[ReadinessAssessment]{BillingMigrationOperationsContractVersion: ContractVersion, RecordType: "readinessAssessment", Payload: ReadinessAssessment{ProgramID: "migration_one", StateVersion: 4, Unresolved: Counts{}, ReadinessDigest: FormatDigest(make([]byte, 32))}}, + } + for _, record := range records { + encoded, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + var document any + if err := json.Unmarshal(encoded, &document); err != nil { + t.Fatal(err) + } + if err := schema.Validate(document); err != nil { + t.Fatalf("record %s rejected by frozen schema: %v", encoded, err) + } + } +} + +type testECMARegexp regexp2.Regexp + +func (expression *testECMARegexp) MatchString(value string) bool { + matched, err := (*regexp2.Regexp)(expression).MatchString(value) + return err == nil && matched +} +func (expression *testECMARegexp) String() string { return (*regexp2.Regexp)(expression).String() } + +type evidenceValidationRepository struct{ testRepository } + +func (*evidenceValidationRepository) AppendManifest(context.Context, int64, ManifestWrite) error { + return nil +} +func (*evidenceValidationRepository) ListManifests(context.Context, string, string, int) ([]SourceManifest, error) { + return nil, nil +} +func (*evidenceValidationRepository) CreateMappingSet(context.Context, int64, MappingSetWrite) error { + return nil +} +func (*evidenceValidationRepository) FreezeMappingSet(context.Context, string, string, string, int64, time.Time) error { + return nil +} +func (*evidenceValidationRepository) ListMappingSets(context.Context, string, string, int) ([]MappingSet, error) { + return nil, nil +} +func (*evidenceValidationRepository) CreateImportBatch(context.Context, int64, ImportBatchWrite) (bool, error) { + return false, nil +} +func (*evidenceValidationRepository) ListImportBatches(context.Context, string, string, int) ([]ImportBatch, error) { + return nil, nil +} +func (*evidenceValidationRepository) ImportBatch(context.Context, string, string, string) (ImportBatch, error) { + return ImportBatch{}, nil +} +func (*evidenceValidationRepository) ImportBatchByIdempotency(context.Context, string, string, string) (ImportBatch, error) { + return ImportBatch{}, nil +} +func (*evidenceValidationRepository) LeaseImportBatch(context.Context, string, time.Time, time.Time) (ImportBatch, bool, error) { + return ImportBatch{}, false, nil +} +func (*evidenceValidationRepository) CompleteImportBatch(context.Context, string, string, string, string, int64, string, int, int, time.Time) error { + return nil +} +func (*evidenceValidationRepository) QueueRun(context.Context, int64, RunJobWrite) (bool, error) { + return false, nil +} +func (*evidenceValidationRepository) RunJob(context.Context, string, string, string) (RunJob, error) { + return RunJob{}, nil +} +func (*evidenceValidationRepository) RunJobByIdempotency(context.Context, string, string, string) (RunJob, error) { + return RunJob{}, nil +} +func (*evidenceValidationRepository) RecordRun(context.Context, int64, RunWrite) error { return nil } +func (*evidenceValidationRepository) ListDivergences(context.Context, string, string, int) ([]Divergence, error) { + return nil, nil +} +func (*evidenceValidationRepository) ReadinessInput(context.Context, string, string) (ReadinessInput, error) { + return ReadinessInput{}, nil +} +func (*evidenceValidationRepository) RecordReadiness(context.Context, int64, ReadinessWrite) error { + return nil +} +func (*evidenceValidationRepository) LatestReadiness(context.Context, string, string) (ReadinessAssessment, error) { + return ReadinessAssessment{}, nil +} + +type zeroReader struct{} + +func (zeroReader) Read(buffer []byte) (int, error) { + for index := range buffer { + buffer[index] = 0 + } + return len(buffer), nil +} + +func contains(value, fragment string) bool { + for index := 0; index+len(fragment) <= len(value); index++ { + if value[index:index+len(fragment)] == fragment { + return true + } + } + return false +} diff --git a/apps/api/internal/billingmigration/source_execution.go b/apps/api/internal/billingmigration/source_execution.go new file mode 100644 index 00000000..bd84fb3b --- /dev/null +++ b/apps/api/internal/billingmigration/source_execution.go @@ -0,0 +1,124 @@ +package billingmigration + +import ( + "context" + "errors" + "time" +) + +const sourceExecutionLease = 2 * time.Minute + +type SourceExecutionProcessor struct { + repository SourceExecutionRepository + provider ProviderEvidenceImporter + prepared PreparedSnapshotBuilder + finalDelta FinalDeltaBuilder + now func() time.Time +} + +func NewSourceExecutionProcessor(repository SourceExecutionRepository, provider ProviderEvidenceImporter, prepared PreparedSnapshotBuilder, finalDelta FinalDeltaBuilder, now func() time.Time) *SourceExecutionProcessor { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + return &SourceExecutionProcessor{repository: repository, provider: provider, prepared: prepared, finalDelta: finalDelta, now: now} +} + +func boundedRetry(attempt int, now time.Time) time.Time { + if attempt < 1 { + attempt = 1 + } + if attempt > 8 { + attempt = 8 + } + return now.Add(time.Duration(1< 512 { + return false + } + for _, r := range value { + if r <= 0x1f || r == 0x7f { + return false + } + } + return true +} + +func validOptionalSourcePullPosition(value string) bool { + return value == "" || validSourcePullPosition(value) +} + +func deterministicPullID(prefix, jobID string, generation int64) string { + d := sha256.Sum256([]byte(prefix + "\x1f" + jobID + "\x1f" + fmt.Sprint(generation))) + return prefix + "_" + hex.EncodeToString(d[:12]) +} + +func sourcePullManifestDigest(lease SourcePullLease, result SourcePullProviderResult, object SourceObject) []byte { + h := sha256.New() + for _, value := range [][]byte{[]byte("mosaic-source-pull-manifest-v1"), []byte(lease.ID), []byte(lease.Intent), result.EvidenceDigest, object.Envelope.PlaintextDigest, []byte(result.FinalWatermark)} { + _, _ = h.Write(value) + _, _ = h.Write([]byte{0}) + } + return h.Sum(nil) +} diff --git a/apps/api/internal/billingmigration/source_pull_repository.go b/apps/api/internal/billingmigration/source_pull_repository.go new file mode 100644 index 00000000..d036ce8b --- /dev/null +++ b/apps/api/internal/billingmigration/source_pull_repository.go @@ -0,0 +1,114 @@ +package billingmigration + +import ( + "context" + "io" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +const ( + SourcePullSnapshot = "snapshot" + SourcePullDelta = "delta" + SourcePullFinalDelta = "final_delta" +) + +type SourcePullCommand struct { + Actor Actor + ProjectID, ProgramID string + Intent string + StartingCursor string + StartingWatermark string + StartingWatermarkDigest []byte + IdempotencyKey string + ExpectedStateVersion int64 + RequestDigest []byte + CreatedAt time.Time +} + +type SourcePullJob struct { + ID string `json:"sourcePullId"` + ProjectID string `json:"-"` + ProgramID string `json:"programId"` + Intent string `json:"intent"` + Status string `json:"status"` + StartingCursor string `json:"startingCursor,omitempty"` + StartingWatermark string `json:"startingWatermark,omitempty"` + IdempotencyKey string `json:"-"` + ResultSourceObjectID string `json:"resultSourceObjectId,omitempty"` + ResultManifestID string `json:"resultManifestId,omitempty"` + ResultImportBatchID string `json:"resultImportBatchId,omitempty"` + ResultFinalDeltaJobID string `json:"resultFinalDeltaJobId,omitempty"` + PredecessorPullJobID string `json:"predecessorPullJobId,omitempty"` + ResultImportStatus string `json:"resultImportStatus,omitempty"` + FailureCode string `json:"failureCode,omitempty"` + RequestDigest []byte `json:"-"` + EvidenceDigest []byte `json:"-"` + SourceObjectDigest []byte `json:"-"` + ManifestDigest []byte `json:"-"` + ImportDigest []byte `json:"-"` + FinalDeltaDigest []byte `json:"-"` + StartingWatermarkDigest []byte `json:"-"` + ExpectedStateVersion int64 `json:"stateVersion"` + LeaseGeneration int64 `json:"-"` + AttemptCount int `json:"attemptCount"` + MaxAttempts int `json:"maxAttempts"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + StartedAt time.Time `json:"startedAt,omitempty"` + CompletedAt time.Time `json:"completedAt,omitempty"` + FailedAt time.Time `json:"failedAt,omitempty"` +} + +type SourcePullLease struct { + SourcePullJob + Owner, OrganizationID, ExternalProjectID, CredentialID string + CredentialEnvelope providercredential.Envelope + EnvironmentID, MappingSetID string + MosaicEnvironmentMode string + MappingDigest []byte + ExpiresAt time.Time +} + +type SourcePullSettlement struct { + Lease SourcePullLease + Status, ErrorCode, ResumeCursor, FinalWatermark string + ManifestID, ImportBatchID, SourceObjectID string + EvidenceDigest []byte + RecordCount, CurrentAccessCount, ImportRecordCount int + RetryAt, SettledAt time.Time +} + +type SourcePullRecord struct { + Kind, SourceIdentifier, SourceRevision, Cursor string + Digest []byte + CurrentAccess bool + ObservedAt time.Time + CustomerID, ProductID, ExternalAppID string + Store, Environment, Provider, Platform, ReferenceKind string + ProviderReference string + StoreIdentifier string + EntitlementIDs []string + Ownership []byte + QuarantineReason string +} + +type SourcePullProviderResult struct { + Records []SourcePullRecord + ProvenCapabilities []string + ResumeCursor, FinalWatermark string + RecordCount, CurrentAccessCount int64 + EvidenceDigest []byte +} + +type SourcePullRepository interface { + QueueSourcePull(ctx context.Context, command SourcePullCommand) (SourcePullJob, bool, error) + LeaseSourcePull(ctx context.Context, workerID string, now, leaseUntil time.Time) (SourcePullLease, bool, error) + BindSourcePullRecords(ctx context.Context, lease SourcePullLease, records []SourcePullRecord) ([]NormalizedSourceRecord, error) + SettleSourcePull(ctx context.Context, settlement SourcePullSettlement) error +} + +type SourcePullProvider interface { + PullSource(ctx context.Context, externalProjectID string, secret []byte, startingAfter string, output io.Writer) (SourcePullProviderResult, error) +} diff --git a/apps/api/internal/billingmigration/source_pull_test.go b/apps/api/internal/billingmigration/source_pull_test.go new file mode 100644 index 00000000..a62cfb64 --- /dev/null +++ b/apps/api/internal/billingmigration/source_pull_test.go @@ -0,0 +1,53 @@ +package billingmigration + +import ( + "context" + "testing" + "time" +) + +type sourcePullRepositoryFake struct{ command SourcePullCommand } + +func (f *sourcePullRepositoryFake) QueueSourcePull(_ context.Context, c SourcePullCommand) (SourcePullJob, bool, error) { + f.command = c + return SourcePullJob{ID: "pull", Intent: c.Intent}, false, nil +} +func (*sourcePullRepositoryFake) LeaseSourcePull(context.Context, string, time.Time, time.Time) (SourcePullLease, bool, error) { + return SourcePullLease{}, false, nil +} +func (*sourcePullRepositoryFake) BindSourcePullRecords(context.Context, SourcePullLease, []SourcePullRecord) ([]NormalizedSourceRecord, error) { + return nil, nil +} +func (*sourcePullRepositoryFake) SettleSourcePull(context.Context, SourcePullSettlement) error { + return nil +} + +func TestSourcePullServiceRequiresExplicitIntentAndCanonicalizesRequest(t *testing.T) { + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + repository := &sourcePullRepositoryFake{} + service := NewSourcePullService(repository, func() time.Time { return now }) + base := SourcePullCommand{Actor: Actor{ID: "owner"}, ProjectID: "project", ProgramID: "program", IdempotencyKey: "pull-one", ExpectedStateVersion: 3} + if _, _, err := service.Queue(context.Background(), base); err != ErrInvalid { + t.Fatalf("implicit intent err=%v", err) + } + base.Intent = SourcePullFinalDelta + base.StartingCursor = "opaque" + base.StartingWatermark = "watermark" + base.StartingWatermarkDigest = make([]byte, 32) + job, replay, err := service.Queue(context.Background(), base) + if err != nil || replay || job.Intent != SourcePullFinalDelta { + t.Fatalf("job=%#v replay=%v err=%v", job, replay, err) + } + if len(repository.command.RequestDigest) != 32 || !repository.command.CreatedAt.Equal(now) { + t.Fatalf("command=%#v", repository.command) + } + // An empty source snapshot has no terminal customer ID. Its server-bound + // successor is still valid: RevenueCat is queried from the beginning and + // will capture any customers created after that empty snapshot. + base.IdempotencyKey = "empty-source-final" + base.StartingCursor = "" + base.RequestDigest = nil + if _, _, err = service.Queue(context.Background(), base); err != nil { + t.Fatalf("empty-source successor err=%v", err) + } +} diff --git a/apps/api/internal/billingmigration/stabilization.go b/apps/api/internal/billingmigration/stabilization.go new file mode 100644 index 00000000..1fde9381 --- /dev/null +++ b/apps/api/internal/billingmigration/stabilization.go @@ -0,0 +1,168 @@ +package billingmigration + +import ( + "context" + "sort" + "time" +) + +type StabilizationThresholds struct { + AuthorityMismatchMax int64 `json:"authorityMismatchMax"` + AccessAPIErrorMax int64 `json:"accessApiErrorMax"` + SDKSyncFailureMax int64 `json:"sdkSyncFailureMax"` + DivergenceMax int64 `json:"divergenceMax"` + ValidationBacklogMax int64 `json:"validationBacklogMax"` + SourceDeltaLagMaxSeconds int64 `json:"sourceDeltaLagMaxSeconds"` + WebhookFailureMax int64 `json:"webhookFailureMax"` + WebhookFreshnessMaxSeconds int64 `json:"webhookFreshnessMaxSeconds"` + QuarantineMax int64 `json:"quarantineMax"` + SupportCaseMax int64 `json:"supportCaseMax"` + OldAppVersionMax int64 `json:"oldAppVersionMax"` + WorkerUnhealthyMax int64 `json:"workerUnhealthyMax"` +} + +func (t StabilizationThresholds) Valid() bool { + return t.AuthorityMismatchMax >= 0 && t.AccessAPIErrorMax >= 0 && t.SDKSyncFailureMax >= 0 && t.DivergenceMax >= 0 && + t.ValidationBacklogMax >= 0 && t.SourceDeltaLagMaxSeconds > 0 && t.WebhookFailureMax >= 0 && + t.WebhookFreshnessMaxSeconds > 0 && t.QuarantineMax >= 0 && t.SupportCaseMax >= 0 && t.OldAppVersionMax >= 0 && t.WorkerUnhealthyMax >= 0 +} + +type StabilizationPolicy struct { + ID string `json:"policyId"` + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + PolicyDigest string `json:"policyDigest"` + FrozenByActorID string `json:"-"` + StateVersion int64 `json:"stateVersion"` + Thresholds StabilizationThresholds `json:"thresholds"` + FrozenAt time.Time `json:"frozenAt"` +} + +type FreezeStabilizationPolicyInput struct { + ProjectID, ProgramID, IdempotencyKey string + ExpectedStateVersion int64 + Thresholds StabilizationThresholds +} + +type StabilizationMetrics struct { + AuthorityMismatches int64 `json:"authorityMismatches"` + AccessAPIErrors int64 `json:"accessApiErrors"` + SDKSyncFailures int64 `json:"sdkSyncFailures"` + Divergences int64 `json:"divergences"` + ValidationBacklog int64 `json:"validationBacklog"` + SourceDeltaLagSeconds int64 `json:"sourceDeltaLagSeconds"` + WebhookFailures int64 `json:"webhookFailures"` + WebhookAgeSeconds int64 `json:"webhookAgeSeconds"` + QuarantinedRecords int64 `json:"quarantinedRecords"` + SupportCases int64 `json:"supportCases"` + OldAppVersions int64 `json:"oldAppVersions"` + UnhealthyWorkers int64 `json:"unhealthyWorkers"` +} + +func (m StabilizationMetrics) Valid() bool { + return m.AuthorityMismatches >= 0 && m.AccessAPIErrors >= 0 && m.SDKSyncFailures >= 0 && m.Divergences >= 0 && + m.ValidationBacklog >= 0 && m.SourceDeltaLagSeconds >= 0 && m.WebhookFailures >= 0 && m.WebhookAgeSeconds >= 0 && + m.QuarantinedRecords >= 0 && m.SupportCases >= 0 && m.OldAppVersions >= 0 && m.UnhealthyWorkers >= 0 +} + +func StabilizationBreaches(t StabilizationThresholds, m StabilizationMetrics) []string { + pairs := []struct { + code string + value, maximum int64 + }{ + {"authority", m.AuthorityMismatches, t.AuthorityMismatchMax}, {"access_api", m.AccessAPIErrors, t.AccessAPIErrorMax}, + {"sdk_sync", m.SDKSyncFailures, t.SDKSyncFailureMax}, {"divergence", m.Divergences, t.DivergenceMax}, + {"validation_backlog", m.ValidationBacklog, t.ValidationBacklogMax}, {"source_delta_lag", m.SourceDeltaLagSeconds, t.SourceDeltaLagMaxSeconds}, + {"webhook_failures", m.WebhookFailures, t.WebhookFailureMax}, {"webhook_freshness", m.WebhookAgeSeconds, t.WebhookFreshnessMaxSeconds}, + {"quarantine", m.QuarantinedRecords, t.QuarantineMax}, {"support_cases", m.SupportCases, t.SupportCaseMax}, + {"old_app_versions", m.OldAppVersions, t.OldAppVersionMax}, {"worker_health", m.UnhealthyWorkers, t.WorkerUnhealthyMax}, + } + var out []string + for _, pair := range pairs { + if pair.value > pair.maximum { + out = append(out, pair.code) + } + } + sort.Strings(out) + return out +} + +type RecordStabilizationInput struct { + ProjectID, ProgramID, IdempotencyKey, ExpectedPolicyDigest string + ExpectedStateVersion, ExpectedAuthorityEpoch int64 +} + +type StabilizationObservation struct { + ID string `json:"observationId"` + ProgramID string `json:"programId"` + ProjectID string `json:"-"` + PolicyID string `json:"policyId"` + PolicyDigest string `json:"policyDigest"` + EvidenceDigest string `json:"evidenceDigest"` + StateVersion int64 `json:"stateVersion"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Metrics StabilizationMetrics `json:"metrics"` + SourceWatermark time.Time `json:"sourceWatermark"` + WebhookLastSuccessAt time.Time `json:"webhookLastSuccessAt"` + ObservedAt time.Time `json:"observedAt"` + BreachCodes []string `json:"breachCodes"` + Healthy bool `json:"healthy"` +} + +type StabilizationRepository interface { + FreezeStabilizationPolicy(context.Context, FreezeStabilizationPolicyCommand) (StabilizationPolicy, bool, error) + RecordStabilization(context.Context, RecordStabilizationCommand) (StabilizationObservation, bool, error) + RecordTrustedAccessAPISignal(context.Context, TrustedAccessAPISignal) error +} +type FreezeStabilizationPolicyCommand struct { + Input FreezeStabilizationPolicyInput + ActorID string + RequestDigest []byte +} +type RecordStabilizationCommand struct { + Input RecordStabilizationInput + ActorID string + RequestDigest, ExpectedPolicyDigest []byte +} + +// TrustedAccessAPISignal is ingested only by an internal telemetry boundary. +// It is deliberately absent from the operator-facing service. +type TrustedAccessAPISignal struct { + ID, ProgramID, ProjectID string + WindowStartedAt, WindowEndedAt time.Time + RequestCount, ErrorCount int64 + EvidenceDigest []byte +} + +type StabilizationService struct { + auth Repository + repo StabilizationRepository +} + +func NewStabilizationService(auth Repository, repo StabilizationRepository) *StabilizationService { + return &StabilizationService{auth: auth, repo: repo} +} + +func (s *StabilizationService) FreezePolicy(ctx context.Context, actor Actor, input FreezeStabilizationPolicyInput) (StabilizationPolicy, bool, error) { + if s == nil || s.auth == nil || s.repo == nil || input.ProjectID == "" || input.ProgramID == "" || input.IdempotencyKey == "" || input.ExpectedStateVersion < 1 || !input.Thresholds.Valid() { + return StabilizationPolicy{}, false, ErrInvalid + } + if _, err := s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityAssessReadiness); err != nil { + return StabilizationPolicy{}, false, err + } + return s.repo.FreezeStabilizationPolicy(ctx, FreezeStabilizationPolicyCommand{Input: input, ActorID: actor.ID, RequestDigest: digest(input)}) +} + +func (s *StabilizationService) Observe(ctx context.Context, actor Actor, input RecordStabilizationInput) (StabilizationObservation, bool, error) { + if s == nil || s.auth == nil || s.repo == nil || input.ProjectID == "" || input.ProgramID == "" || input.IdempotencyKey == "" || input.ExpectedStateVersion < 1 || input.ExpectedAuthorityEpoch < 1 { + return StabilizationObservation{}, false, ErrInvalid + } + policy, err := ParseDigest(input.ExpectedPolicyDigest) + if err != nil { + return StabilizationObservation{}, false, ErrInvalid + } + if _, err = s.auth.Authorize(ctx, actor, input.ProjectID, CapabilityAssessReadiness); err != nil { + return StabilizationObservation{}, false, err + } + return s.repo.RecordStabilization(ctx, RecordStabilizationCommand{Input: input, ActorID: actor.ID, RequestDigest: digest(input), ExpectedPolicyDigest: policy}) +} diff --git a/apps/api/internal/billingmigration/stabilization_test.go b/apps/api/internal/billingmigration/stabilization_test.go new file mode 100644 index 00000000..0bab9d17 --- /dev/null +++ b/apps/api/internal/billingmigration/stabilization_test.go @@ -0,0 +1,18 @@ +package billingmigration + +import "testing" + +func TestStabilizationBreachesCoversEveryFrozenThreshold(t *testing.T) { + thresholds := StabilizationThresholds{SourceDeltaLagMaxSeconds: 60, WebhookFreshnessMaxSeconds: 60} + metrics := StabilizationMetrics{AuthorityMismatches: 1, AccessAPIErrors: 1, SDKSyncFailures: 1, Divergences: 1, ValidationBacklog: 1, SourceDeltaLagSeconds: 61, WebhookFailures: 1, WebhookAgeSeconds: 61, QuarantinedRecords: 1, SupportCases: 1, OldAppVersions: 1, UnhealthyWorkers: 1} + got := StabilizationBreaches(thresholds, metrics) + if len(got) != 12 { + t.Fatalf("breaches=%v", got) + } +} + +func TestStabilizationThresholdsRejectMissingFreshnessBounds(t *testing.T) { + if (StabilizationThresholds{}).Valid() { + t.Fatal("zero freshness bounds were accepted") + } +} diff --git a/apps/api/internal/billingmigration/transition_delivery.go b/apps/api/internal/billingmigration/transition_delivery.go new file mode 100644 index 00000000..4a57b022 --- /dev/null +++ b/apps/api/internal/billingmigration/transition_delivery.go @@ -0,0 +1,216 @@ +package billingmigration + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" +) + +const ( + TransitionCutoverPending = "cutover_pending" + TransitionCutoverCompleted = "cutover_completed" + TransitionRollbackCompleted = "rollback_completed" + TransitionStabilizationCompleted = "stabilization_completed" + TransitionLegacyCutoverCompleted = "authority_changed" + TransitionLegacyRollbackCompleted = "rollback_changed" + TransitionDeliveryLease = 90 * time.Second + TransitionDeliveryMaxAttempts = 8 +) + +type TransitionOutbox struct { + ID, ProgramID, ProjectID, AuthorityScopeID string + CheckpointID, TransitionID, CompletionReportID string + EventKind, CorrelationID string + AuthorityEpoch, LeaseGeneration int64 + AttemptCount, MaxAttempts int + CreatedAt, DueAt, LeaseExpiresAt time.Time + LeaseOwner string +} + +type TransitionAudienceMember struct { + BillingCustomerID, CustomerSnapshotID string + SnapshotVersion int64 + PreviousSnapshotVersion *int64 + SnapshotChecksum, AuthorityDigest []byte + ProjectID, EnvironmentID string + ApplicationID, Platform string + AuthorityKind, TransitionState string + AuthorityEpoch int64 + CutoverAt *time.Time + OccurredAt time.Time +} + +type AppendTransition struct { + ID, ProgramID, ProjectID, AuthorityScopeID string + CheckpointID, TransitionID, CompletionReportID string + EventKind, CorrelationID string + AuthorityEpoch int64 + DueAt, CreatedAt time.Time +} + +type TransitionFailure struct { + OutboxID, LeaseOwner, ErrorCode string + LeaseGeneration int64 + RetryAt, FailedAt time.Time +} + +type TransitionDeliveryRepository interface { + AppendTransition(ctx context.Context, input AppendTransition) (TransitionOutbox, bool, error) + LeaseTransition(ctx context.Context, workerID string, now, leaseUntil time.Time) (TransitionOutbox, bool, error) + TransitionAudience(ctx context.Context, outbox TransitionOutbox) ([]TransitionAudienceMember, error) + CommitTransition(ctx context.Context, outbox TransitionOutbox, events []billingwebhook.StoredEventV2, completedAt time.Time) error + FailTransition(ctx context.Context, failure TransitionFailure) error +} + +type TransitionDeliveryService struct { + repository TransitionDeliveryRepository + now func() time.Time +} + +func NewTransitionDeliveryService(repository TransitionDeliveryRepository, now func() time.Time) *TransitionDeliveryService { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + return &TransitionDeliveryService{repository: repository, now: now} +} + +func (s *TransitionDeliveryService) Append(ctx context.Context, input AppendTransition) (TransitionOutbox, bool, error) { + if input.ProgramID == "" || input.ProjectID == "" || input.AuthorityScopeID == "" || + input.CheckpointID == "" || input.CorrelationID == "" || input.AuthorityEpoch < 0 { + return TransitionOutbox{}, false, ErrInvalid + } + switch input.EventKind { + case TransitionCutoverPending: + if input.TransitionID != "" || input.CompletionReportID != "" { + return TransitionOutbox{}, false, ErrInvalid + } + case TransitionCutoverCompleted, TransitionRollbackCompleted: + if input.TransitionID == "" || input.CompletionReportID != "" { + return TransitionOutbox{}, false, ErrInvalid + } + case TransitionStabilizationCompleted: + if input.TransitionID != "" || input.CompletionReportID == "" { + return TransitionOutbox{}, false, ErrInvalid + } + default: + return TransitionOutbox{}, false, ErrInvalid + } + return s.repository.AppendTransition(ctx, input) +} + +func transitionEventType(kind string) string { + switch kind { + case TransitionCutoverPending: + return billingwebhook.EventTypeAuthorityCutoverPending + case TransitionCutoverCompleted: + return billingwebhook.EventTypeAuthorityCutoverCompleted + case TransitionLegacyCutoverCompleted: + return billingwebhook.EventTypeAuthorityCutoverCompleted + case TransitionRollbackCompleted: + return billingwebhook.EventTypeAuthorityRollbackCompleted + case TransitionLegacyRollbackCompleted: + return billingwebhook.EventTypeAuthorityRollbackCompleted + case TransitionStabilizationCompleted: + return billingwebhook.EventTypeAuthorityStabilizationCompleted + default: + return "" + } +} + +func transitionEventID(outboxID, customerID string) string { + sum := sha256.Sum256([]byte("mosaic-bsw-v2\x00" + outboxID + "\x00" + customerID)) + return "whe2_" + hex.EncodeToString(sum[:16]) +} + +func snapshotBindingDigest(member TransitionAudienceMember) string { + hash := sha256.New() + _, _ = hash.Write([]byte("mosaic-bsw-v2-snapshot-authority\x00")) + _, _ = hash.Write(member.AuthorityDigest) + _, _ = hash.Write([]byte{0}) + if len(member.SnapshotChecksum) == 0 { + _, _ = hash.Write([]byte("absent")) + } else { + _, _ = hash.Write(member.SnapshotChecksum) + } + return billingwebhook.FormatDigest(hash.Sum(nil)) +} + +func renderTransitionEvent(outbox TransitionOutbox, member TransitionAudienceMember) (billingwebhook.StoredEventV2, error) { + eventType := transitionEventType(outbox.EventKind) + if eventType == "" { + return billingwebhook.StoredEventV2{}, ErrInvalid + } + event := billingwebhook.EventV2{ + EventID: transitionEventID(outbox.ID, member.BillingCustomerID), EventType: eventType, + BillingCustomerID: member.BillingCustomerID, SnapshotVersion: member.SnapshotVersion, + PreviousSnapshotVersion: member.PreviousSnapshotVersion, OccurredAt: member.OccurredAt, + CreatedAt: outbox.CreatedAt, CorrelationID: outbox.CorrelationID, + ChangedEntitlements: []billingwebhook.ChangedEntitlement{}, + Authority: billingwebhook.EventAuthority{AuthorityEpoch: member.AuthorityEpoch, + AuthorityKind: member.AuthorityKind, TransitionState: member.TransitionState, + CutoverAt: member.CutoverAt, SnapshotAuthorityDigest: snapshotBindingDigest(member), + Scope: billingwebhook.AuthorityScope{ProjectID: member.ProjectID, + EnvironmentID: member.EnvironmentID, ApplicationID: member.ApplicationID, Platform: member.Platform}}, + } + body, digest, err := billingwebhook.CanonicalEventV2(event) + if err != nil { + return billingwebhook.StoredEventV2{}, err + } + return billingwebhook.StoredEventV2{Event: event, CustomerSnapshotID: member.CustomerSnapshotID, + AuthorityScopeID: outbox.AuthorityScopeID, TransitionOutboxID: outbox.ID, + Body: body, Digest: digest}, nil +} + +func (s *TransitionDeliveryService) ProcessOne(ctx context.Context, workerID string) (bool, error) { + now := s.now() + outbox, ok, err := s.repository.LeaseTransition(ctx, workerID, now, now.Add(TransitionDeliveryLease)) + if err != nil || !ok { + return ok, err + } + audience, err := s.repository.TransitionAudience(ctx, outbox) + if err == nil && len(audience) == 0 { + err = fmt.Errorf("transition audience is empty") + } + events := make([]billingwebhook.StoredEventV2, 0, len(audience)) + if err == nil { + for _, member := range audience { + event, renderErr := renderTransitionEvent(outbox, member) + if renderErr != nil { + err = renderErr + break + } + events = append(events, event) + } + } + if err == nil { + err = s.repository.CommitTransition(ctx, outbox, events, s.now()) + if err == nil { + return true, nil + } + } + failure := TransitionFailure{OutboxID: outbox.ID, LeaseOwner: workerID, + LeaseGeneration: outbox.LeaseGeneration, ErrorCode: "transition_delivery_failed", FailedAt: s.now(), + RetryAt: s.now().Add(transitionBackoff(outbox.AttemptCount))} + if failErr := s.repository.FailTransition(ctx, failure); failErr != nil { + return true, fmt.Errorf("transition failed: %v; release lease: %w", err, failErr) + } + return true, err +} + +func transitionBackoff(attempt int) time.Duration { + if attempt < 1 { + attempt = 1 + } + if attempt > 7 { + attempt = 7 + } + delay := 15 * time.Second * time.Duration(1<<(attempt-1)) + if delay > 10*time.Minute { + return 10 * time.Minute + } + return delay +} diff --git a/apps/api/internal/billingmigration/transition_delivery_repository.go b/apps/api/internal/billingmigration/transition_delivery_repository.go new file mode 100644 index 00000000..34f13b0e --- /dev/null +++ b/apps/api/internal/billingmigration/transition_delivery_repository.go @@ -0,0 +1,5 @@ +package billingmigration + +// This file intentionally contains only the transition-delivery port. Keeping +// it separate from Repository avoids coupling the Stage 2B management service +// and its existing tests to Stage 2E worker-only persistence. diff --git a/apps/api/internal/billingmigration/transition_delivery_test.go b/apps/api/internal/billingmigration/transition_delivery_test.go new file mode 100644 index 00000000..a0714d00 --- /dev/null +++ b/apps/api/internal/billingmigration/transition_delivery_test.go @@ -0,0 +1,103 @@ +package billingmigration + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" +) + +type transitionRepositoryStub struct { + outbox TransitionOutbox + audience []TransitionAudienceMember + committed []billingwebhook.StoredEventV2 + commitErr error + failure *TransitionFailure +} + +func (r *transitionRepositoryStub) AppendTransition(context.Context, AppendTransition) (TransitionOutbox, bool, error) { + return TransitionOutbox{}, false, nil +} +func (r *transitionRepositoryStub) LeaseTransition(context.Context, string, time.Time, time.Time) (TransitionOutbox, bool, error) { + return r.outbox, true, nil +} +func (r *transitionRepositoryStub) TransitionAudience(context.Context, TransitionOutbox) ([]TransitionAudienceMember, error) { + return r.audience, nil +} +func (r *transitionRepositoryStub) CommitTransition(_ context.Context, _ TransitionOutbox, e []billingwebhook.StoredEventV2, _ time.Time) error { + r.committed = e + return r.commitErr +} +func (r *transitionRepositoryStub) FailTransition(_ context.Context, f TransitionFailure) error { + r.failure = &f + return nil +} + +// The unit boundary protects deterministic rendering and absent-baseline +// semantics. PostgreSQL integration separately protects atomic insert/fanout. +func TestTransitionDeliveryRendersOneStableEventPerCustomerIncludingAbsentBaseline(t *testing.T) { + now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC) + out := TransitionOutbox{ID: "outbox-1", ProgramID: "program-1", ProjectID: "project-1", AuthorityScopeID: "scope-1", + CheckpointID: "checkpoint-1", TransitionID: "transition-1", EventKind: TransitionRollbackCompleted, + CorrelationID: "transition-1", AuthorityEpoch: 2, AttemptCount: 1, MaxAttempts: 8, LeaseGeneration: 1, + LeaseOwner: "worker-1", CreatedAt: now} + cutover := now.Add(-24 * time.Hour) + previous := int64(8) + repository := &transitionRepositoryStub{outbox: out, audience: []TransitionAudienceMember{ + {BillingCustomerID: "customer-a", SnapshotVersion: 0, PreviousSnapshotVersion: &previous, + ProjectID: "project-1", EnvironmentID: "environment-1", ApplicationID: "app-1", Platform: "ios", + AuthorityKind: "source_rollback", TransitionState: "rolled_back", AuthorityEpoch: 2, CutoverAt: &cutover, OccurredAt: now, + AuthorityDigest: bytes.Repeat([]byte{1}, 32)}, + {BillingCustomerID: "customer-b", CustomerSnapshotID: "snapshot-b", SnapshotVersion: 4, PreviousSnapshotVersion: &previous, + SnapshotChecksum: bytes.Repeat([]byte{2}, 32), ProjectID: "project-1", EnvironmentID: "environment-1", ApplicationID: "app-1", Platform: "ios", + AuthorityKind: "source_rollback", TransitionState: "rolled_back", AuthorityEpoch: 2, CutoverAt: &cutover, OccurredAt: now, + AuthorityDigest: bytes.Repeat([]byte{1}, 32)}}} + service := NewTransitionDeliveryService(repository, func() time.Time { return now }) + processed, err := service.ProcessOne(context.Background(), "worker-1") + if err != nil || !processed || len(repository.committed) != 2 { + t.Fatalf("processed=%v events=%d err=%v", processed, len(repository.committed), err) + } + if repository.committed[0].Event.SnapshotVersion != 0 || repository.committed[0].CustomerSnapshotID != "" { + t.Fatalf("absent baseline event=%#v", repository.committed[0]) + } + first, err := renderTransitionEvent(out, repository.audience[0]) + if err != nil { + t.Fatal(err) + } + second, err := renderTransitionEvent(out, repository.audience[0]) + if err != nil { + t.Fatal(err) + } + if first.Event.EventID != second.Event.EventID || !bytes.Equal(first.Body, second.Body) || !bytes.Equal(first.Digest, second.Digest) { + t.Fatal("crash retry changed event identity or bytes") + } +} + +func TestTransitionCommitFailureLeavesLeaseRetryable(t *testing.T) { + now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC) + cutover := now.Add(-time.Hour) + repository := &transitionRepositoryStub{outbox: TransitionOutbox{ID: "outbox-failure", EventKind: TransitionCutoverCompleted, + CorrelationID: "transition-1", AuthorityScopeID: "scope-1", LeaseOwner: "worker-1", LeaseGeneration: 3, AttemptCount: 2, CreatedAt: now}, + commitErr: errors.New("forced fanout failure"), audience: []TransitionAudienceMember{{BillingCustomerID: "customer-1", CustomerSnapshotID: "snapshot-1", SnapshotVersion: 1, + ProjectID: "project-1", EnvironmentID: "environment-1", ApplicationID: "app-1", Platform: "ios", AuthorityKind: "mosaic", TransitionState: "stabilizing", + AuthorityEpoch: 1, CutoverAt: &cutover, OccurredAt: now, SnapshotChecksum: bytes.Repeat([]byte{1}, 32), AuthorityDigest: bytes.Repeat([]byte{2}, 32)}}} + processed, err := NewTransitionDeliveryService(repository, func() time.Time { return now }).ProcessOne(context.Background(), "worker-1") + if !processed || err == nil || repository.failure == nil { + t.Fatalf("processed=%v failure=%#v err=%v", processed, repository.failure, err) + } + if repository.failure.LeaseGeneration != 3 || repository.failure.RetryAt.Sub(now) != 30*time.Second { + t.Fatalf("retry=%#v", repository.failure) + } +} + +func TestTransitionBackoffIsBounded(t *testing.T) { + cases := map[int]time.Duration{1: 15 * time.Second, 2: 30 * time.Second, 4: 2 * time.Minute, 20: 10 * time.Minute} + for attempt, want := range cases { + if got := transitionBackoff(attempt); got != want { + t.Fatalf("attempt %d backoff=%v want=%v", attempt, got, want) + } + } +} diff --git a/apps/api/internal/billingmigration/types.go b/apps/api/internal/billingmigration/types.go new file mode 100644 index 00000000..c132b031 --- /dev/null +++ b/apps/api/internal/billingmigration/types.go @@ -0,0 +1,150 @@ +// Package billingmigration owns Phase 9C migration evidence and operator +// workflow state. Source records in this package are evidence only; this +// package has no port capable of inserting a billing Transaction Fact. +package billingmigration + +import "time" + +const ( + ContractVersion = "1" + AdapterRevenueCat = "revenuecat" + AdapterVersion = "revenuecat-v2-2026-07-29" + ProviderAPIV2 = "v2" + + CapabilityView = "view" + CapabilityManageSource = "manage-source" + CapabilityManageMappings = "manage-mappings" + CapabilityRunImport = "run-import" + CapabilityAssessReadiness = "assess-readiness" + + StateDraft = "draft" + StateMapping = "mapping" + StateImporting = "importing" + StateDryRun = "dry_run" + StateShadowing = "shadowing" + StateReady = "ready" + StateCutoverPending = "cutover_pending" + StateStabilizing = "stabilizing" +) + +type Actor struct { + ID string +} + +type Authorization struct { + OrganizationID string + Role string +} + +type ScopeItem struct { + ApplicationID string `json:"applicationId"` + Platform string `json:"platform"` +} + +type Scope struct { + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + Applications []ScopeItem `json:"applications"` +} + +type Source struct { + Adapter string `json:"adapter"` + AdapterVersion string `json:"adapterVersion"` + CredentialReference string `json:"credentialReference"` +} + +type Program struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + State string `json:"state"` + Scope Scope `json:"scope"` + Source Source `json:"source"` + AuthorityEpochBefore int64 `json:"authorityEpochBefore"` + StabilizationDays int `json:"stabilizationDays"` + RollbackWindowDays int `json:"rollbackWindowDays"` + + ScopeDigest string `json:"-"` + PolicyDigest string `json:"-"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` +} + +type CapabilityAssessment struct { + ProgramID string `json:"programId"` + StateVersion int64 `json:"stateVersion"` + Adapter string `json:"adapter"` + ProviderAPIVersion string `json:"providerApiVersion"` + Capabilities []string `json:"capabilities"` + AssessedAt time.Time `json:"assessedAt"` +} + +type ProgramDetail struct { + Program Program `json:"program"` + SourceCapabilityAssessment *CapabilityAssessment `json:"sourceCapabilityAssessment,omitempty"` + OperatorCapabilities []string `json:"operatorCapabilities"` +} + +type ContractRecord[T any] struct { + BillingMigrationOperationsContractVersion string `json:"billingMigrationOperationsContractVersion"` + RecordType string `json:"recordType"` + Payload T `json:"payload"` +} + +func ProgramRecord(program Program) ContractRecord[Program] { + return ContractRecord[Program]{ + BillingMigrationOperationsContractVersion: ContractVersion, + RecordType: "migrationProgram", + Payload: program, + } +} + +type CreateProgramInput struct { + ProjectID string + EnvironmentID string + Applications []ScopeItem + ExternalProjectID string + Credential []byte + IdempotencyKey string + StabilizationDays int + RollbackWindowDays int +} + +type CapabilityResult struct { + ProviderAPIVersion string + Capabilities []string + AssessedAt time.Time +} + +type SealedCredential struct { + ID string + ProjectID string + ExternalProjectID string + EnvelopeVersion int + Algorithm string + KeyID string + Nonce []byte + Ciphertext []byte + Fingerprint []byte + CreatedByActorID string + CreatedAt time.Time +} + +type CreateProgramCommand struct { + OrganizationID string + Program Program + Credential SealedCredential + AssessmentID string + Assessment CapabilityAssessment + AssessmentDigest []byte + RequestDigest []byte + ScopeDigest []byte + PolicyDigest []byte + ActorID string + IdempotencyKey string + Now time.Time +} + +type StoredIdempotency struct { + ProgramID string + RequestDigest []byte +} diff --git a/apps/api/internal/billingwebhook/errors.go b/apps/api/internal/billingwebhook/errors.go index c262ba26..6fc53d32 100644 --- a/apps/api/internal/billingwebhook/errors.go +++ b/apps/api/internal/billingwebhook/errors.go @@ -7,6 +7,10 @@ import "errors" var ( // ErrUnauthenticated is a missing operator identity. ErrUnauthenticated = errors.New("the request could not be authenticated") + // ErrForbidden is an authenticated Project member whose role is too low for + // billing webhook management. Non-members remain ErrNotFound so this error + // cannot be used to enumerate Projects outside the actor's organization. + ErrForbidden = errors.New("the actor is not authorized to manage billing webhooks") // ErrNotFound covers both a genuinely absent destination and one owned by // another tenant. They are deliberately the same answer: distinguishing // them would let a caller enumerate another Project's destinations. diff --git a/apps/api/internal/billingwebhook/freshness_test.go b/apps/api/internal/billingwebhook/freshness_test.go new file mode 100644 index 00000000..80e430ff --- /dev/null +++ b/apps/api/internal/billingwebhook/freshness_test.go @@ -0,0 +1,32 @@ +package billingwebhook + +import ( + "context" + "testing" + "time" +) + +type freshnessAttemptRepository struct { + Repository + result AttemptResult +} + +func (r *freshnessAttemptRepository) CompleteAttempt(_ context.Context, result AttemptResult) (int, error) { + r.result = result + return 0, nil +} + +func TestDeliveryServicePersistsVerifiedOutcomeWithoutCallerFreshnessField(t *testing.T) { + repository := &freshnessAttemptRepository{} + service := NewService(repository, nil, nil) + now := time.Date(2026, 7, 30, 11, 0, 0, 0, time.UTC) + leased := LeasedDelivery{Delivery: Delivery{ID: "delivery", ProjectID: "project", EnvironmentID: "environment", EventID: "event", DestinationID: "destination", AttemptCount: 1, MaxAttempts: 8}} + result := AttemptResult{Delivery: leased.Delivery, AttemptNumber: 1, Outcome: OutcomeDelivered, Status: DeliverySucceeded, AttemptedAt: now, RespondedAt: &now, CompletedAt: &now, ResetDestinationFailures: true} + + if err := service.record(context.Background(), leased, result, ""); err != nil { + t.Fatal(err) + } + if repository.result.Outcome != OutcomeDelivered || repository.result.Status != DeliverySucceeded || !repository.result.ResetDestinationFailures { + t.Fatalf("persisted verified outcome=%#v", repository.result) + } +} diff --git a/apps/api/internal/billingwebhook/model.go b/apps/api/internal/billingwebhook/model.go index 999c8b83..3507e230 100644 --- a/apps/api/internal/billingwebhook/model.go +++ b/apps/api/internal/billingwebhook/model.go @@ -15,6 +15,8 @@ import "time" // transaction writes the complete body and delivery sends those exact bytes. const ContractVersion = "1" +const ContractVersionV2 = "2" + // EventTypeEntitlementsChanged is the one event type Phase 9B emits. The // contract declares ten; the other nine are reserved vocabulary. const EventTypeEntitlementsChanged = "customer.entitlements.changed" @@ -116,15 +118,16 @@ type Actor struct{ ID string } // carries secret material — not the ciphertext, not the plaintext, not a // fingerprint an offline guess could be checked against. type Destination struct { - ID string `json:"id"` - ProjectID string `json:"projectId"` - EnvironmentID string `json:"environmentId"` - URL string `json:"url"` - Status string `json:"status"` - EventTypes []string `json:"eventTypes"` - Description string `json:"description"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + URL string `json:"url"` + Status string `json:"status"` + ContractVersion int `json:"contractVersion"` + EventTypes []string `json:"eventTypes"` + Description string `json:"description"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` SecretLastRotatedAt *time.Time `json:"secretLastRotatedAt,omitempty"` DisabledReason string `json:"disabledReason,omitempty"` @@ -250,19 +253,21 @@ type StoredSecret struct { // DestinationInput is a create or update request after transport validation. type DestinationInput struct { - ProjectID string - EnvironmentID string - URL string - EventTypes []string - Description string + ProjectID string + EnvironmentID string + URL string + EventTypes []string + Description string + ContractVersion int } // DestinationUpdate is a partial update. A nil field is unchanged, which keeps // "clear the description" distinguishable from "leave it alone". type DestinationUpdate struct { - URL *string - EventTypes []string - Description *string + URL *string + EventTypes []string + Description *string + ContractVersion *int } // AttemptResult is the outcome of one delivery attempt, applied to the diff --git a/apps/api/internal/billingwebhook/model_v2.go b/apps/api/internal/billingwebhook/model_v2.go new file mode 100644 index 00000000..fe1b9b7b --- /dev/null +++ b/apps/api/internal/billingwebhook/model_v2.go @@ -0,0 +1,75 @@ +package billingwebhook + +import ( + "context" + "time" +) + +const ( + EventTypeAuthorityCutoverPending = "authority.cutover.pending" + EventTypeAuthorityCutoverCompleted = "authority.cutover.completed" + EventTypeAuthorityRollbackCompleted = "authority.rollback.completed" + EventTypeAuthorityStabilizationCompleted = "authority.stabilization.completed" +) + +type AuthorityScope struct { + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + ApplicationID string `json:"applicationId"` + Platform string `json:"platform"` +} + +type EventAuthority struct { + AuthorityEpoch int64 `json:"authorityEpoch"` + AuthorityKind string `json:"authorityKind"` + Scope AuthorityScope `json:"scope"` + TransitionState string `json:"transitionState"` + CutoverAt *time.Time `json:"-"` + SnapshotAuthorityDigest string `json:"snapshotAuthorityDigest"` +} + +type ChangedEntitlement struct { + EntitlementKey string `json:"entitlementKey"` + PreviousState string `json:"previousState"` + CurrentState string `json:"currentState"` +} + +// EventV2 is the strict v2 event payload. Times are rendered through +// CanonicalEventV2 so the wire always uses millisecond UTC precision. +type EventV2 struct { + EventID string + EventType string + BillingCustomerID string + Authority EventAuthority + SnapshotVersion int64 + PreviousSnapshotVersion *int64 + OccurredAt time.Time + CreatedAt time.Time + CorrelationID string + ChangedEntitlements []ChangedEntitlement +} + +type StoredEventV2 struct { + Event EventV2 + CustomerSnapshotID string + AuthorityScopeID string + TransitionOutboxID string + Body []byte + Digest []byte +} + +type DestinationReadiness struct { + ActiveDestinationCount int + HealthyV2Count int +} + +func (r DestinationReadiness) Ready() bool { + return r.ActiveDestinationCount == 0 || r.HealthyV2Count > 0 +} + +// DestinationReadinessReader is intentionally separate from the management +// repository. Stage 2E readiness can consume it without widening or wiring the +// shared migration service yet. +type DestinationReadinessReader interface { + DestinationReadiness(ctx context.Context, projectID, environmentID string, recentAfter time.Time) (DestinationReadiness, error) +} diff --git a/apps/api/internal/billingwebhook/repository.go b/apps/api/internal/billingwebhook/repository.go index 7ebfdb68..5402e01a 100644 --- a/apps/api/internal/billingwebhook/repository.go +++ b/apps/api/internal/billingwebhook/repository.go @@ -12,6 +12,13 @@ import ( // rather than of the caller remembering to check, because a read surface that // depends on the caller checking eventually meets a caller that did not. type Repository interface { + // AuthorizeProject requires organization membership with the owner or admin + // role. A non-member is reported as ErrNotFound so Project existence is not + // exposed across tenant boundaries. + AuthorizeProject(ctx context.Context, actor Actor, projectID string) error + // AuthorizeEnvironment applies the same role check and verifies that the + // Environment belongs to the Project named by the caller. + AuthorizeEnvironment(ctx context.Context, actor Actor, projectID, environmentID string) error // BillingEnabled reports the Project's billing setting. It fails closed: // the service treats an unreadable setting as disabled. BillingEnabled(ctx context.Context, projectID string) (bool, error) diff --git a/apps/api/internal/billingwebhook/service.go b/apps/api/internal/billingwebhook/service.go index 874c8925..6dfb7d06 100644 --- a/apps/api/internal/billingwebhook/service.go +++ b/apps/api/internal/billingwebhook/service.go @@ -110,13 +110,17 @@ func (s *Service) CreateDestination(ctx context.Context, actor Actor, input Dest ctx, span := s.tracer.Start(ctx, "billing.webhook.destination.create") defer span.End() - if actor.ID == "" { - return DestinationWithSecret{}, ErrUnauthenticated + if err := s.repository.AuthorizeEnvironment(ctx, actor, input.ProjectID, input.EnvironmentID); err != nil { + return DestinationWithSecret{}, err } if err := s.requireEnabled(ctx, input.ProjectID); err != nil { return DestinationWithSecret{}, err } - eventTypes, err := normalizeEventTypes(input.EventTypes) + contractVersion := input.ContractVersion + if contractVersion == 0 { + contractVersion = 1 + } + eventTypes, err := normalizeEventTypes(contractVersion, input.EventTypes) if err != nil { return DestinationWithSecret{}, err } @@ -136,15 +140,16 @@ func (s *Service) CreateDestination(ctx context.Context, actor Actor, input Dest now := s.now() created, err := s.repository.CreateDestination(ctx, Destination{ - ID: destinationID, - ProjectID: input.ProjectID, - EnvironmentID: input.EnvironmentID, - URL: strings.TrimSpace(input.URL), - Status: DestinationActive, - EventTypes: eventTypes, - Description: strings.TrimSpace(input.Description), - CreatedAt: now, - UpdatedAt: now, + ID: destinationID, + ProjectID: input.ProjectID, + EnvironmentID: input.EnvironmentID, + URL: strings.TrimSpace(input.URL), + Status: DestinationActive, + ContractVersion: contractVersion, + EventTypes: eventTypes, + Description: strings.TrimSpace(input.Description), + CreatedAt: now, + UpdatedAt: now, }, sealed, actor.ID, now) if err != nil { return DestinationWithSecret{}, err @@ -166,15 +171,15 @@ func (s *Service) CreateDestination(ctx context.Context, actor Actor, input Dest } func (s *Service) ListDestinations(ctx context.Context, actor Actor, projectID, environmentID string) ([]Destination, error) { - if actor.ID == "" { - return nil, ErrUnauthenticated + if err := s.repository.AuthorizeEnvironment(ctx, actor, projectID, environmentID); err != nil { + return nil, err } return s.repository.ListDestinations(ctx, projectID, environmentID) } func (s *Service) Destination(ctx context.Context, actor Actor, projectID, destinationID string) (Destination, error) { - if actor.ID == "" { - return Destination{}, ErrUnauthenticated + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return Destination{}, err } return s.repository.Destination(ctx, projectID, destinationID) } @@ -182,8 +187,8 @@ func (s *Service) Destination(ctx context.Context, actor Actor, projectID, desti // UpdateDestination changes the URL, subscribed event types, or description. A // changed URL is screened before it is stored, exactly as a new one is. func (s *Service) UpdateDestination(ctx context.Context, actor Actor, projectID, destinationID string, update DestinationUpdate) (Destination, error) { - if actor.ID == "" { - return Destination{}, ErrUnauthenticated + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return Destination{}, err } if err := s.requireEnabled(ctx, projectID); err != nil { return Destination{}, err @@ -195,12 +200,26 @@ func (s *Service) UpdateDestination(ctx context.Context, actor Actor, projectID, trimmed := strings.TrimSpace(*update.URL) update.URL = &trimmed } - if update.EventTypes != nil { - eventTypes, err := normalizeEventTypes(update.EventTypes) + if update.EventTypes != nil || update.ContractVersion != nil { + destination, err := s.repository.Destination(ctx, projectID, destinationID) + if err != nil { + return Destination{}, err + } + version := destination.ContractVersion + if update.ContractVersion != nil { + version = *update.ContractVersion + } + requested := update.EventTypes + if requested == nil { + requested = destination.EventTypes + } + eventTypes, err := normalizeEventTypes(version, requested) if err != nil { return Destination{}, err } - update.EventTypes = eventTypes + if update.EventTypes != nil || update.ContractVersion != nil { + update.EventTypes = eventTypes + } } return s.repository.UpdateDestination(ctx, projectID, destinationID, update, actor.ID, s.now()) } @@ -210,8 +229,8 @@ func (s *Service) UpdateDestination(ctx context.Context, actor Actor, projectID, // Resuming clears the auto-disable state, which is what makes an automatic // disable recoverable by an operator rather than permanent. func (s *Service) SetStatus(ctx context.Context, actor Actor, projectID, destinationID, status, reason string) (Destination, error) { - if actor.ID == "" { - return Destination{}, ErrUnauthenticated + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return Destination{}, err } switch status { case DestinationActive, DestinationPaused, DestinationDisabled: @@ -229,8 +248,8 @@ func (s *Service) SetStatus(ctx context.Context, actor Actor, projectID, destina // is the record of what a tenant's backend was told, and the destination is // what identifies it. func (s *Service) DeleteDestination(ctx context.Context, actor Actor, projectID, destinationID string) error { - if actor.ID == "" { - return ErrUnauthenticated + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return err } return s.repository.DeleteDestination(ctx, projectID, destinationID, actor.ID, s.now()) } @@ -251,8 +270,8 @@ func (s *Service) RotateSecret(ctx context.Context, actor Actor, projectID, dest ctx, span := s.tracer.Start(ctx, "billing.webhook.secret.rotate") defer span.End() - if actor.ID == "" { - return DestinationWithSecret{}, ErrUnauthenticated + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return DestinationWithSecret{}, err } if err := s.requireEnabled(ctx, projectID); err != nil { return DestinationWithSecret{}, err @@ -296,15 +315,15 @@ func (s *Service) RotateSecret(ctx context.Context, actor Actor, projectID, dest // suspected compromise: the secret stops signing on the next delivery rather // than when its window would have lapsed. func (s *Service) RetireSecret(ctx context.Context, actor Actor, projectID, destinationID, secretID string) (SecretMetadata, error) { - if actor.ID == "" { - return SecretMetadata{}, ErrUnauthenticated + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return SecretMetadata{}, err } return s.repository.RetireSecret(ctx, projectID, destinationID, secretID, actor.ID, s.now()) } func (s *Service) ListSecrets(ctx context.Context, actor Actor, projectID, destinationID string) ([]SecretMetadata, error) { - if actor.ID == "" { - return nil, ErrUnauthenticated + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return nil, err } return s.repository.ListSecrets(ctx, projectID, destinationID) } @@ -314,22 +333,26 @@ func (s *Service) ListSecrets(ctx context.Context, actor Actor, projectID, desti // --------------------------------------------------------------------------- func (s *Service) ListDeliveries(ctx context.Context, actor Actor, projectID string, filter DeliveryFilter) ([]Delivery, error) { - if actor.ID == "" { - return nil, ErrUnauthenticated + if filter.EnvironmentID != "" { + if err := s.repository.AuthorizeEnvironment(ctx, actor, projectID, filter.EnvironmentID); err != nil { + return nil, err + } + } else if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return nil, err } return s.repository.ListDeliveries(ctx, projectID, filter.Bounded()) } func (s *Service) Delivery(ctx context.Context, actor Actor, projectID, deliveryID string) (Delivery, error) { - if actor.ID == "" { - return Delivery{}, ErrUnauthenticated + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return Delivery{}, err } return s.repository.Delivery(ctx, projectID, deliveryID) } func (s *Service) ListAttempts(ctx context.Context, actor Actor, projectID, deliveryID string) ([]Attempt, error) { - if actor.ID == "" { - return nil, ErrUnauthenticated + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return nil, err } return s.repository.ListAttempts(ctx, projectID, deliveryID) } @@ -340,8 +363,8 @@ func (s *Service) ListAttempts(ctx context.Context, actor Actor, projectID, deli // new logical event, so a receiver deduplicating on the event id sees the // change exactly once however many times an operator replays it. func (s *Service) ReplayDelivery(ctx context.Context, actor Actor, projectID, deliveryID string) (Delivery, error) { - if actor.ID == "" { - return Delivery{}, ErrUnauthenticated + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return Delivery{}, err } if err := s.requireEnabled(ctx, projectID); err != nil { return Delivery{}, err @@ -712,14 +735,24 @@ func (s *Service) newID(prefix string) (string, error) { // vocabulary Phase 9B emits. The contract declares ten event types; subscribing // to one Mosaic never emits would be a destination that is configured and // permanently silent. -func normalizeEventTypes(requested []string) ([]string, error) { +func normalizeEventTypes(contractVersion int, requested []string) ([]string, error) { + if contractVersion != 1 && contractVersion != 2 { + return nil, ErrInvalid + } if len(requested) == 0 { return []string{EventTypeEntitlementsChanged}, nil } seen := map[string]bool{} result := make([]string, 0, len(requested)) for _, eventType := range requested { - if eventType != EventTypeEntitlementsChanged { + valid := eventType == EventTypeEntitlementsChanged + if contractVersion == 2 { + valid = valid || eventType == EventTypeAuthorityCutoverPending || + eventType == EventTypeAuthorityCutoverCompleted || + eventType == EventTypeAuthorityRollbackCompleted || + eventType == EventTypeAuthorityStabilizationCompleted + } + if !valid { return nil, ErrInvalid } if seen[eventType] { diff --git a/apps/api/internal/billingwebhook/v2.go b/apps/api/internal/billingwebhook/v2.go new file mode 100644 index 00000000..529cb885 --- /dev/null +++ b/apps/api/internal/billingwebhook/v2.go @@ -0,0 +1,168 @@ +package billingwebhook + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "regexp" + "time" +) + +const maxContractInteger int64 = 999999999999 + +var ( + contractIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) + entitlementKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]*$`) + lowercaseHexDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) +) + +type eventAuthorityWire struct { + AuthorityEpoch int64 `json:"authorityEpoch"` + AuthorityKind string `json:"authorityKind"` + Scope AuthorityScope `json:"scope"` + TransitionState string `json:"transitionState"` + CutoverAt string `json:"cutoverAt,omitempty"` + SnapshotAuthorityDigest string `json:"snapshotAuthorityDigest"` +} + +type eventV2Wire struct { + EventID string `json:"eventId"` + EventType string `json:"eventType"` + BillingCustomerID string `json:"billingCustomerId"` + Authority eventAuthorityWire `json:"authority"` + SnapshotVersion int64 `json:"snapshotVersion"` + PreviousSnapshotVersion *int64 `json:"previousSnapshotVersion,omitempty"` + OccurredAt string `json:"occurredAt"` + CreatedAt string `json:"createdAt"` + CorrelationID string `json:"correlationId"` + ChangedEntitlements []ChangedEntitlement `json:"changedEntitlements"` +} + +type eventEnvelopeV2 struct { + ContractVersion string `json:"billingStateWebhookContractVersion"` + RecordType string `json:"recordType"` + Payload eventV2Wire `json:"payload"` +} + +func ContractTimestamp(value time.Time) string { + return value.UTC().Truncate(time.Millisecond).Format("2006-01-02T15:04:05.000Z") +} + +func validContractID(value string) bool { + return len(value) >= 1 && len(value) <= 128 && contractIDPattern.MatchString(value) +} + +func validContractTimestamp(value time.Time) bool { + if value.IsZero() || value.Year() < 0 || value.Year() > 9999 { + return false + } + return len(ContractTimestamp(value)) == 24 +} + +func validEntitlementState(value string, previous bool) bool { + return value == "active" || value == "inactive" || value == "unknown" || (previous && value == "absent") +} + +func validateEventV2(event EventV2) error { + if !validContractID(event.EventID) || !validContractID(event.BillingCustomerID) || !validContractID(event.CorrelationID) || + !validContractID(event.Authority.Scope.ProjectID) || !validContractID(event.Authority.Scope.EnvironmentID) || + !validContractID(event.Authority.Scope.ApplicationID) || + (event.Authority.Scope.Platform != "ios" && event.Authority.Scope.Platform != "android") || + event.Authority.AuthorityEpoch < 0 || event.Authority.AuthorityEpoch > maxContractInteger || + event.SnapshotVersion < 0 || event.SnapshotVersion > maxContractInteger || + !validContractTimestamp(event.OccurredAt) || !validContractTimestamp(event.CreatedAt) { + return ErrInvalid + } + if event.PreviousSnapshotVersion != nil && (*event.PreviousSnapshotVersion < 0 || *event.PreviousSnapshotVersion > maxContractInteger) { + return ErrInvalid + } + if len(event.ChangedEntitlements) > 200 { + return ErrInvalid + } + for _, changed := range event.ChangedEntitlements { + if len(changed.EntitlementKey) < 1 || len(changed.EntitlementKey) > 64 || !entitlementKeyPattern.MatchString(changed.EntitlementKey) || + !validEntitlementState(changed.PreviousState, true) || !validEntitlementState(changed.CurrentState, false) { + return ErrInvalid + } + } + wantKind, wantState := "", "" + switch event.EventType { + case EventTypeEntitlementsChanged: + if len(event.ChangedEntitlements) == 0 { + return ErrInvalid + } + case EventTypeAuthorityCutoverPending: + wantKind, wantState = "source", "cutover_pending" + case EventTypeAuthorityCutoverCompleted: + wantKind, wantState = "mosaic", "stabilizing" + case EventTypeAuthorityRollbackCompleted: + wantKind, wantState = "source_rollback", "rolled_back" + case EventTypeAuthorityStabilizationCompleted: + wantKind, wantState = "mosaic", "stable" + default: + return ErrInvalid + } + // Version zero is the deliberately narrow absent-rollback-baseline + // sentinel. It is not a general snapshot version and must retain the Mosaic + // snapshot it replaced so receivers can order the rollback notification. + if event.SnapshotVersion == 0 && (event.EventType != EventTypeAuthorityRollbackCompleted || + event.PreviousSnapshotVersion == nil || *event.PreviousSnapshotVersion < 1) { + return ErrInvalid + } + if wantKind != "" && (event.Authority.AuthorityKind != wantKind || event.Authority.TransitionState != wantState || len(event.ChangedEntitlements) != 0) { + return ErrInvalid + } + if event.Authority.AuthorityKind == "source" && event.Authority.CutoverAt != nil { + return ErrInvalid + } + if event.Authority.AuthorityKind != "source" && event.Authority.AuthorityKind != "mosaic" && event.Authority.AuthorityKind != "source_rollback" { + return ErrInvalid + } + if event.Authority.AuthorityKind != "source" && event.Authority.CutoverAt == nil { + return ErrInvalid + } + if event.Authority.CutoverAt != nil && !validContractTimestamp(*event.Authority.CutoverAt) { + return ErrInvalid + } + if !lowercaseHexDigestPattern.MatchString(event.Authority.SnapshotAuthorityDigest) { + return ErrInvalid + } + if _, err := hex.DecodeString(event.Authority.SnapshotAuthorityDigest[7:]); err != nil { + return ErrInvalid + } + return nil +} + +// CanonicalEventV2 renders once. Persistence stores the returned bytes and +// delivery signs and sends those same bytes without JSON re-serialization. +func CanonicalEventV2(event EventV2) ([]byte, []byte, error) { + if event.ChangedEntitlements == nil { + event.ChangedEntitlements = []ChangedEntitlement{} + } + if err := validateEventV2(event); err != nil { + return nil, nil, err + } + authority := eventAuthorityWire{AuthorityEpoch: event.Authority.AuthorityEpoch, + AuthorityKind: event.Authority.AuthorityKind, Scope: event.Authority.Scope, + TransitionState: event.Authority.TransitionState, + SnapshotAuthorityDigest: event.Authority.SnapshotAuthorityDigest} + if event.Authority.CutoverAt != nil { + authority.CutoverAt = ContractTimestamp(*event.Authority.CutoverAt) + } + body, err := json.Marshal(eventEnvelopeV2{ContractVersion: ContractVersionV2, + RecordType: "billingStateEvent", Payload: eventV2Wire{ + EventID: event.EventID, EventType: event.EventType, + BillingCustomerID: event.BillingCustomerID, Authority: authority, + SnapshotVersion: event.SnapshotVersion, + PreviousSnapshotVersion: event.PreviousSnapshotVersion, + OccurredAt: ContractTimestamp(event.OccurredAt), CreatedAt: ContractTimestamp(event.CreatedAt), + CorrelationID: event.CorrelationID, ChangedEntitlements: event.ChangedEntitlements}}) + if err != nil { + return nil, nil, fmt.Errorf("encode Billing State Webhook v2 event: %w", err) + } + digest := sha256.Sum256(body) + return body, digest[:], nil +} + +func FormatDigest(raw []byte) string { return "sha256:" + hex.EncodeToString(raw) } diff --git a/apps/api/internal/billingwebhook/v2_test.go b/apps/api/internal/billingwebhook/v2_test.go new file mode 100644 index 00000000..81bfe132 --- /dev/null +++ b/apps/api/internal/billingwebhook/v2_test.go @@ -0,0 +1,152 @@ +package billingwebhook + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +// This test protects the producer contract: the Go model must keep rendering +// the canonical protocol fixture's structure, and the persisted digest must be +// over the exact bytes that delivery later signs. +func TestCanonicalEventV2MatchesCutoverFixtureAndDigest(t *testing.T) { + _, source, _, _ := runtime.Caller(0) + fixturePath := filepath.Join(filepath.Dir(source), "..", "..", "..", "..", "protocol", "fixtures", "billing-state-webhook", "v2", "events", "cutover-completed.json") + fixture, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatal(err) + } + cutover := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + created := cutover.Add(time.Second) + previous := int64(41) + event := EventV2{EventID: "event-cutover-001", EventType: EventTypeAuthorityCutoverCompleted, + BillingCustomerID: "customer-001", SnapshotVersion: 42, PreviousSnapshotVersion: &previous, + OccurredAt: cutover, CreatedAt: created, CorrelationID: "correlation-cutover-001", + ChangedEntitlements: []ChangedEntitlement{}, Authority: EventAuthority{AuthorityEpoch: 5, + AuthorityKind: "mosaic", TransitionState: "stabilizing", CutoverAt: &cutover, + SnapshotAuthorityDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Scope: AuthorityScope{ProjectID: "project-001", EnvironmentID: "env-production", ApplicationID: "app-ios", Platform: "ios"}}} + body, digest, err := CanonicalEventV2(event) + if err != nil { + t.Fatal(err) + } + var got, want any + if err = json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + if err = json.Unmarshal(fixture, &want); err != nil { + t.Fatal(err) + } + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(want) + if !bytes.Equal(gotJSON, wantJSON) { + t.Fatalf("canonical event mismatch\n got %s\nwant %s", gotJSON, wantJSON) + } + sum := sha256.Sum256(body) + if !bytes.Equal(digest, sum[:]) { + t.Fatalf("digest %x does not cover stored bytes", digest) + } + bodyAgain, digestAgain, err := CanonicalEventV2(event) + if err != nil || !bytes.Equal(body, bodyAgain) || !bytes.Equal(digest, digestAgain) { + t.Fatalf("render is not deterministic: %v", err) + } +} + +func TestCanonicalEventV2RejectsAuthorityBindingMismatch(t *testing.T) { + now := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + _, _, err := CanonicalEventV2(EventV2{EventID: "event-1", EventType: EventTypeAuthorityRollbackCompleted, + BillingCustomerID: "customer-1", SnapshotVersion: 1, OccurredAt: now, CreatedAt: now, CorrelationID: "transition-1", + ChangedEntitlements: []ChangedEntitlement{}, Authority: EventAuthority{AuthorityEpoch: 2, + AuthorityKind: "mosaic", TransitionState: "rolled_back", CutoverAt: &now, + SnapshotAuthorityDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Scope: AuthorityScope{ProjectID: "project-1", EnvironmentID: "environment-1", ApplicationID: "app-1", Platform: "ios"}}}) + if err == nil { + t.Fatal("rollback event with Mosaic authority was accepted") + } +} + +// These cases protect the producer boundary from persisting bytes that the +// owned JSON Schema rejects. The database cannot repair an invalid canonical +// body after its digest has been committed. +func TestCanonicalEventV2RejectsSchemaInvariantViolations(t *testing.T) { + now := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + previous := int64(8) + valid := EventV2{EventID: "event-1", EventType: EventTypeEntitlementsChanged, + BillingCustomerID: "customer-1", SnapshotVersion: 9, PreviousSnapshotVersion: &previous, + OccurredAt: now, CreatedAt: now.Add(time.Second), CorrelationID: "correlation-1", + ChangedEntitlements: []ChangedEntitlement{{EntitlementKey: "premium_access", PreviousState: "inactive", CurrentState: "active"}}, + Authority: EventAuthority{AuthorityEpoch: 2, AuthorityKind: "mosaic", TransitionState: "stabilizing", CutoverAt: &now, + SnapshotAuthorityDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Scope: AuthorityScope{ProjectID: "project-1", EnvironmentID: "environment-1", ApplicationID: "application-1", Platform: "ios"}}} + + tests := []struct { + name string + mutate func(*EventV2) + }{ + {"invalid id", func(e *EventV2) { e.EventID = "bad id" }}, + {"invalid platform", func(e *EventV2) { e.Authority.Scope.Platform = "web" }}, + {"negative epoch", func(e *EventV2) { e.Authority.AuthorityEpoch = -1 }}, + {"epoch above schema maximum", func(e *EventV2) { e.Authority.AuthorityEpoch = maxContractInteger + 1 }}, + {"negative previous version", func(e *EventV2) { value := int64(-1); e.PreviousSnapshotVersion = &value }}, + {"snapshot above schema maximum", func(e *EventV2) { e.SnapshotVersion = maxContractInteger + 1 }}, + {"invalid entitlement key", func(e *EventV2) { e.ChangedEntitlements[0].EntitlementKey = "Premium Access" }}, + {"invalid previous state", func(e *EventV2) { e.ChangedEntitlements[0].PreviousState = "trial" }}, + {"absent current state", func(e *EventV2) { e.ChangedEntitlements[0].CurrentState = "absent" }}, + {"too many entitlement changes", func(e *EventV2) { + e.ChangedEntitlements = make([]ChangedEntitlement, 201) + for index := range e.ChangedEntitlements { + e.ChangedEntitlements[index] = ChangedEntitlement{EntitlementKey: "key", PreviousState: "absent", CurrentState: "inactive"} + } + }}, + {"invalid occurrence timestamp", func(e *EventV2) { e.OccurredAt = time.Time{} }}, + {"invalid cutover timestamp", func(e *EventV2) { + value := time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC) + e.Authority.CutoverAt = &value + }}, + {"uppercase digest", func(e *EventV2) { + e.Authority.SnapshotAuthorityDigest = "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + event := valid + event.ChangedEntitlements = append([]ChangedEntitlement(nil), valid.ChangedEntitlements...) + test.mutate(&event) + if _, _, err := CanonicalEventV2(event); err == nil { + t.Fatal("invalid event was accepted") + } + }) + } +} + +func TestCanonicalEventV2AbsentBaselineIsRollbackOnly(t *testing.T) { + now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC) + previous := int64(4) + event := EventV2{EventID: "event-rollback", EventType: EventTypeAuthorityRollbackCompleted, + BillingCustomerID: "customer-1", SnapshotVersion: 0, PreviousSnapshotVersion: &previous, + OccurredAt: now, CreatedAt: now, CorrelationID: "rollback-1", ChangedEntitlements: []ChangedEntitlement{}, + Authority: EventAuthority{AuthorityEpoch: 6, AuthorityKind: "source_rollback", TransitionState: "rolled_back", CutoverAt: &now, + SnapshotAuthorityDigest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + Scope: AuthorityScope{ProjectID: "project-1", EnvironmentID: "environment-1", ApplicationID: "application-1", Platform: "android"}}} + if _, _, err := CanonicalEventV2(event); err != nil { + t.Fatalf("valid absent rollback baseline rejected: %v", err) + } + event.EventType = EventTypeAuthorityCutoverCompleted + event.Authority.AuthorityKind = "mosaic" + event.Authority.TransitionState = "stabilizing" + if _, _, err := CanonicalEventV2(event); err == nil { + t.Fatal("zero-version non-rollback event was accepted") + } + event.EventType = EventTypeAuthorityRollbackCompleted + event.Authority.AuthorityKind = "source_rollback" + event.Authority.TransitionState = "rolled_back" + event.PreviousSnapshotVersion = nil + if _, _, err := CanonicalEventV2(event); err == nil { + t.Fatal("absent rollback baseline without replaced version was accepted") + } +} diff --git a/apps/api/internal/cloudworkspace/service_bootstrap.go b/apps/api/internal/cloudworkspace/service_bootstrap.go new file mode 100644 index 00000000..5078d420 --- /dev/null +++ b/apps/api/internal/cloudworkspace/service_bootstrap.go @@ -0,0 +1,72 @@ +package cloudworkspace + +import "context" + +// bootstrapProjectsPerOrganization bounds the snapshot. Entry only needs enough +// Projects to route into one and to populate a switcher; an operator with more +// than this reads the rest from the paginated Project list. +const bootstrapProjectsPerOrganization = 25 + +// BootstrapOrganization pairs an Organization the actor belongs to with the +// actor's role in it and that Organization's active Projects. +type BootstrapOrganization struct { + Organization Organization `json:"organization"` + Role Role `json:"role"` + Projects []Project `json:"projects"` + ProjectCount int `json:"projectCount"` + ProjectsTruncated bool `json:"projectsTruncated"` +} + +// WorkspaceBootstrap is the one-shot answer to "where does this actor land?". +// Resolving entry from the paginated Organization and Project lists cost one +// request per Organization and could not be decided without a round trip per +// candidate, so the destination was guessed before the data arrived. +type WorkspaceBootstrap struct { + Organizations []BootstrapOrganization `json:"organizations"` +} + +// Bootstrap returns every Organization the actor belongs to together with that +// Organization's active Projects, in a single read so entry resolves in one hop. +func (s *Service) Bootstrap(ctx context.Context, actor Actor) (WorkspaceBootstrap, error) { + if err := requireActor(actor); err != nil { + return WorkspaceBootstrap{}, err + } + var organizations []BootstrapOrganization + err := s.repository.View(ctx, func(reader Reader) error { + organizations = nil + for _, organization := range reader.OrganizationsForActor(actor.ID) { + membership, ok := reader.Membership(organization.ID, actor.ID) + if !ok { + // OrganizationsForActor already filters on membership, so a missing + // row means the two reads disagree. Skip rather than report a role + // the actor may not hold. + continue + } + entry := BootstrapOrganization{ + Organization: organization, + Role: membership.Role, + Projects: []Project{}, + } + for _, project := range reader.Projects(organization.ID) { + if project.Status != ProjectActive { + continue + } + entry.ProjectCount++ + if len(entry.Projects) >= bootstrapProjectsPerOrganization { + entry.ProjectsTruncated = true + continue + } + entry.Projects = append(entry.Projects, project) + } + organizations = append(organizations, entry) + } + return nil + }) + if err != nil { + return WorkspaceBootstrap{}, err + } + if organizations == nil { + organizations = []BootstrapOrganization{} + } + return WorkspaceBootstrap{Organizations: organizations}, nil +} diff --git a/apps/api/internal/cloudworkspace/service_bootstrap_test.go b/apps/api/internal/cloudworkspace/service_bootstrap_test.go new file mode 100644 index 00000000..5125ba3b --- /dev/null +++ b/apps/api/internal/cloudworkspace/service_bootstrap_test.go @@ -0,0 +1,129 @@ +package cloudworkspace_test + +import ( + "context" + "errors" + "testing" + + "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" +) + +func TestBootstrapScopesToMembershipAndActiveProjects(t *testing.T) { + service, _ := newService() + owner := cloudworkspace.Actor{ID: "actor-owner"} + member := cloudworkspace.Actor{ID: "actor-member"} + outsider := cloudworkspace.Actor{ID: "actor-outsider"} + ctx := context.Background() + + first, err := service.CreateOrganization(ctx, owner, "Northwind") + if err != nil { + t.Fatalf("create first organization: %v", err) + } + second, err := service.CreateOrganization(ctx, owner, "Contoso") + if err != nil { + t.Fatalf("create second organization: %v", err) + } + if _, err := service.AddMember(ctx, owner, second.ID, member.ID, cloudworkspace.RoleMember); err != nil { + t.Fatalf("add member: %v", err) + } + + mobile, err := service.CreateProject(ctx, owner, first.ID, "mobile", "Mobile") + if err != nil { + t.Fatalf("create project: %v", err) + } + retired, err := service.CreateProject(ctx, owner, first.ID, "retired", "Retired") + if err != nil { + t.Fatalf("create archived project: %v", err) + } + if _, err := service.ArchiveProject(ctx, owner, retired.ID); err != nil { + t.Fatalf("archive project: %v", err) + } + + bootstrap, err := service.Bootstrap(ctx, owner) + if err != nil { + t.Fatalf("bootstrap owner: %v", err) + } + if len(bootstrap.Organizations) != 2 { + t.Fatalf("expected both organizations, got %d", len(bootstrap.Organizations)) + } + if bootstrap.Organizations[0].Organization.ID != first.ID { + t.Fatalf("expected organizations ordered by id, got %q first", bootstrap.Organizations[0].Organization.ID) + } + if role := bootstrap.Organizations[0].Role; role != cloudworkspace.RoleOwner { + t.Fatalf("expected owner role, got %q", role) + } + if projects := bootstrap.Organizations[0].Projects; len(projects) != 1 || projects[0].ID != mobile.ID { + t.Fatalf("expected only the active project, got %+v", projects) + } + if count := bootstrap.Organizations[0].ProjectCount; count != 1 { + t.Fatalf("expected an archived project to be excluded from the count, got %d", count) + } + if bootstrap.Organizations[0].ProjectsTruncated { + t.Fatal("expected a single project not to be reported as truncated") + } + // An Organization with no Projects must still appear, otherwise entry cannot + // tell "you have no Organizations" from "your Organization has no Projects". + if projects := bootstrap.Organizations[1].Projects; len(projects) != 0 { + t.Fatalf("expected the second organization to carry no projects, got %+v", projects) + } + + // Membership, not visibility of the tenant, decides what the snapshot holds. + memberBootstrap, err := service.Bootstrap(ctx, member) + if err != nil { + t.Fatalf("bootstrap member: %v", err) + } + if len(memberBootstrap.Organizations) != 1 || memberBootstrap.Organizations[0].Organization.ID != second.ID { + t.Fatalf("expected only the joined organization, got %+v", memberBootstrap.Organizations) + } + if role := memberBootstrap.Organizations[0].Role; role != cloudworkspace.RoleMember { + t.Fatalf("expected member role, got %q", role) + } + + outsiderBootstrap, err := service.Bootstrap(ctx, outsider) + if err != nil { + t.Fatalf("bootstrap outsider: %v", err) + } + if len(outsiderBootstrap.Organizations) != 0 { + t.Fatalf("expected an empty snapshot for a non-member, got %+v", outsiderBootstrap.Organizations) + } +} + +func TestBootstrapRequiresAnActor(t *testing.T) { + service, _ := newService() + if _, err := service.Bootstrap(context.Background(), cloudworkspace.Actor{}); !errors.Is(err, cloudworkspace.ErrUnauthenticated) { + t.Fatalf("expected an unauthenticated error, got %v", err) + } +} + +func TestBootstrapTruncatesProjectsButReportsTheTotal(t *testing.T) { + service, _ := newService() + owner := cloudworkspace.Actor{ID: "actor-owner"} + ctx := context.Background() + + organization, err := service.CreateOrganization(ctx, owner, "Northwind") + if err != nil { + t.Fatalf("create organization: %v", err) + } + const total = 30 + for index := 0; index < total; index++ { + key := "project-" + string(rune('a'+index/26)) + string(rune('a'+index%26)) + if _, err := service.CreateProject(ctx, owner, organization.ID, key, key); err != nil { + t.Fatalf("create project %s: %v", key, err) + } + } + + bootstrap, err := service.Bootstrap(ctx, owner) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + entry := bootstrap.Organizations[0] + if len(entry.Projects) != 25 { + t.Fatalf("expected the snapshot to be bounded at 25 projects, got %d", len(entry.Projects)) + } + if entry.ProjectCount != total { + t.Fatalf("expected the full count to survive truncation, got %d", entry.ProjectCount) + } + if !entry.ProjectsTruncated { + t.Fatal("expected truncation to be reported so the client does not present a partial list as complete") + } +} diff --git a/apps/api/internal/platform/billingaccesspostgres/authority.go b/apps/api/internal/platform/billingaccesspostgres/authority.go new file mode 100644 index 00000000..2574dba0 --- /dev/null +++ b/apps/api/internal/platform/billingaccesspostgres/authority.go @@ -0,0 +1,257 @@ +package billingaccesspostgres + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" +) + +func (r *Repository) LegacyAuthority(ctx context.Context, scope billingaccess.AuthorityScope) (string, error) { + var authority string + err := r.pool.QueryRow(ctx, + `SELECT current_authority FROM billing_migration_authority_scopes + WHERE project_id=$1 AND environment_id=$2 AND application_id=$3 AND platform=$4`, + scope.ProjectID, scope.EnvironmentID, scope.ApplicationID, scope.Platform).Scan(&authority) + if errors.Is(err, pgx.ErrNoRows) { + return "", billingaccess.ErrNotFound + } + if err != nil { + return "", fmt.Errorf("read legacy serving authority: %w", err) + } + return authority, nil +} + +func (r *Repository) MinimumSupport(ctx context.Context, scope billingaccess.AuthorityScope) (billingaccess.MinimumSupport, error) { + var support billingaccess.MinimumSupport + err := r.pool.QueryRow(ctx, + `SELECT ps.program_id, ps.minimum_sdk_version, ps.minimum_app_version, ps.maximum_app_version, + ps.required_capabilities + FROM billing_migration_readiness_policy_scopes ps + JOIN billing_migration_readiness_policies p ON p.id=ps.policy_id AND p.program_id=ps.program_id + JOIN billing_migration_programs mp ON mp.id=ps.program_id AND mp.project_id=ps.project_id + WHERE ps.project_id=$1 AND mp.environment_id=$2 AND ps.application_id=$3 AND ps.platform=$4 + ORDER BY p.frozen_at DESC, p.id DESC LIMIT 1`, + scope.ProjectID, scope.EnvironmentID, scope.ApplicationID, scope.Platform). + Scan(&support.ProgramID, &support.MinimumSDKVersion, &support.MinimumAppVersion, &support.MaximumAppVersion, + &support.RequiredCapabilities) + if errors.Is(err, pgx.ErrNoRows) { + return billingaccess.MinimumSupport{}, billingaccess.ErrNotFound + } + if err != nil { + return billingaccess.MinimumSupport{}, fmt.Errorf("read authority minimum support: %w", err) + } + return support, nil +} + +func (r *Repository) AuthoritySelection(ctx context.Context, scope billingaccess.AuthorityScope, + customerID string, at time.Time) (billingaccess.AuthoritySelection, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return billingaccess.AuthoritySelection{}, fmt.Errorf("begin authority selection: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + selection := billingaccess.AuthoritySelection{Scope: scope} + var snapshotID, programState, transitionKind string + var stabilizationDays int + var transitionedAt, cutoverAt *time.Time + err = tx.QueryRow(ctx, + `SELECT a.active_program_id, a.current_epoch, a.current_authority, + p.state, p.stabilization_days, + ps.minimum_sdk_version, ps.minimum_app_version, ps.maximum_app_version, + ps.required_capabilities, cp.current_snapshot_id, + COALESCE(t.transition_kind,''), t.transitioned_at, c.transitioned_at + FROM billing_migration_authority_scopes a + JOIN billing_migration_programs p + ON p.id=a.active_program_id AND p.project_id=a.project_id AND p.environment_id=a.environment_id + JOIN billing_migration_readiness_policies rp + ON rp.program_id=p.id AND rp.project_id=p.project_id AND rp.policy_digest=p.policy_digest + JOIN billing_migration_readiness_policy_scopes ps + ON ps.policy_id=rp.id AND ps.program_id=p.id AND ps.application_id=a.application_id AND ps.platform=a.platform + JOIN billing_migration_scope_current_pointers cp + ON cp.project_id=a.project_id AND cp.environment_id=a.environment_id + AND cp.application_id=a.application_id AND cp.platform=a.platform + AND cp.billing_customer_id=$5 AND cp.authority_epoch=a.current_epoch + LEFT JOIN LATERAL ( + SELECT transition_kind, transitioned_at + FROM billing_migration_authority_transitions + WHERE authority_scope_id=a.id AND to_epoch=a.current_epoch + ORDER BY transitioned_at DESC, id DESC LIMIT 1 + ) t ON true + LEFT JOIN LATERAL ( + SELECT transitioned_at + FROM billing_migration_authority_transitions + WHERE authority_scope_id=a.id AND transition_kind='cutover' AND to_epoch<=a.current_epoch + ORDER BY to_epoch DESC, id DESC LIMIT 1 + ) c ON true + WHERE a.project_id=$1 AND a.environment_id=$2 AND a.application_id=$3 AND a.platform=$4`, + scope.ProjectID, scope.EnvironmentID, scope.ApplicationID, scope.Platform, customerID). + Scan(&selection.ProgramID, &selection.AuthorityEpoch, &selection.AuthorityKind, + &programState, &stabilizationDays, &selection.MinimumSupport.MinimumSDKVersion, + &selection.MinimumSupport.MinimumAppVersion, &selection.MinimumSupport.MaximumAppVersion, + &selection.MinimumSupport.RequiredCapabilities, &snapshotID, &transitionKind, &transitionedAt, &cutoverAt) + if errors.Is(err, pgx.ErrNoRows) { + return billingaccess.AuthoritySelection{}, billingaccess.ErrNotFound + } + if err != nil { + return billingaccess.AuthoritySelection{}, fmt.Errorf("read authority selection: %w", err) + } + + switch selection.AuthorityKind { + case "source": + selection.TransitionState = "stable" + if programState == "cutover_pending" { + selection.TransitionState = "cutover_pending" + } + case "mosaic": + if transitionKind != "cutover" || transitionedAt == nil || cutoverAt == nil { + return billingaccess.AuthoritySelection{}, billingaccess.ErrNotFound + } + cutover := cutoverAt.UTC() + selection.CutoverAt = &cutover + selection.TransitionState = "stable" + if at.Before(cutover.Add(time.Duration(stabilizationDays) * 24 * time.Hour)) { + selection.TransitionState = "stabilizing" + } + case "source_rollback": + if transitionKind != "rollback" || transitionedAt == nil || cutoverAt == nil { + return billingaccess.AuthoritySelection{}, billingaccess.ErrNotFound + } + cutover := cutoverAt.UTC() + selection.CutoverAt = &cutover + selection.TransitionState = "rolled_back" + default: + return billingaccess.AuthoritySelection{}, billingaccess.ErrNotFound + } + + selection.Snapshot, err = readSnapshotByID(ctx, tx, scope, customerID, snapshotID) + if err != nil { + return billingaccess.AuthoritySelection{}, err + } + if err := tx.Commit(ctx); err != nil { + return billingaccess.AuthoritySelection{}, fmt.Errorf("commit authority selection read: %w", err) + } + return selection, nil +} + +func readSnapshotByID(ctx context.Context, tx pgx.Tx, scope billingaccess.AuthorityScope, + customerID, snapshotID string) (billingaccess.SnapshotView, error) { + var view billingaccess.SnapshotView + var previousID *string + err := tx.QueryRow(ctx, + `SELECT id,project_id,environment_id,billing_customer_id,snapshot_version,rule_version, + computed_at,as_of,previous_snapshot_id,checksum,change_reason + FROM customer_entitlement_snapshots + WHERE id=$1 AND project_id=$2 AND environment_id=$3 AND billing_customer_id=$4`, + snapshotID, scope.ProjectID, scope.EnvironmentID, customerID). + Scan(&view.SnapshotID, &view.ProjectID, &view.EnvironmentID, &view.CustomerID, + &view.SnapshotVersion, &view.RuleVersion, &view.ComputedAt, &view.AsOf, + &previousID, &view.Checksum, &view.ChangeReason) + if errors.Is(err, pgx.ErrNoRows) { + return billingaccess.SnapshotView{}, billingaccess.ErrNotFound + } + if err != nil { + return billingaccess.SnapshotView{}, fmt.Errorf("read scoped entitlement snapshot: %w", err) + } + if previousID != nil { + _ = tx.QueryRow(ctx, `SELECT snapshot_version FROM customer_entitlement_snapshots WHERE id=$1`, *previousID). + Scan(&view.PreviousSnapshotVersion) + } + + rows, err := tx.Query(ctx, + `SELECT e.id,e.entitlement_id,e.purchase_lineage_id,e.product_id,e.grant_version_id, + COALESCE(e.subscription_instance_id,''),COALESCE(e.one_time_purchase_instance_id,''), + COALESCE(e.source_snapshot_id,''),COALESCE(l.provider,''),e.source_type,e.source_state, + e.source_start,e.source_end,e.end_known,e.uncertainty_reason,e.is_test_source,e.explanation_code + FROM entitlement_sources e LEFT JOIN purchase_lineages l ON l.id=e.purchase_lineage_id + WHERE e.customer_entitlement_snapshot_id=$1 ORDER BY e.id`, snapshotID) + if err != nil { + return billingaccess.SnapshotView{}, fmt.Errorf("read scoped entitlement sources: %w", err) + } + byEntitlement := map[string][]string{} + for rows.Next() { + var source billingaccess.SnapshotSource + if err := rows.Scan(&source.RowID, &source.EntitlementID, &source.PurchaseLineageID, &source.ProductID, + &source.GrantVersionID, &source.SubscriptionInstanceID, &source.OneTimePurchaseInstanceID, + &source.SourceSnapshotID, &source.StorePlatform, &source.SourceType, &source.SourceState, + &source.SourceStart, &source.SourceEnd, &source.EndKnown, &source.UncertaintyReason, + &source.IsTestSource, &source.ExplanationCode); err != nil { + rows.Close() + return view, fmt.Errorf("scan scoped source: %w", err) + } + byEntitlement[source.EntitlementID] = append(byEntitlement[source.EntitlementID], source.RowID) + view.Sources = append(view.Sources, source) + } + if err := rows.Err(); err != nil { + rows.Close() + return view, err + } + rows.Close() + + entries, err := tx.Query(ctx, + `SELECT entitlement_id,entitlement_key,state,effective_start,effective_end,end_known, + source_count,uncertainty_reason,is_test_source,explanation_code + FROM customer_entitlement_snapshot_entries WHERE customer_entitlement_snapshot_id=$1 ORDER BY entitlement_key`, snapshotID) + if err != nil { + return view, fmt.Errorf("read scoped entitlement entries: %w", err) + } + defer entries.Close() + for entries.Next() { + var entry billingaccess.SnapshotEntry + if err := entries.Scan(&entry.EntitlementID, &entry.EntitlementKey, &entry.State, &entry.EffectiveStart, + &entry.EffectiveEnd, &entry.EndKnown, &entry.SourceCount, &entry.UncertaintyReason, + &entry.IsTestSource, &entry.ExplanationCode); err != nil { + return view, fmt.Errorf("scan scoped entry: %w", err) + } + entry.SourceIDs = byEntitlement[entry.EntitlementID] + view.Entries = append(view.Entries, entry) + } + view.Projection = billingaccess.ProjectionStatus{State: billingaccess.ProjectionCurrent, LastProjectedAt: view.ComputedAt} + return view, entries.Err() +} + +func (r *Repository) ObservedSnapshotDigest(ctx context.Context, selection billingaccess.AuthoritySelection, digest []byte) (bool, error) { + var found bool + err := r.pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM billing_migration_v2_sync_observations + WHERE program_id=$1 AND project_id=$2 AND application_id=$3 AND platform=$4 + AND authority_epoch=$5 AND sync_result='accepted' AND observation_digest=$6)`, + selection.ProgramID, selection.Scope.ProjectID, selection.Scope.ApplicationID, + selection.Scope.Platform, selection.AuthorityEpoch, digest).Scan(&found) + if err != nil { + return false, fmt.Errorf("verify observed snapshot digest: %w", err) + } + return found, nil +} + +func (r *Repository) AppendSyncObservation(ctx context.Context, observation billingaccess.SyncObservation) error { + if observation.ProgramID == "" { + return billingaccess.ErrNotFound + } + randomID := make([]byte, 16) + if _, err := rand.Read(randomID); err != nil { + return fmt.Errorf("generate v2 sync observation id: %w", err) + } + id := "bmo_" + hex.EncodeToString(randomID) + _, err := r.pool.Exec(ctx, + `INSERT INTO billing_migration_v2_sync_observations( + id,program_id,project_id,application_id,platform,app_version,sdk_version, + supported_contract_versions,authority_capabilities,traffic_count,authority_epoch, + sync_result,observation_digest,observed_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,1,$10,$11,$12,$13)`, + id, observation.ProgramID, observation.Scope.ProjectID, observation.Scope.ApplicationID, + observation.Scope.Platform, observation.AppVersion, observation.SDKVersion, + observation.SupportedContractVersions, observation.Capabilities, observation.AuthorityEpoch, + observation.Result, observation.Digest, observation.ObservedAt) + if err != nil { + return fmt.Errorf("append v2 sync observation: %w", err) + } + return nil +} diff --git a/apps/api/internal/platform/billingaccesspostgres/authority_integration_test.go b/apps/api/internal/platform/billingaccesspostgres/authority_integration_test.go new file mode 100644 index 00000000..f69c8608 --- /dev/null +++ b/apps/api/internal/platform/billingaccesspostgres/authority_integration_test.go @@ -0,0 +1,171 @@ +package billingaccesspostgres + +import ( + "context" + "database/sql" + "errors" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +func authorityTestPool(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + url := os.Getenv("DATABASE_TEST_URL") + if url == "" { + t.Skip("DATABASE_TEST_URL is required") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + t.Cleanup(cancel) + db, err := sql.Open("pgx", url) + if err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err := goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err := goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + _ = db.Close() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + return pool, ctx +} + +// Exact Application/platform and customer selection is the security boundary: +// neither another Application's pointer nor the legacy global pointer may be +// used when the requested scope is missing or has a stale epoch. +func TestAuthoritySelectionIsExactAndEpochBound(t *testing.T) { + pool, ctx := authorityTestPool(t) + lockConnection, err := pool.Acquire(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := lockConnection.Exec(ctx, `SELECT pg_advisory_lock(hashtextextended('billingaccess-authority-integration',0))`); err != nil { + lockConnection.Release() + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = lockConnection.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtextextended('billingaccess-authority-integration',0))`) + lockConnection.Release() + }) + now := time.Now().UTC() + suffix := "_" + strconv.FormatInt(now.UnixNano(), 36) + name := func(value string) string { return value + suffix } + replacer := strings.NewReplacer( + "app_auth_ios", name("app_auth_ios"), "app_auth_android", name("app_auth_android"), + "brps_auth_ios", name("brps_auth_ios"), "brps_auth_android", name("brps_auth_android"), + "bmc_auth", name("bmc_auth"), "bmp_auth", name("bmp_auth"), "brp_auth", name("brp_auth"), + ) + z := make([]byte, 32) + z[0] = 1 + org, project, environment, customer := name("org_auth_sel"), name("proj_auth_sel"), name("env_auth_sel"), name("bcu_auth_sel") + cleanup := func() { + for _, table := range []string{"billing_migration_v2_sync_observations", "billing_migration_authority_transitions", "billing_migration_readiness_policy_scopes", "billing_migration_readiness_policies", "customer_entitlement_snapshots"} { + _, _ = pool.Exec(context.Background(), `ALTER TABLE `+table+` DISABLE TRIGGER USER`) + } + for _, query := range []string{ + `DELETE FROM billing_migration_v2_sync_observations WHERE project_id=$1`, + `DELETE FROM billing_migration_scope_current_pointers WHERE project_id=$1`, + `DELETE FROM billing_migration_authority_transitions WHERE project_id=$1`, + `DELETE FROM billing_migration_authority_scopes WHERE project_id=$1`, + `DELETE FROM billing_migration_readiness_policy_scopes WHERE project_id=$1`, + `DELETE FROM billing_migration_readiness_policies WHERE project_id=$1`, + `DELETE FROM billing_migration_program_scopes WHERE project_id=$1`, + `DELETE FROM billing_migration_programs WHERE project_id=$1`, + `DELETE FROM billing_migration_credentials WHERE project_id=$1`, + `DELETE FROM customer_entitlement_pointers WHERE project_id=$1`, + `DELETE FROM customer_entitlement_snapshots WHERE project_id=$1`, + `DELETE FROM billing_customers WHERE project_id=$1`, + `DELETE FROM applications WHERE project_id=$1`, + `DELETE FROM environments WHERE project_id=$1`, + `DELETE FROM projects WHERE id=$1`, + } { + _, _ = pool.Exec(context.Background(), query, project) + } + _, _ = pool.Exec(context.Background(), `DELETE FROM organizations WHERE id=$1`, org) + for _, table := range []string{"billing_migration_v2_sync_observations", "billing_migration_authority_transitions", "billing_migration_readiness_policy_scopes", "billing_migration_readiness_policies", "customer_entitlement_snapshots"} { + _, _ = pool.Exec(context.Background(), `ALTER TABLE `+table+` ENABLE TRIGGER USER`) + } + } + cleanup() + t.Cleanup(cleanup) + statements := []struct { + q string + a []any + }{ + {`INSERT INTO organizations(id,name,created_at,updated_at) VALUES($1,'Authority',$2,$2)`, []any{org, now}}, + {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) VALUES($1,$2,$1,'Authority','active',$3,$3)`, []any{project, org, now}}, + {`INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) VALUES($1,$2,'production','Production','production',$3,$3)`, []any{environment, project, now}}, + {`INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) VALUES + ('app_auth_ios',$1,'iOS','ios','dev.auth.ios',$2,$2),('app_auth_android',$1,'Android','android','dev.auth.android',$2,$2)`, []any{project, now}}, + {`INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) VALUES($1,$2,'active','none',$3,$3)`, []any{customer, project, now}}, + {`INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) + VALUES('bmc_auth',$1,'revenuecat','rc','active',1,'AES-256-GCM','key',decode(repeat('00',12),'hex'),decode(repeat('00',16),'hex'),$2,'actor',$3)`, []any{project, z, now}}, + {`INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) + VALUES('bmp_auth',$1,$2,'revenuecat','1','bmc_auth','stabilizing',1,4,7,7,$3,$3,'idem',$3,'actor',$4,$4)`, []any{project, environment, z, now}}, + {`INSERT INTO billing_migration_program_scopes(program_id,project_id,environment_id,application_id,platform,created_at) VALUES + ('bmp_auth',$1,$2,'app_auth_ios','ios',$3),('bmp_auth',$1,$2,'app_auth_android','android',$3)`, []any{project, environment, now}}, + {`INSERT INTO billing_migration_readiness_policies(id,program_id,project_id,state_version,watermark_max_age_seconds,supported_version_window_start,application_version_digest,policy_digest,frozen_at) + VALUES('brp_auth','bmp_auth',$1,1,3600,$2,$3,$3,$2)`, []any{project, now, z}}, + {`INSERT INTO billing_migration_readiness_policy_scopes(id,policy_id,program_id,project_id,application_id,platform,minimum_app_version,maximum_app_version,traffic_window_started_at,traffic_window_ended_at,outside_window_accepted,minimum_sdk_version,required_capabilities,serving_requirements_digest) VALUES + ('brps_auth_ios','brp_auth','bmp_auth',$1,'app_auth_ios','ios','4.0.0','5.9.9',$2::timestamptz-interval '1 hour',$2::timestamptz+interval '1 hour',false,'2.0.0',ARRAY['authority_epoch','authority_scope'],$3), + ('brps_auth_android','brp_auth','bmp_auth',$1,'app_auth_android','android','4.0.0','5.9.9',$2::timestamptz-interval '1 hour',$2::timestamptz+interval '1 hour',false,'2.0.0',ARRAY['authority_epoch','authority_scope'],$3)`, []any{project, now, z}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, replacer.Replace(statement.q), statement.a...); err != nil { + t.Fatal(err) + } + } + + for index, id := range []string{"ios", "android"} { + if _, err := pool.Exec(ctx, `INSERT INTO customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id,snapshot_version,rule_version,computed_at,as_of,checksum,change_reason,created_at) + VALUES($1,$2,$3,$4,$5,1,$6,$6,$7,'initial_projection',$6)`, name("ces_auth_"+id), project, environment, customer, index+1, now, z); err != nil { + t.Fatal(err) + } + } + cutover := now.Add(-time.Hour) + for _, row := range []struct { + id, app, platform, snapshot string + epoch int64 + }{{name("mas_auth_ios"), name("app_auth_ios"), "ios", name("ces_auth_ios"), 5}, {name("mas_auth_android"), name("app_auth_android"), "android", name("ces_auth_android"), 8}} { + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_authority_scopes(id,project_id,environment_id,application_id,platform,current_authority,current_epoch,active_program_id,authority_digest,updated_at) VALUES($1,$2,$3,$4,$5,'mosaic',$6,$7,$8,$9)`, row.id, project, environment, row.app, row.platform, row.epoch, name("bmp_auth"), z, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_authority_transitions(id,program_id,project_id,authority_scope_id,from_authority,to_authority,from_epoch,to_epoch,transition_kind,transition_digest,transitioned_at) VALUES($1,$2,$3,$4,'source','mosaic',$5,$6,'cutover',$7,$8)`, name("bat_"+row.platform), name("bmp_auth"), project, row.id, row.epoch-1, row.epoch, z, cutover); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_scope_current_pointers(project_id,environment_id,application_id,platform,billing_customer_id,current_snapshot_id,authority_epoch,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, project, environment, row.app, row.platform, customer, row.snapshot, row.epoch, now); err != nil { + t.Fatal(err) + } + } + repository := New(pool) + iosScope := billingaccess.AuthorityScope{ProjectID: project, EnvironmentID: environment, ApplicationID: name("app_auth_ios"), Platform: "ios"} + selection, err := repository.AuthoritySelection(ctx, iosScope, customer, now) + if err != nil { + t.Fatal(err) + } + if selection.AuthorityEpoch != 5 || selection.Snapshot.SnapshotID != name("ces_auth_ios") { + t.Fatalf("cross-scope selection: %+v", selection) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_scope_current_pointers SET authority_epoch=4 WHERE project_id=$1 AND application_id=$2`, project, name("app_auth_ios")); err != nil { + t.Fatal(err) + } + if _, err := repository.AuthoritySelection(ctx, iosScope, customer, now); !errors.Is(err, billingaccess.ErrNotFound) { + t.Fatalf("epoch mismatch returned %v, want unavailable", err) + } +} diff --git a/apps/api/internal/platform/billingaccesspostgres/keyauth.go b/apps/api/internal/platform/billingaccesspostgres/keyauth.go index 87844952..f3863b79 100644 --- a/apps/api/internal/platform/billingaccesspostgres/keyauth.go +++ b/apps/api/internal/platform/billingaccesspostgres/keyauth.go @@ -39,6 +39,7 @@ func (k KeyAuthenticator) AuthenticateServerKey(ctx context.Context, raw string) EnvironmentID: scope.EnvironmentID, EnvironmentMode: scope.EnvironmentMode, ApplicationID: scope.ApplicationID, + Platform: scope.Platform, }, nil } @@ -57,5 +58,6 @@ func (k KeyAuthenticator) AuthenticateSDKKey(ctx context.Context, raw string) (b EnvironmentID: scope.EnvironmentID, EnvironmentMode: scope.EnvironmentMode, ApplicationID: scope.ApplicationID, + Platform: scope.Platform, }, nil } diff --git a/apps/api/internal/platform/billingaccesspostgres/repository.go b/apps/api/internal/platform/billingaccesspostgres/repository.go index 1ff109e0..4c4c8424 100644 --- a/apps/api/internal/platform/billingaccesspostgres/repository.go +++ b/apps/api/internal/platform/billingaccesspostgres/repository.go @@ -20,15 +20,30 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" ) type Repository struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + migrationSignals *billingmigrationpostgres.Repository } -func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } +func New(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool, migrationSignals: billingmigrationpostgres.New(pool)} +} var _ billingaccess.Repository = (*Repository)(nil) +var _ billingaccess.AccessAPISignalRecorder = (*Repository)(nil) + +// RecordAccessAPIResult forwards only PII-free scope, timing, and outcome to +// the Phase 9C immutable evidence repository. It intentionally has no customer, +// entitlement, credential, or request-payload parameter. +func (r *Repository) RecordAccessAPIResult(ctx context.Context, projectID, environmentID string, startedAt, endedAt time.Time, failed bool) error { + if r == nil || r.migrationSignals == nil { + return errors.New("billing migration access signal repository is unavailable") + } + return r.migrationSignals.RecordTrustedAccessAPIResult(ctx, projectID, environmentID, endedAt.Sub(startedAt), failed) +} func (r *Repository) BillingEnabled(ctx context.Context, projectID string) (bool, error) { var enabled bool diff --git a/apps/api/internal/platform/billingmigrationevaluation/builder.go b/apps/api/internal/platform/billingmigrationevaluation/builder.go new file mode 100644 index 00000000..c549de38 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationevaluation/builder.go @@ -0,0 +1,427 @@ +// Package billingmigrationevaluation builds immutable Phase 9C entitlement +// candidates from the ordinary validated-fact pipeline. It has no port capable +// of changing live entitlement or authority pointers. +package billingmigrationevaluation + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sort" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingprojectionpostgres" +) + +type Builder struct { + pool *pgxpool.Pool + projection *billingprojectionpostgres.Repository +} + +func New(pool *pgxpool.Pool) *Builder { + return &Builder{pool: pool, projection: billingprojectionpostgres.New(pool)} +} + +var _ billingmigration.PreparedSnapshotBuilder = (*Builder)(nil) +var _ billingmigration.FinalDeltaBuilder = (*Builder)(nil) + +type frozenProgram struct { + environmentID, manifestID, mappingID string + programState string + stateVersion, authorityEpoch int64 + manifestDigest, mappingDigest []byte + policyDigest []byte + evidenceDigest []byte + sourceWatermark, capturedAt time.Time + scopes []evaluationScope +} + +type evaluationScope struct{ applicationID, platform string } + +type cohortItem struct { + scope evaluationScope + customerID string + sourceDigests [][]byte + sourceCurrentAccess bool +} + +type candidate struct { + cohortItem + snapshot billingprojection.CustomerSnapshot + snapshotID string + sourceDigest []byte + candidateDigest []byte + comparisonDigest []byte + mosaicCurrentAccess bool +} + +func (b *Builder) Evaluate(ctx context.Context, lease billingmigration.ExecutionLease) (*billingmigration.RunExecutionResult, []billingmigration.PreparedPointer, []byte, error) { + if lease.JobKind != "dry_run" && lease.JobKind != "shadow" { + return nil, nil, nil, billingmigration.ErrInvalid + } + result, candidates, digest, frozen, err := b.build(ctx, lease) + if err != nil { + return nil, nil, nil, err + } + pointers := make([]billingmigration.PreparedPointer, 0, len(candidates)) + if lease.JobKind == "shadow" { + for _, item := range candidates { + pointers = append(pointers, billingmigration.PreparedPointer{EnvironmentID: frozen.environmentID, ApplicationID: item.scope.applicationID, Platform: item.scope.platform, BillingCustomerID: item.customerID, SnapshotID: item.snapshotID, PreparedDigest: item.candidateDigest}) + result.ShadowSnapshots = append(result.ShadowSnapshots, billingmigration.ShadowSnapshotWrite{ + ID: stableID("mss", lease.ProgramID, lease.JobID, item.scope.applicationID, item.scope.platform, item.customerID), + EnvironmentID: frozen.environmentID, ApplicationID: item.scope.applicationID, Platform: item.scope.platform, + BillingCustomerID: item.customerID, SourceSnapshotID: "rcs_" + hex.EncodeToString(item.sourceDigest[:12]), MosaicSnapshotID: item.snapshotID, ShadowDigest: item.comparisonDigest, + }) + } + } + return result, pointers, digest, nil +} + +func (b *Builder) BuildFinalDelta(ctx context.Context, lease billingmigration.ExecutionLease) (*billingmigration.FinalDeltaResult, []billingmigration.PreparedPointer, []byte, error) { + if lease.JobKind != "final_delta" { + return nil, nil, nil, billingmigration.ErrInvalid + } + run, candidates, digest, frozen, err := b.build(ctx, lease) + if err != nil { + return nil, nil, nil, err + } + pointers := make([]billingmigration.PreparedPointer, 0, len(candidates)) + customerCandidates := map[string][][]byte{} + for _, item := range candidates { + pointers = append(pointers, billingmigration.PreparedPointer{EnvironmentID: frozen.environmentID, ApplicationID: item.scope.applicationID, Platform: item.scope.platform, BillingCustomerID: item.customerID, SnapshotID: item.snapshotID, PreparedDigest: item.candidateDigest}) + customerCandidates[item.customerID] = append(customerCandidates[item.customerID], item.candidateDigest) + } + ids := make([]string, 0, len(customerCandidates)) + for id := range customerCandidates { + ids = append(ids, id) + } + sort.Strings(ids) + cohort := make([]billingmigration.CohortCustomer, 0, len(ids)) + cohortParts := [][]byte{} + for _, id := range ids { + customerDigest := hash("mosaic-migration-final-customer-v1", []byte(id), hashSorted("mosaic-migration-final-customer-scopes-v1", customerCandidates[id])) + cohort = append(cohort, billingmigration.CohortCustomer{BillingCustomerID: id, CustomerDigest: customerDigest}) + cohortParts = append(cohortParts, []byte(id), customerDigest) + } + cohortDigest := hash("mosaic-migration-final-cohort-v1", cohortParts...) + sourceTime, _ := time.Parse(time.RFC3339Nano, run.SourceWatermark) + providerTime, _ := time.Parse(time.RFC3339Nano, run.ProviderWatermark) + shadowTime, _ := time.Parse(time.RFC3339Nano, run.ShadowWatermark) + watermarkDigest := hash("mosaic-migration-final-watermarks-v1", []byte(sourceTime.Format(time.RFC3339Nano)), []byte(providerTime.Format(time.RFC3339Nano)), []byte(shadowTime.Format(time.RFC3339Nano))) + deltaDigest := hash("mosaic-migration-final-delta-v1", digest, cohortDigest, watermarkDigest) + result := &billingmigration.FinalDeltaResult{ID: stableID("mfd", lease.ProgramID, lease.JobID, hex.EncodeToString(deltaDigest)), StateVersion: lease.ExpectedStateVersion, ManifestDigest: lease.ManifestDigest, MappingDigest: lease.MappingDigest, EvidenceDigest: lease.EvidenceDigest, FinalWatermarkDigest: watermarkDigest, DeltaDigest: deltaDigest, CohortDigest: cohortDigest, SourceWatermark: sourceTime, ProviderWatermark: providerTime, ShadowWatermark: shadowTime, Cohort: cohort} + return result, pointers, deltaDigest, nil +} + +func (b *Builder) build(ctx context.Context, lease billingmigration.ExecutionLease) (*billingmigration.RunExecutionResult, []candidate, []byte, frozenProgram, error) { + if b == nil || b.pool == nil || lease.ProjectID == "" || lease.ProgramID == "" || lease.JobID == "" || lease.ExpectedStateVersion < 1 || len(lease.ManifestDigest) != sha256.Size || len(lease.MappingDigest) != sha256.Size || (lease.JobKind != "final_delta" && len(lease.PolicyDigest) != sha256.Size) || (lease.JobKind == "final_delta" && len(lease.EvidenceDigest) != sha256.Size) { + return nil, nil, nil, frozenProgram{}, billingmigration.ErrInvalid + } + frozen, err := b.loadFrozen(ctx, lease) + if err != nil { + return nil, nil, nil, frozenProgram{}, err + } + cohort, divergences, providerWatermark, evidenceDigest, allowedFacts, err := b.loadEvidence(ctx, lease, frozen) + if err != nil { + return nil, nil, nil, frozenProgram{}, err + } + frozen.evidenceDigest = evidenceDigest + if lease.JobKind == "final_delta" && !bytes.Equal(lease.EvidenceDigest, evidenceDigest) { + return nil, nil, nil, frozenProgram{}, billingmigration.ErrStaleDigest + } + if lease.JobKind == "final_delta" { + cohort = expandFinalCohort(cohort, frozen.scopes) + } + asOf := frozen.sourceWatermark + if providerWatermark.After(asOf) { + asOf = providerWatermark + } + if asOf.IsZero() { + return nil, nil, nil, frozenProgram{}, billingmigration.ErrConflict + } + candidates := make([]candidate, 0, len(cohort)) + for _, item := range cohort { + input, loadErr := b.projection.LoadInput(ctx, billingprojection.Scope{ProjectID: lease.ProjectID, EnvironmentID: frozen.environmentID, CustomerID: item.customerID}) + if loadErr != nil { + return nil, nil, nil, frozenProgram{}, loadErr + } + input = prepareProjectionInput(input, allowedFacts[item.scope.applicationID]) + if divergence := missingCustomerFactDivergence(lease, item, input, asOf); divergence != nil { + divergences = append(divergences, *divergence) + } + output := billingprojection.Compute(input, asOf) + if output.CustomerSnapshot == nil && !item.sourceCurrentAccess && len(input.Lineages) == 0 { + empty := billingprojection.ProjectEntitlements(billingprojection.CustomerProjection{}, asOf) + output.CustomerSnapshot = &empty + } + if output.CustomerSnapshot == nil { + return nil, nil, nil, frozenProgram{}, fmt.Errorf("candidate projection produced no snapshot: %w", billingmigration.ErrConflict) + } + mosaicActive := false + for _, entry := range output.CustomerSnapshot.Entries { + if entry.State == billingprojection.AccessActive { + mosaicActive = true + break + } + } + sourceDigest := hashSorted("mosaic-migration-source-current-access-v1", item.sourceDigests) + candidateDigest := hash("mosaic-migration-candidate-v1", []byte(item.scope.applicationID), []byte(item.scope.platform), []byte(item.customerID), sourceDigest, output.CustomerSnapshot.Checksum) + comparison := hash("mosaic-migration-comparison-v1", sourceDigest, candidateDigest, []byte(fmt.Sprint(item.sourceCurrentAccess)), []byte(fmt.Sprint(mosaicActive))) + c := candidate{cohortItem: item, snapshot: *output.CustomerSnapshot, sourceDigest: sourceDigest, candidateDigest: candidateDigest, comparisonDigest: comparison, mosaicCurrentAccess: mosaicActive} + if divergence := accessComparisonDivergence(lease, item.sourceCurrentAccess, mosaicActive, comparison, asOf); divergence != nil { + divergences = append(divergences, *divergence) + } + candidates = append(candidates, c) + } + sort.Slice(candidates, func(i, j int) bool { + a, b := candidates[i], candidates[j] + if a.scope.applicationID != b.scope.applicationID { + return a.scope.applicationID < b.scope.applicationID + } + if a.scope.platform != b.scope.platform { + return a.scope.platform < b.scope.platform + } + return a.customerID < b.customerID + }) + evalParts := [][]byte{lease.ManifestDigest, lease.MappingDigest, frozen.policyDigest, evidenceDigest} + for _, c := range candidates { + evalParts = append(evalParts, c.candidateDigest, c.comparisonDigest) + } + for _, d := range divergences { + evalParts = append(evalParts, d.EvidenceDigest) + } + evaluationDigest := hash("mosaic-migration-candidate-evaluation-v1", evalParts...) + evaluationID := stableID("mce", lease.ProgramID, lease.JobID) + if err = b.persist(ctx, lease, frozen, evaluationID, evaluationDigest, providerWatermark, asOf, candidates); err != nil { + return nil, nil, nil, frozenProgram{}, err + } + for i := range candidates { + candidates[i].snapshotID = stableID("cesm", evaluationID, candidates[i].scope.applicationID, candidates[i].scope.platform, candidates[i].customerID) + } + result := &billingmigration.RunExecutionResult{SourceWatermark: frozen.sourceWatermark.Format(time.RFC3339Nano), ProviderWatermark: providerWatermark.Format(time.RFC3339Nano), ShadowWatermark: asOf.Format(time.RFC3339Nano), Divergences: divergences} + return result, candidates, evaluationDigest, frozen, nil +} + +func accessComparisonDivergence(lease billingmigration.ExecutionLease, sourceActive, mosaicActive bool, evidence []byte, at time.Time) *billingmigration.DivergenceWrite { + if sourceActive == mosaicActive { + return nil + } + reason := "source_grants_mosaic_denies" + if mosaicActive { + reason = "mosaic_grants_source_denies" + } + result := newDivergence(lease, "critical", reason, evidence, at) + return &result +} + +func missingCustomerFactDivergence(lease billingmigration.ExecutionLease, item cohortItem, input billingprojection.Input, at time.Time) *billingmigration.DivergenceWrite { + if !item.sourceCurrentAccess { + return nil + } + for _, lineage := range input.Lineages { + if len(lineage.Facts) > 0 { + return nil + } + } + d := hash("mosaic-migration-current-access-without-fact-v1", []byte(item.scope.applicationID), []byte(item.scope.platform), []byte(item.customerID)) + result := newDivergence(lease, "blocking", "provider_validation_missing", d, at) + return &result +} + +// expandFinalCohort guarantees the checkpoint has one prepared pointer for +// every final-cohort customer in every exact program scope. Source evidence is +// retained only on its originating scope; synthetic scope rows start with no +// source access, preventing facts from one application leaking into another. +func expandFinalCohort(cohort []cohortItem, scopes []evaluationScope) []cohortItem { + type key struct{ app, platform, customer string } + items := make(map[key]cohortItem, len(cohort)) + customers := map[string]bool{} + for _, item := range cohort { + items[key{item.scope.applicationID, item.scope.platform, item.customerID}] = item + customers[item.customerID] = true + } + for customer := range customers { + for _, scope := range scopes { + k := key{scope.applicationID, scope.platform, customer} + if _, exists := items[k]; !exists { + items[k] = cohortItem{scope: scope, customerID: customer} + } + } + } + out := make([]cohortItem, 0, len(items)) + for _, item := range items { + out = append(out, item) + } + sort.Slice(out, func(i, j int) bool { + if out[i].scope.applicationID != out[j].scope.applicationID { + return out[i].scope.applicationID < out[j].scope.applicationID + } + if out[i].scope.platform != out[j].scope.platform { + return out[i].scope.platform < out[j].scope.platform + } + return out[i].customerID < out[j].customerID + }) + return out +} + +func prepareProjectionInput(input billingprojection.Input, allowed map[string]bool) billingprojection.Input { + for i := range input.Lineages { + filtered := input.Lineages[i].Facts[:0] + for _, fact := range input.Lineages[i].Facts { + if allowed[fact.ID] { + filtered = append(filtered, fact) + } + } + input.Lineages[i].Facts = filtered + input.Lineages[i].Checkpoint = "" + input.Lineages[i].CheckpointChecksum = nil + input.Lineages[i].CheckpointFacts = 0 + input.Lineages[i].SnapshotID = "" + } + kept := input.Lineages[:0] + for _, lineage := range input.Lineages { + if len(lineage.Facts) > 0 { + kept = append(kept, lineage) + } + } + input.Lineages = kept + input.PriorCustomerSnapshot = nil + input.RuleVersion = billingprojection.ActiveRuleVersion + return input +} + +func (b *Builder) loadFrozen(ctx context.Context, lease billingmigration.ExecutionLease) (frozenProgram, error) { + var f frozenProgram + var sourceWatermark string + err := b.pool.QueryRow(ctx, `SELECT p.environment_id,p.state,p.state_version,p.authority_epoch_before,p.policy_digest, + m.id,m.mapping_digest,s.id,s.manifest_digest,s.source_watermark,s.captured_at + FROM billing_migration_programs p + JOIN LATERAL (SELECT id,mapping_digest FROM billing_migration_mapping_sets WHERE program_id=p.id AND project_id=p.project_id AND status='frozen' ORDER BY version DESC LIMIT 1) m ON true + JOIN LATERAL (SELECT id,manifest_digest,source_watermark,captured_at FROM billing_migration_source_manifests WHERE program_id=p.id AND project_id=p.project_id ORDER BY captured_at DESC,id DESC LIMIT 1) s ON true + WHERE p.id=$1 AND p.project_id=$2`, lease.ProgramID, lease.ProjectID).Scan(&f.environmentID, &f.programState, &f.stateVersion, &f.authorityEpoch, &f.policyDigest, &f.mappingID, &f.mappingDigest, &f.manifestID, &f.manifestDigest, &sourceWatermark, &f.capturedAt) + if errors.Is(err, pgx.ErrNoRows) { + return f, billingmigration.ErrStaleState + } + if err != nil { + return f, err + } + f.sourceWatermark = f.capturedAt.UTC() + if parsed, parseErr := time.Parse(time.RFC3339Nano, sourceWatermark); parseErr == nil { + f.sourceWatermark = parsed.UTC() + } + if !validProgramBinding(lease, f.programState, f.stateVersion) { + return f, billingmigration.ErrStaleState + } + if !bytes.Equal(f.manifestDigest, lease.ManifestDigest) || !bytes.Equal(f.mappingDigest, lease.MappingDigest) || (lease.JobKind != "final_delta" && !bytes.Equal(f.policyDigest, lease.PolicyDigest)) { + return f, billingmigration.ErrStaleDigest + } + if lease.JobKind == "final_delta" { + var evidence []byte + err = b.pool.QueryRow(ctx, `SELECT evidence_digest FROM billing_migration_final_delta_jobs WHERE id=$1 AND program_id=$2 AND project_id=$3 AND status='running' AND lease_owner=$4 AND lease_generation=$5 AND lease_expires_at=$6`, lease.JobID, lease.ProgramID, lease.ProjectID, lease.Owner, lease.Generation, lease.ExpiresAt).Scan(&evidence) + if errors.Is(err, pgx.ErrNoRows) { + return f, billingmigration.ErrLeaseLost + } + if err != nil { + return f, err + } + if !bytes.Equal(evidence, lease.EvidenceDigest) { + return f, billingmigration.ErrStaleDigest + } + } else { + var manifest, mapping, policy []byte + err = b.pool.QueryRow(ctx, `SELECT manifest_digest,mapping_digest,policy_digest FROM billing_migration_run_jobs WHERE id=$1 AND program_id=$2 AND project_id=$3 AND run_kind=$4 AND status='running' AND lease_owner=$5 AND lease_generation=$6 AND lease_expires_at=$7`, lease.JobID, lease.ProgramID, lease.ProjectID, lease.JobKind, lease.Owner, lease.Generation, lease.ExpiresAt).Scan(&manifest, &mapping, &policy) + if errors.Is(err, pgx.ErrNoRows) { + return f, billingmigration.ErrLeaseLost + } + if err != nil { + return f, err + } + if !bytes.Equal(manifest, lease.ManifestDigest) || !bytes.Equal(mapping, lease.MappingDigest) || !bytes.Equal(policy, lease.PolicyDigest) { + return f, billingmigration.ErrStaleDigest + } + } + rows, err := b.pool.Query(ctx, `SELECT ps.application_id,ps.platform,coalesce(a.current_authority,''),coalesce(a.current_epoch,-1),coalesce(a.active_program_id,'') + FROM billing_migration_program_scopes ps LEFT JOIN billing_migration_authority_scopes a ON a.project_id=$2 AND a.environment_id=$3 AND a.application_id=ps.application_id AND a.platform=ps.platform + WHERE ps.program_id=$1 ORDER BY ps.application_id,ps.platform`, lease.ProgramID, lease.ProjectID, f.environmentID) + if err != nil { + return f, err + } + defer rows.Close() + for rows.Next() { + var s evaluationScope + var authority, active string + var epoch int64 + if err = rows.Scan(&s.applicationID, &s.platform, &authority, &epoch, &active); err != nil { + return f, err + } + if authority != "source" || epoch != f.authorityEpoch || active != lease.ProgramID { + return f, billingmigration.ErrAuthorityEpoch + } + f.scopes = append(f.scopes, s) + } + if err = rows.Err(); err != nil { + return f, err + } + if len(f.scopes) == 0 { + return f, billingmigration.ErrPointerCoverage + } + return f, nil +} + +func validProgramBinding(lease billingmigration.ExecutionLease, state string, version int64) bool { + if lease.JobKind == "final_delta" { + return (state == "shadowing" || state == "ready") && version == lease.ExpectedStateVersion + } + want := "dry_run" + if lease.JobKind == "shadow" { + want = "shadowing" + } + return state == want && (version == lease.ExpectedStateVersion || version == lease.ExpectedStateVersion+1) +} + +func stableID(prefix string, parts ...string) string { + h := sha256.New() + h.Write([]byte(prefix)) + for _, p := range parts { + h.Write([]byte{0}) + h.Write([]byte(p)) + } + return prefix + "_" + hex.EncodeToString(h.Sum(nil)[:12]) +} +func hash(domain string, parts ...[]byte) []byte { + h := sha256.New() + h.Write([]byte(domain)) + for _, p := range parts { + h.Write([]byte{0}) + h.Write(p) + } + return h.Sum(nil) +} +func hashSorted(domain string, parts [][]byte) []byte { + sort.Slice(parts, func(i, j int) bool { return bytes.Compare(parts[i], parts[j]) < 0 }) + return hash(domain, parts...) +} +func newDivergence(lease billingmigration.ExecutionLease, class, reason string, evidence []byte, at time.Time) billingmigration.DivergenceWrite { + return billingmigration.DivergenceWrite{Divergence: billingmigration.Divergence{ProgramID: lease.ProgramID, StateVersion: lease.ExpectedStateVersion, DivergenceID: stableID("mdv", lease.JobID, reason, hex.EncodeToString(evidence)), Classification: class, Reason: reason, ObservedAt: at, ClassificationRuleVersion: "phase-9c-candidate-v1"}, EvidenceDigest: evidence} +} + +// migrationReferenceDigest reproduces the persisted Phase 9A reference-key +// contract without retaining the provider reference beyond this read. +func migrationReferenceDigest(provider, kind, reference string) []byte { + if provider == billing.ProviderAppStore { + return billing.AppleTransactionKey(billing.StoreUnclassified, reference) + } + if kind == "google_play_purchase_token" { + return billing.TokenDigest(reference) + } + return hash("mosaic-billing-google-order-v1", []byte(reference)) +} diff --git a/apps/api/internal/platform/billingmigrationevaluation/builder_test.go b/apps/api/internal/platform/billingmigrationevaluation/builder_test.go new file mode 100644 index 00000000..0a76828c --- /dev/null +++ b/apps/api/internal/platform/billingmigrationevaluation/builder_test.go @@ -0,0 +1,182 @@ +package billingmigrationevaluation + +import ( + "bytes" + "crypto/sha256" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +func TestMigrationReferenceDigestUsesPhase9AIdentityContracts(t *testing.T) { + t.Parallel() + if got, want := migrationReferenceDigest(billing.ProviderAppStore, "app_store_transaction_id", "transaction"), billing.AppleTransactionKey(billing.StoreUnclassified, "transaction"); !bytes.Equal(got, want) { + t.Fatal("Apple migration reference did not use the Phase 9A transaction identity") + } + if got, want := migrationReferenceDigest(billing.ProviderGooglePlay, "google_play_purchase_token", "token"), billing.TokenDigest("token"); !bytes.Equal(got, want) { + t.Fatal("Google token migration reference did not use the Phase 9A token identity") + } + h := sha256.New() + h.Write([]byte("mosaic-billing-google-order-v1")) + h.Write([]byte{0}) + h.Write([]byte("order")) + if got := migrationReferenceDigest(billing.ProviderGooglePlay, "google_play_order_id", "order"); !bytes.Equal(got, h.Sum(nil)) { + t.Fatal("Google order migration reference did not use the Phase 9A order identity") + } +} + +func TestCohortGroupingDoesNotFanRecordsAcrossApplications(t *testing.T) { + t.Parallel() + lease := billingmigration.ExecutionLease{ProgramID: "program", JobID: "job", ExpectedStateVersion: 3} + records := []cohortRecord{ + {scope: evaluationScope{applicationID: "app_ios", platform: "ios"}, recordID: "ios_record", sourceID: "source_ios", digest: bytes.Repeat([]byte{1}, 32), targets: []string{"ios_customer"}, currentAccess: true, providerEnvironment: "production", expectedStoreEnvironment: "production"}, + {scope: evaluationScope{applicationID: "app_android", platform: "android"}, recordID: "android_record", sourceID: "source_android", digest: bytes.Repeat([]byte{2}, 32), targets: []string{"android_customer"}, providerEnvironment: "production", expectedStoreEnvironment: "production"}, + } + cohort, divergences := groupCohortRecords(lease, records, time.Unix(1, 0)) + if len(divergences) != 0 { + t.Fatalf("unexpected divergences: %+v", divergences) + } + if len(cohort) != 2 { + t.Fatalf("got %d cohort rows, want exact two", len(cohort)) + } + for _, item := range cohort { + if item.scope.applicationID == "app_ios" && item.customerID != "ios_customer" { + t.Fatal("iOS source contaminated by Android customer") + } + if item.scope.applicationID == "app_android" && item.customerID != "android_customer" { + t.Fatal("Android source contaminated by iOS customer") + } + } +} + +func TestMissingFactDivergenceIsCustomerScopedWithinApplication(t *testing.T) { + t.Parallel() + lease := billingmigration.ExecutionLease{ProgramID: "program", JobID: "job", ExpectedStateVersion: 3} + scope := evaluationScope{applicationID: "shared_app", platform: "ios"} + at := time.Unix(1, 0) + withFact := billingprojection.Input{Lineages: []billingprojection.LineageInput{{Facts: []billingprojection.Fact{{ID: "validated"}}}}} + withoutFact := billingprojection.Input{} + if got := missingCustomerFactDivergence(lease, cohortItem{scope: scope, customerID: "customer_a", sourceCurrentAccess: true}, withFact, at); got != nil { + t.Fatal("customer with its own validated fact was marked missing") + } + got := missingCustomerFactDivergence(lease, cohortItem{scope: scope, customerID: "customer_b", sourceCurrentAccess: true}, withoutFact, at) + if got == nil || got.Divergence.Reason != "provider_validation_missing" { + t.Fatalf("second customer borrowed first customer's fact: %+v", got) + } +} + +func TestFinalCohortExpandsEveryCustomerAcrossExactScopes(t *testing.T) { + t.Parallel() + ios := evaluationScope{applicationID: "app_ios", platform: "ios"} + android := evaluationScope{applicationID: "app_android", platform: "android"} + sourceDigest := bytes.Repeat([]byte{0x41}, 32) + got := expandFinalCohort([]cohortItem{{scope: ios, customerID: "customer", sourceDigests: [][]byte{sourceDigest}, sourceCurrentAccess: true}}, []evaluationScope{ios, android}) + if len(got) != 2 { + t.Fatalf("expanded cohort rows=%d want=2", len(got)) + } + for _, item := range got { + if item.scope == ios && (!item.sourceCurrentAccess || len(item.sourceDigests) != 1) { + t.Fatal("originating scope lost its source evidence") + } + if item.scope == android && (item.sourceCurrentAccess || len(item.sourceDigests) != 0) { + t.Fatal("source evidence contaminated the synthetic Android scope") + } + } +} + +func TestAccessComparisonDetectsBothDivergenceDirections(t *testing.T) { + t.Parallel() + lease := billingmigration.ExecutionLease{ProgramID: "program", JobID: "job", ExpectedStateVersion: 3} + evidence := bytes.Repeat([]byte{3}, 32) + at := time.Unix(1, 0) + if got := accessComparisonDivergence(lease, true, false, evidence, at); got == nil || got.Divergence.Reason != "source_grants_mosaic_denies" { + t.Fatalf("source grant divergence=%+v", got) + } + if got := accessComparisonDivergence(lease, false, true, evidence, at); got == nil || got.Divergence.Reason != "mosaic_grants_source_denies" { + t.Fatalf("Mosaic grant divergence=%+v", got) + } + if got := accessComparisonDivergence(lease, true, true, evidence, at); got != nil { + t.Fatal("matching active access diverged") + } +} + +func TestCohortQuarantinesStoreEnvironmentMismatch(t *testing.T) { + t.Parallel() + lease := billingmigration.ExecutionLease{ProgramID: "program", JobID: "job", ExpectedStateVersion: 3} + records := []cohortRecord{{scope: evaluationScope{applicationID: "app", platform: "ios"}, recordID: "record", sourceID: "customer", digest: bytes.Repeat([]byte{1}, 32), targets: []string{"customer"}, currentAccess: true, providerEnvironment: "sandbox", expectedStoreEnvironment: "production"}} + cohort, divergences := groupCohortRecords(lease, records, time.Unix(1, 0)) + if len(cohort) != 0 { + t.Fatal("environment-mismatched record entered candidate cohort") + } + if len(divergences) != 1 || divergences[0].Divergence.Reason != "normalization_difference" { + t.Fatalf("environment mismatch divergence=%+v", divergences) + } +} + +func TestPrepareProjectionInputAdmitsOnlyExactValidatedFacts(t *testing.T) { + t.Parallel() + prior := &billingprojection.CustomerSnapshot{Checksum: bytes.Repeat([]byte{0x44}, 32)} + input := billingprojection.Input{PriorCustomerSnapshot: prior, RuleVersion: 99, Lineages: []billingprojection.LineageInput{ + {LineageID: "accepted", SnapshotID: "live_snapshot", Checkpoint: "watermark", CheckpointChecksum: []byte{1}, CheckpointFacts: 2, Facts: []billingprojection.Fact{{ID: "fact_accepted"}, {ID: "fact_other_program"}}}, + {LineageID: "foreign", Facts: []billingprojection.Fact{{ID: "fact_wrong_scope"}}}, + }} + got := prepareProjectionInput(input, map[string]bool{"fact_accepted": true}) + if len(got.Lineages) != 1 || got.Lineages[0].LineageID != "accepted" || len(got.Lineages[0].Facts) != 1 || got.Lineages[0].Facts[0].ID != "fact_accepted" { + t.Fatalf("unexpected filtered input: %+v", got.Lineages) + } + if got.PriorCustomerSnapshot != nil || got.RuleVersion != billingprojection.ActiveRuleVersion { + t.Fatal("candidate reused live comparison state or non-accepted rules") + } + lineage := got.Lineages[0] + if lineage.SnapshotID != "" || lineage.Checkpoint != "" || lineage.CheckpointChecksum != nil || lineage.CheckpointFacts != 0 { + t.Fatal("candidate reused a live snapshot/checkpoint") + } +} + +func TestEvaluationDigestsAreOrderIndependentAndDomainSeparated(t *testing.T) { + t.Parallel() + a := bytes.Repeat([]byte{0x11}, 32) + b := bytes.Repeat([]byte{0x22}, 32) + first := hashSorted("candidate-source", [][]byte{append([]byte(nil), b...), append([]byte(nil), a...)}) + second := hashSorted("candidate-source", [][]byte{append([]byte(nil), a...), append([]byte(nil), b...)}) + if !bytes.Equal(first, second) { + t.Fatal("replaying the same immutable evidence in a different read order changed the digest") + } + if bytes.Equal(first, hashSorted("candidate-comparison", [][]byte{a, b})) { + t.Fatal("source and comparison evidence domains collided") + } +} + +func TestStableCandidateIdentifiersExcludeLeaseAttempt(t *testing.T) { + t.Parallel() + first := stableID("cesm", "evaluation", "app", "ios", "customer") + second := stableID("cesm", "evaluation", "app", "ios", "customer") + if first != second { + t.Fatal("deterministic replay changed the immutable candidate identifier") + } + if first == stableID("cesm", "evaluation", "app", "android", "customer") { + t.Fatal("platform scope was omitted from the immutable candidate identifier") + } +} + +func TestProgramBindingAllowsOnlyQueueTransitionVersion(t *testing.T) { + t.Parallel() + dry := billingmigration.ExecutionLease{JobKind: "dry_run", ExpectedStateVersion: 3} + if !validProgramBinding(dry, "dry_run", 3) || !validProgramBinding(dry, "dry_run", 4) { + t.Fatal("dry-run queue's atomic state transition was rejected") + } + if validProgramBinding(dry, "dry_run", 5) || validProgramBinding(dry, "shadowing", 4) { + t.Fatal("stale or wrong-state dry run was accepted") + } + shadow := billingmigration.ExecutionLease{JobKind: "shadow", ExpectedStateVersion: 4} + if !validProgramBinding(shadow, "shadowing", 5) || validProgramBinding(shadow, "dry_run", 5) { + t.Fatal("shadow state binding is not exact") + } + final := billingmigration.ExecutionLease{JobKind: "final_delta", ExpectedStateVersion: 5} + if !validProgramBinding(final, "shadowing", 5) || !validProgramBinding(final, "ready", 5) || validProgramBinding(final, "ready", 6) { + t.Fatal("final-delta state/version binding is not exact") + } +} diff --git a/apps/api/internal/platform/billingmigrationevaluation/evidence.go b/apps/api/internal/platform/billingmigrationevaluation/evidence.go new file mode 100644 index 00000000..a8590b67 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationevaluation/evidence.go @@ -0,0 +1,252 @@ +package billingmigrationevaluation + +import ( + "bytes" + "context" + "sort" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +type importEvidence struct { + applicationID, provider, reference, referenceKind string + expectedStoreEnvironment string + sourceRecordID, expectedStoreProductID, mosaicProductID string +} + +type cohortRecord struct { + scope evaluationScope + recordID, sourceID string + digest []byte + targets []string + currentAccess bool + providerEnvironment, expectedStoreEnvironment string +} + +// loadEvidence accepts only facts produced by a terminal validated Package E +// binding whose frozen expectations still match the current-manifest import +// row. Missing, quarantined, or mismatched evidence becomes a blocking +// divergence and is never offered to the projection engine. +func (b *Builder) loadEvidence(ctx context.Context, lease billingmigration.ExecutionLease, frozen frozenProgram) ([]cohortItem, []billingmigration.DivergenceWrite, time.Time, []byte, map[string]map[string]bool, error) { + rows, err := b.pool.Query(ctx, `SELECT r.source_record_id,r.application_id,r.provider,r.provider_reference,r.reference_kind, + coalesce(r.expected_store_product_identifier,''),coalesce(r.mosaic_product_id,''),r.expected_store_environment + FROM billing_migration_import_batch_records r + JOIN billing_migration_import_batches batch ON batch.id=r.import_batch_id AND batch.program_id=r.program_id AND batch.project_id=r.project_id + WHERE r.program_id=$1 AND r.project_id=$2 AND batch.manifest_id=$3 + ORDER BY r.application_id,r.ordinal,r.source_record_id`, lease.ProgramID, lease.ProjectID, frozen.manifestID) + if err != nil { + return nil, nil, time.Time{}, nil, nil, err + } + defer rows.Close() + imports := []importEvidence{} + for rows.Next() { + var v importEvidence + if err = rows.Scan(&v.sourceRecordID, &v.applicationID, &v.provider, &v.reference, &v.referenceKind, &v.expectedStoreProductID, &v.mosaicProductID, &v.expectedStoreEnvironment); err != nil { + return nil, nil, time.Time{}, nil, nil, err + } + imports = append(imports, v) + } + if err = rows.Err(); err != nil { + return nil, nil, time.Time{}, nil, nil, err + } + + // Facts are admitted by the exact terminal validation attempt, not merely by + // the raw input. A later retry may append different facts for the same raw + // input and must not silently change frozen migration evidence. + allowedAttempts := map[string]map[string]bool{} + providerWatermark := time.Time{} + divergences := []billingmigration.DivergenceWrite{} + terminalEvidence := [][]byte{} + for _, scope := range frozen.scopes { + allowedAttempts[scope.applicationID] = map[string]bool{} + } + for _, item := range imports { + digest := migrationReferenceDigest(item.provider, item.referenceKind, item.reference) + var rawID, validationAttemptID, status, application, storeProduct, mosaicProduct, storeEnvironment string + var evidence []byte + var watermark *time.Time + err = b.pool.QueryRow(ctx, `SELECT raw_input_id,validation_attempt_id,status,expected_application_id,expected_store_product_identifier,expected_mosaic_product_id,expected_store_environment,evidence_digest,provider_watermark + FROM billing_migration_validation_bindings WHERE program_id=$1 AND project_id=$2 AND provider=$3 AND reference_kind=$4 AND reference_digest=$5`, + lease.ProgramID, lease.ProjectID, item.provider, item.referenceKind, digest).Scan(&rawID, &validationAttemptID, &status, &application, &storeProduct, &mosaicProduct, &storeEnvironment, &evidence, &watermark) + valid := err == nil && status == "validated" && validationAttemptID != "" && application == item.applicationID && storeProduct == item.expectedStoreProductID && mosaicProduct == item.mosaicProductID && storeEnvironment == item.expectedStoreEnvironment && len(evidence) == 32 && watermark != nil + if err == nil && (status == "validated" || status == "quarantined") && len(evidence) == 32 { + terminalEvidence = append(terminalEvidence, evidence) + if watermark != nil && watermark.After(providerWatermark) { + providerWatermark = watermark.UTC() + } + } + if !valid { + reasonDigest := hash("mosaic-migration-provider-validation-missing-v1", []byte(item.sourceRecordID), digest) + divergences = append(divergences, newDivergence(lease, "blocking", "provider_validation_missing", reasonDigest, frozen.capturedAt)) + continue + } + applicationEvidence, inScope := allowedAttempts[item.applicationID] + if !inScope { + reasonDigest := hash("mosaic-migration-authority-scope-conflict-v1", []byte(item.sourceRecordID), []byte(item.applicationID)) + divergences = append(divergences, newDivergence(lease, "blocking", "authority_scope_conflict", reasonDigest, frozen.capturedAt)) + continue + } + applicationEvidence[rawID+"\x00"+validationAttemptID] = true + } + if providerWatermark.IsZero() { + providerWatermark = frozen.capturedAt.UTC() + } + + allowedFacts := map[string]map[string]bool{} + for _, scope := range frozen.scopes { + allowedFacts[scope.applicationID] = map[string]bool{} + if len(allowedAttempts[scope.applicationID]) == 0 { + continue + } + factRows, qerr := b.pool.Query(ctx, `SELECT id,resolution_state,mosaic_product_id IS NOT NULL,source_raw_input_id,validation_attempt_id FROM billing_transaction_facts WHERE project_id=$1 AND environment_id=$2 AND application_id=$3 AND source_raw_input_id IS NOT NULL AND validation_attempt_id IS NOT NULL ORDER BY id`, lease.ProjectID, frozen.environmentID, scope.applicationID) + if qerr != nil { + return nil, nil, time.Time{}, nil, nil, qerr + } + for factRows.Next() { + var id, state, rawID, attemptID string + var resolved bool + if qerr = factRows.Scan(&id, &state, &resolved, &rawID, &attemptID); qerr != nil { + factRows.Close() + return nil, nil, time.Time{}, nil, nil, qerr + } + if !allowedAttempts[scope.applicationID][rawID+"\x00"+attemptID] { + continue + } + if resolved && (state == "active_mapping" || state == "archived_mapping" || state == "replacement_chain") { + allowedFacts[scope.applicationID][id] = true + } else { + d := hash("mosaic-migration-unresolved-fact-v1", []byte(scope.applicationID), []byte(id), []byte(state)) + divergences = append(divergences, newDivergence(lease, "blocking", "mapping_missing", d, frozen.capturedAt)) + } + } + qerr = factRows.Err() + factRows.Close() + if qerr != nil { + return nil, nil, time.Time{}, nil, nil, qerr + } + } + + // Import rows are intentionally absent for source-pull records quarantined + // before import. Inspect the manifest directly so those current-access + // records cannot disappear from readiness evidence through an inner join. + unusableRows, err := b.pool.Query(ctx, `SELECT s.id,s.record_digest,coalesce(r.quarantine_reason,''),coalesce(r.customer_source_identifier,'') + FROM billing_migration_source_records s + LEFT JOIN billing_migration_source_record_relationships r ON r.source_record_id=s.id AND r.program_id=s.program_id AND r.project_id=s.project_id + WHERE s.program_id=$1 AND s.project_id=$2 AND s.manifest_id=$3 AND s.current_access + AND (r.source_record_id IS NULL OR r.quarantine_reason IS NOT NULL OR r.customer_source_identifier IS NULL OR NOT EXISTS ( + SELECT 1 FROM billing_migration_import_batch_records ir JOIN billing_migration_import_batches batch + ON batch.id=ir.import_batch_id AND batch.program_id=ir.program_id AND batch.project_id=ir.project_id + WHERE ir.source_record_id=s.id AND ir.program_id=s.program_id AND ir.project_id=s.project_id AND batch.manifest_id=s.manifest_id)) + ORDER BY s.id`, lease.ProgramID, lease.ProjectID, frozen.manifestID) + if err != nil { + return nil, nil, time.Time{}, nil, nil, err + } + for unusableRows.Next() { + var recordID, quarantineReason, sourceID string + var recordDigest []byte + if err = unusableRows.Scan(&recordID, &recordDigest, &quarantineReason, &sourceID); err != nil { + unusableRows.Close() + return nil, nil, time.Time{}, nil, nil, err + } + d := hash("mosaic-migration-unusable-current-access-v1", []byte(recordID), recordDigest, []byte(quarantineReason), []byte(sourceID)) + divergences = append(divergences, newDivergence(lease, "blocking", "provider_validation_missing", d, frozen.capturedAt)) + } + if err = unusableRows.Err(); err != nil { + unusableRows.Close() + return nil, nil, time.Time{}, nil, nil, err + } + unusableRows.Close() + + recordRows, err := b.pool.Query(ctx, `SELECT ps.application_id,ps.platform,s.id,s.record_digest,s.current_access,r.customer_source_identifier, + coalesce(r.provider_environment,''),ir.expected_store_environment, + array_agg(m.target_id ORDER BY m.target_id) FILTER (WHERE m.target_id IS NOT NULL) + FROM billing_migration_source_records s + JOIN billing_migration_source_record_relationships r ON r.source_record_id=s.id AND r.program_id=s.program_id AND r.project_id=s.project_id + JOIN billing_migration_import_batch_records ir ON ir.source_record_id=s.id AND ir.program_id=s.program_id AND ir.project_id=s.project_id + JOIN billing_migration_import_batches batch ON batch.id=ir.import_batch_id AND batch.program_id=ir.program_id AND batch.project_id=ir.project_id AND batch.manifest_id=s.manifest_id + JOIN applications app ON app.id=ir.application_id AND app.project_id=ir.project_id + JOIN billing_migration_program_scopes ps ON ps.program_id=s.program_id AND ps.application_id=ir.application_id AND ps.platform=app.platform + LEFT JOIN billing_migration_mapping_entries m ON m.mapping_set_id=$4 AND m.source_identifier=r.customer_source_identifier + AND m.source_kind IN ('customer_id','original_customer_id','audited_alias') AND (m.application_id IS NULL OR (m.application_id=ps.application_id AND m.platform=ps.platform)) + WHERE s.program_id=$1 AND s.project_id=$2 AND s.manifest_id=$3 AND r.customer_source_identifier IS NOT NULL + GROUP BY ps.application_id,ps.platform,s.id,s.record_digest,r.customer_source_identifier,r.provider_environment,ir.expected_store_environment ORDER BY ps.application_id,ps.platform,s.id`, lease.ProgramID, lease.ProjectID, frozen.manifestID, frozen.mappingID) + if err != nil { + return nil, nil, time.Time{}, nil, nil, err + } + defer recordRows.Close() + records := []cohortRecord{} + for recordRows.Next() { + var record cohortRecord + if err = recordRows.Scan(&record.scope.applicationID, &record.scope.platform, &record.recordID, &record.digest, &record.currentAccess, &record.sourceID, &record.providerEnvironment, &record.expectedStoreEnvironment, &record.targets); err != nil { + return nil, nil, time.Time{}, nil, nil, err + } + records = append(records, record) + } + if err = recordRows.Err(); err != nil { + return nil, nil, time.Time{}, nil, nil, err + } + cohort, mappingDivergences := groupCohortRecords(lease, records, frozen.capturedAt) + divergences = append(divergences, mappingDivergences...) + sort.Slice(divergences, func(i, j int) bool { + return bytes.Compare(divergences[i].EvidenceDigest, divergences[j].EvidenceDigest) < 0 + }) + evidenceDigest := hashSorted("mosaic-billing-migration-validation-result-v1", terminalEvidence) + if len(terminalEvidence) == 0 { + evidenceDigest = hash("mosaic-billing-migration-validation-pending-v1") + } + return cohort, divergences, providerWatermark, evidenceDigest, allowedFacts, nil +} + +func groupCohortRecords(lease billingmigration.ExecutionLease, records []cohortRecord, at time.Time) ([]cohortItem, []billingmigration.DivergenceWrite) { + type key struct{ app, platform, customer string } + grouped := map[key]*cohortItem{} + divergences := []billingmigration.DivergenceWrite{} + for _, record := range records { + if record.providerEnvironment != record.expectedStoreEnvironment { + d := hash("mosaic-migration-source-environment-mismatch-v1", []byte(record.scope.applicationID), []byte(record.recordID), []byte(record.providerEnvironment), []byte(record.expectedStoreEnvironment)) + divergences = append(divergences, newDivergence(lease, "blocking", "normalization_difference", d, at)) + continue + } + unique := uniqueStrings(record.targets) + if len(unique) != 1 { + d := hash("mosaic-migration-mapping-missing-v1", []byte(record.scope.applicationID), []byte(record.scope.platform), []byte(record.recordID), []byte(record.sourceID)) + divergences = append(divergences, newDivergence(lease, "blocking", "mapping_missing", d, at)) + continue + } + k := key{record.scope.applicationID, record.scope.platform, unique[0]} + g := grouped[k] + if g == nil { + g = &cohortItem{scope: record.scope, customerID: unique[0]} + grouped[k] = g + } + g.sourceDigests = append(g.sourceDigests, record.digest) + g.sourceCurrentAccess = g.sourceCurrentAccess || record.currentAccess + } + cohort := make([]cohortItem, 0, len(grouped)) + for _, v := range grouped { + cohort = append(cohort, *v) + } + sort.Slice(cohort, func(i, j int) bool { + a, c := cohort[i], cohort[j] + if a.scope.applicationID != c.scope.applicationID { + return a.scope.applicationID < c.scope.applicationID + } + if a.scope.platform != c.scope.platform { + return a.scope.platform < c.scope.platform + } + return a.customerID < c.customerID + }) + return cohort, divergences +} + +func uniqueStrings(values []string) []string { + sort.Strings(values) + out := values[:0] + for _, v := range values { + if v != "" && (len(out) == 0 || out[len(out)-1] != v) { + out = append(out, v) + } + } + return out +} diff --git a/apps/api/internal/platform/billingmigrationevaluation/migration_integration_test.go b/apps/api/internal/platform/billingmigrationevaluation/migration_integration_test.go new file mode 100644 index 00000000..d2ce15ac --- /dev/null +++ b/apps/api/internal/platform/billingmigrationevaluation/migration_integration_test.go @@ -0,0 +1,221 @@ +package billingmigrationevaluation + +import ( + "bytes" + "context" + "database/sql" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +// This test needs its own throwaway database because both the migration being +// tested and the append-only evidence it creates intentionally refuse cleanup. +func TestCandidateEvaluationMigrationDownRefusesImmutableEvidence(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := sql.Open("pgx", databaseURL) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatalf("reset dedicated schema: %v", err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("apply migrations: %v", err) + } + if err = goose.DownContext(ctx, db, "."); err != nil { + t.Fatalf("empty migration 00061 down: %v", err) + } + if err = goose.DownContext(ctx, db, "."); err != nil { + t.Fatalf("empty migration 00060 down: %v", err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("migrations 00060-00061 re-up: %v", err) + } + _, err = db.ExecContext(ctx, `INSERT INTO organizations(id,name,created_at,updated_at) VALUES('org_eval','Evaluation',now(),now()); + INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) VALUES('project_eval','org_eval','evaluation','Evaluation','active',now(),now()); + INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) VALUES('environment_eval','project_eval','production','Production','production',now(),now()); + INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) VALUES('credential_eval','project_eval','revenuecat','rc_eval','active',1,'AES-256-GCM','key',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),'owner',now()); + INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) VALUES('program_eval','project_eval','environment_eval','revenuecat','revenuecat-v2-readonly-v1','credential_eval','shadowing',3,0,7,7,decode(repeat('11',32),'hex'),decode(repeat('12',32),'hex'),'eval',decode(repeat('13',32),'hex'),'owner',now(),now()); + INSERT INTO billing_migration_candidate_evaluations(id,program_id,project_id,job_id,job_kind,state_version,authority_epoch,manifest_digest,mapping_digest,policy_digest,evidence_digest,source_watermark,provider_watermark,shadow_watermark,cohort_digest,evaluation_digest,evaluated_at) VALUES('evaluation_one','program_eval','project_eval','job_eval','shadow',3,0,decode(repeat('21',32),'hex'),decode(repeat('22',32),'hex'),decode(repeat('12',32),'hex'),decode(repeat('23',32),'hex'),now(),now(),now(),decode(repeat('24',32),'hex'),decode(repeat('25',32),'hex'),now())`) + if err != nil { + t.Fatalf("seed immutable evaluation: %v", err) + } + if err = goose.DownContext(ctx, db, "."); err != nil { + t.Fatalf("empty migration 00061 down before 00060 guard: %v", err) + } + err = goose.DownContext(ctx, db, ".") + if err == nil || !strings.Contains(err.Error(), "immutable candidate evaluation evidence exists") { + t.Fatalf("populated migration down error=%v", err) + } + var count int + if err = db.QueryRowContext(ctx, `SELECT count(*) FROM billing_migration_candidate_evaluations`).Scan(&count); err != nil || count != 1 { + t.Fatalf("down guard lost evidence count=%d err=%v", count, err) + } +} + +func TestBuilderPersistsCandidateWithoutLiveMutationAndReplaysDeterministically(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + config, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + config.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol + db := stdlib.OpenDB(*config) + defer db.Close() + if _, err = db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Microsecond) + manifest, mapping, policy, evidence := bytes.Repeat([]byte{0x21}, 32), bytes.Repeat([]byte{0x22}, 32), bytes.Repeat([]byte{0x23}, 32), bytes.Repeat([]byte{0x24}, 32) + referenceDigest := billing.AppleTransactionKey(billing.StoreUnclassified, "transaction_one") + aggregateEvidence := hashSorted("mosaic-billing-migration-validation-result-v1", [][]byte{evidence}) + _, err = db.ExecContext(ctx, `INSERT INTO organizations(id,name,created_at,updated_at) VALUES('org_eval','Evaluation',$1,$1); + INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) VALUES('project_eval','org_eval','evaluation','Evaluation','active',$1,$1); + INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) VALUES('environment_eval','project_eval','production','Production','production',$1,$1); + INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) VALUES + ('app_eval','project_eval','App','ios','com.example.eval',$1,$1), + ('app_eval_android','project_eval','App Android','android','com.example.eval.android',$1,$1); + INSERT INTO products(id,project_id,key,internal_name,description,type,status,metadata_source,readiness_ready,created_at,updated_at) VALUES('product_eval','project_eval','pro','Pro','','subscription','connected','mock',true,$1,$1); + INSERT INTO entitlements(id,project_id,key,name,description,created_at,updated_at) VALUES('entitlement_eval','project_eval','pro','Pro','',$1,$1); + INSERT INTO provider_product_mappings(id,project_id,product_id,application_id,provider,provider_product_identifier,platform,status,created_at,updated_at) VALUES('ppm_eval','project_eval','product_eval','app_eval','app_store','store.product','ios','placeholder',$1,$1); + INSERT INTO product_entitlement_grant_versions(id,project_id,product_id,entitlement_id,version,effective_start,created_at) VALUES('grant_eval','project_eval','product_eval','entitlement_eval',1,$1::timestamptz-interval '1 day',$1); + INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) VALUES('customer_eval','project_eval','active','none',$1,$1); + INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) VALUES('credential_eval','project_eval','revenuecat','rc_eval','active',1,'AES-256-GCM','key',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),'owner',$1); + INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) VALUES('program_eval','project_eval','environment_eval','revenuecat','revenuecat-v2-readonly-v1','credential_eval','shadowing',3,0,7,7,decode(repeat('11',32),'hex'),$4,'eval',decode(repeat('13',32),'hex'),'owner',$1,$1); + INSERT INTO billing_migration_program_scopes(program_id,project_id,environment_id,application_id,platform,created_at) VALUES + ('program_eval','project_eval','environment_eval','app_eval','ios',$1), + ('program_eval','project_eval','environment_eval','app_eval_android','android',$1); + INSERT INTO billing_migration_authority_scopes(id,project_id,environment_id,application_id,platform,current_authority,current_epoch,active_program_id,authority_digest,updated_at) VALUES + ('authority_eval','project_eval','environment_eval','app_eval','ios','source',0,'program_eval',decode(repeat('14',32),'hex'),$1), + ('authority_eval_android','project_eval','environment_eval','app_eval_android','android','source',0,'program_eval',decode(repeat('19',32),'hex'),$1); + INSERT INTO billing_migration_mapping_sets(id,program_id,project_id,version,status,mapping_digest,expected_program_state_version,created_by_actor_id,created_at) VALUES('mapping_eval','program_eval','project_eval',1,'draft',$3,3,'owner',$1); + INSERT INTO billing_migration_mapping_entries(id,mapping_set_id,program_id,project_id,source_kind,source_identifier,target_id,match_kind,application_id,platform,created_at) VALUES('customer_map','mapping_eval','program_eval','project_eval','customer_id','source_customer','customer_eval','exact','app_eval','ios',$1),('product_map','mapping_eval','program_eval','project_eval','product','source_product','product_eval','exact','app_eval','ios',$1); + UPDATE billing_migration_mapping_sets SET status='frozen',frozen_at=$1 WHERE id='mapping_eval'; + INSERT INTO billing_migration_source_manifests(id,program_id,project_id,state_version,adapter_version,provider_api_version,schema_version,record_count,current_access_record_count,object_key,object_checksum,object_size_bytes,object_encryption,manifest_digest,source_watermark,captured_at) VALUES('manifest_eval','program_eval','project_eval',3,'adapter','v2','schema',1,1,'object',decode(repeat('15',32),'hex'),1,'AES-256-GCM',$2,$5,$1); + INSERT INTO billing_migration_source_records(id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,record_digest,current_access,normalization_schema_version,evidence_kind,observed_at,created_at) VALUES('record_eval','program_eval','project_eval','manifest_eval','subscription','subscription_one','1',decode(repeat('16',32),'hex'),true,'schema','trusted_provider_api',$1,$1); + INSERT INTO billing_migration_source_record_relationships(source_record_id,program_id,project_id,customer_source_identifier,product_source_identifier,external_application_id,store,provider_environment,store_identifier,mosaic_product_id,relationship_digest,created_at) VALUES('record_eval','program_eval','project_eval','source_customer','source_product','external','app_store','production','store.product','product_eval',decode(repeat('17',32),'hex'),$1); + INSERT INTO billing_migration_source_records(id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,record_digest,current_access,normalization_schema_version,evidence_kind,observed_at,created_at) VALUES('record_quarantined','program_eval','project_eval','manifest_eval','subscription','subscription_quarantined','1',decode(repeat('1a',32),'hex'),true,'schema','trusted_provider_api',$1,$1); + INSERT INTO billing_migration_source_record_relationships(source_record_id,program_id,project_id,customer_source_identifier,product_source_identifier,external_application_id,store,provider_environment,store_identifier,mosaic_product_id,quarantine_reason,relationship_digest,created_at) VALUES('record_quarantined','program_eval','project_eval','source_quarantined','source_product',NULL,'app_store','production','store.product','product_eval','missing_application_binding',decode(repeat('1b',32),'hex'),$1); + INSERT INTO billing_migration_import_batches(id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest,expected_program_state_version,status,record_count,validated_count,quarantined_count,cursor_before,cursor_after,attempt_count,lease_generation,created_at,updated_at,due_at,max_attempts) VALUES('batch_eval','program_eval','project_eval','manifest_eval','mapping_eval','batch',decode(repeat('18',32),'hex'),3,'completed',1,1,0,'','',1,1,$1,$1,$1,8); + INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,source_product_identifier,mosaic_product_id,expected_store_product_identifier,expected_store_environment) VALUES('batch_eval','program_eval','project_eval','record_eval',0,'app_store','environment_eval','app_eval','transaction_one','app_store_transaction_id','source_product','product_eval','store.product','production'); + INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_eval','project_eval','org_eval','environment_eval','production','app_eval','app_store','migration_known_reference','store_reconciliation',decode(repeat('31',32),'hex'),decode(repeat('32',32),'hex'),$6,'not_retained','verified_transport','unclassified','accepted','eval',$1,$1::timestamptz+interval '1 hour'); + INSERT INTO billing_validation_attempts(id,project_id,environment_id,raw_input_id,attempt_number,validator_version,started_at,completed_at,outcome,retryable,store_environment,latency_ms,correlation_id) VALUES('attempt_eval','project_eval','environment_eval','raw_eval',1,2,$1,$1,'validated',false,'production',1,'eval'); + INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_eval','program_eval','project_eval','environment_eval','raw_eval','app_store','app_store_transaction_id',$6,'app_eval','store.product','production','product_eval','validated','attempt_eval',$7,$1,$1,$1); + INSERT INTO purchase_lineages(id,project_id,environment_id,environment_mode,application_id,provider,store_environment,lineage_key_digest,lineage_type,billing_customer_id,created_at,updated_at) VALUES('lineage_eval','project_eval','environment_eval','production','app_eval','app_store','production',decode(repeat('33',32),'hex'),'subscription','customer_eval',$1,$1); + INSERT INTO subscription_instances(id,project_id,environment_id,application_id,purchase_lineage_id,billing_customer_id,provider,created_at,updated_at) VALUES('subscription_eval','project_eval','environment_eval','app_eval','lineage_eval','customer_eval','app_store',$1,$1); + INSERT INTO billing_transaction_facts(id,project_id,environment_id,environment_mode,application_id,provider,store_environment,provider_transaction_id,purchase_chain_digest,transaction_type,fact_kind,occurred_at,period_start_at,period_end_at,provider_product_identifier,resolution_state,mosaic_product_id,provider_product_mapping_id,resolved_mapping_version,validator_version,source_raw_input_id,validation_attempt_id,fact_digest,recorded_at) VALUES('fact_eval','project_eval','environment_eval','production','app_eval','app_store','production','transaction_one',decode(repeat('33',32),'hex'),'auto_renewable_subscription','initial_purchase',$1,$1,$1::timestamptz+interval '1 day','store.product','active_mapping','product_eval','ppm_eval',1,2,'raw_eval','attempt_eval',decode(repeat('34',32),'hex'),$1); + INSERT INTO billing_validation_attempts(id,project_id,environment_id,raw_input_id,attempt_number,validator_version,started_at,completed_at,outcome,retryable,store_environment,latency_ms,correlation_id) VALUES('attempt_later','project_eval','environment_eval','raw_eval',2,2,$1,$1,'validated',false,'production',1,'eval-later'); + INSERT INTO billing_transaction_facts(id,project_id,environment_id,environment_mode,application_id,provider,store_environment,provider_transaction_id,purchase_chain_digest,transaction_type,fact_kind,occurred_at,period_start_at,period_end_at,provider_product_identifier,resolution_state,mosaic_product_id,provider_product_mapping_id,resolved_mapping_version,validator_version,source_raw_input_id,validation_attempt_id,fact_digest,recorded_at) VALUES('fact_later_attempt','project_eval','environment_eval','production','app_eval','app_store','production','transaction_later',decode(repeat('33',32),'hex'),'auto_renewable_subscription','renewal',$1,$1,$1::timestamptz+interval '2 days','store.product','active_mapping','product_eval','ppm_eval',1,2,'raw_eval','attempt_later',decode(repeat('35',32),'hex'),$1); + INSERT INTO billing_migration_run_jobs(id,program_id,project_id,run_kind,idempotency_key,request_digest,expected_program_state_version,manifest_digest,mapping_digest,policy_digest,status,lease_owner,lease_expires_at,lease_generation,attempt_count,due_at,max_attempts,created_at,updated_at) VALUES('run_eval','program_eval','project_eval','shadow','run',decode(repeat('41',32),'hex'),3,$2,$3,$4,'running','worker',$1::timestamptz+interval '1 hour',1,1,$1,8,$1,$1)`, now, manifest, mapping, policy, now.Format(time.RFC3339Nano), referenceDigest, evidence) + if err != nil { + t.Fatalf("seed evaluation: %v", err) + } + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + lease := billingmigration.ExecutionLease{JobID: "run_eval", JobKind: "shadow", ProjectID: "project_eval", ProgramID: "program_eval", Owner: "worker", Generation: 1, ExpectedStateVersion: 3, ManifestDigest: manifest, MappingDigest: mapping, PolicyDigest: policy, ExpiresAt: now.Add(time.Hour)} + builder := New(pool) + frozen, err := builder.loadFrozen(ctx, lease) + if err != nil { + t.Fatalf("load frozen evaluation: %v", err) + } + _, evidenceDivergences, _, _, allowedFacts, err := builder.loadEvidence(ctx, lease, frozen) + if err != nil { + t.Fatalf("load evaluation evidence: %v", err) + } + if !allowedFacts["app_eval"]["fact_eval"] || allowedFacts["app_eval"]["fact_later_attempt"] { + t.Fatalf("facts were not bound to exact terminal attempt: %+v", allowedFacts["app_eval"]) + } + quarantineBlocked := false + for _, divergence := range evidenceDivergences { + quarantineBlocked = quarantineBlocked || (divergence.Divergence.Classification == "blocking" && divergence.Divergence.Reason == "provider_validation_missing") + } + if !quarantineBlocked { + t.Fatalf("pre-import quarantined current-access record was invisible: %+v", evidenceDivergences) + } + run, pointers, digest, err := builder.Evaluate(ctx, lease) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if len(pointers) != 1 || len(run.ShadowSnapshots) != 1 || len(digest) != 32 { + t.Fatalf("candidate coverage pointers=%d shadows=%d digest=%d", len(pointers), len(run.ShadowSnapshots), len(digest)) + } + var live int + if err = pool.QueryRow(ctx, `SELECT count(*) FROM customer_entitlement_pointers WHERE project_id='project_eval'`).Scan(&live); err != nil || live != 0 { + t.Fatalf("candidate mutated live pointer count=%d err=%v", live, err) + } + _, pointers2, digest2, err := builder.Evaluate(ctx, lease) + if err != nil || len(pointers2) != 1 || !bytes.Equal(digest, digest2) || pointers2[0].SnapshotID != pointers[0].SnapshotID { + t.Fatalf("deterministic replay pointers=%+v digest=%x err=%v", pointers2, digest2, err) + } + var candidates int + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_candidate_snapshots WHERE program_id='program_eval'`).Scan(&candidates); err != nil || candidates != 1 { + t.Fatalf("replay duplicated candidate count=%d err=%v", candidates, err) + } + stale := lease + stale.Generation = 2 + if _, _, _, err = builder.Evaluate(ctx, stale); err == nil { + t.Fatal("stale lease generation was accepted") + } + if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_final_delta_jobs(id,program_id,project_id,idempotency_key,request_digest,expected_program_state_version,manifest_digest,mapping_digest,evidence_digest,status,due_at,lease_owner,lease_expires_at,lease_generation,attempt_count,max_attempts,created_at,updated_at) VALUES('final_eval','program_eval','project_eval','final',decode(repeat('51',32),'hex'),3,$1,$2,$3,'running',$4,'final_worker',$4::timestamptz+interval '1 hour',1,1,8,$4,$4)`, manifest, mapping, aggregateEvidence, now); err != nil { + t.Fatal(err) + } + finalLease := billingmigration.ExecutionLease{JobID: "final_eval", JobKind: "final_delta", ProjectID: "project_eval", ProgramID: "program_eval", Owner: "final_worker", Generation: 1, ExpectedStateVersion: 3, ManifestDigest: manifest, MappingDigest: mapping, EvidenceDigest: aggregateEvidence, ExpiresAt: now.Add(time.Hour)} + delta, finalPointers, finalDigest, err := builder.BuildFinalDelta(ctx, finalLease) + if err != nil { + t.Fatalf("build final delta: %v", err) + } + if len(finalPointers) != 2 || len(delta.Cohort) != 1 || delta.Cohort[0].BillingCustomerID != "customer_eval" || len(finalDigest) != 32 { + t.Fatalf("final coverage pointers=%d cohort=%+v digest=%d", len(finalPointers), delta.Cohort, len(finalDigest)) + } + seenScopes := map[string]bool{} + for _, pointer := range finalPointers { + seenScopes[pointer.ApplicationID+":"+pointer.Platform] = true + } + if !seenScopes["app_eval:ios"] || !seenScopes["app_eval_android:android"] { + t.Fatalf("final cohort did not cover every exact program scope: %+v", finalPointers) + } + if !delta.SourceWatermark.Equal(now) || !delta.ProviderWatermark.Equal(now) || !delta.ShadowWatermark.Equal(now) { + t.Fatalf("unstable final watermarks source=%s provider=%s shadow=%s want=%s", delta.SourceWatermark, delta.ProviderWatermark, delta.ShadowWatermark, now) + } + wrongEvidence := finalLease + wrongEvidence.EvidenceDigest = bytes.Repeat([]byte{0x99}, 32) + if _, _, _, err = builder.BuildFinalDelta(ctx, wrongEvidence); err == nil { + t.Fatal("wrong final evidence digest was accepted") + } +} diff --git a/apps/api/internal/platform/billingmigrationevaluation/persistence.go b/apps/api/internal/platform/billingmigrationevaluation/persistence.go new file mode 100644 index 00000000..7aed5657 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationevaluation/persistence.go @@ -0,0 +1,131 @@ +package billingmigrationevaluation + +import ( + "bytes" + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +func (b *Builder) persist(ctx context.Context, lease billingmigration.ExecutionLease, frozen frozenProgram, evaluationID string, evaluationDigest []byte, providerWatermark, asOf time.Time, candidates []candidate) error { + tx, err := b.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + // Re-read all mutable bindings in the write transaction. A candidate may be + // orphaned by a later job settlement, but it can never be written for a + // state/digest/authority tuple that was already stale at commit time. + var stateVersion, epoch int64 + var state string + var policy, manifest, mapping []byte + err = tx.QueryRow(ctx, `SELECT p.state,p.state_version,p.authority_epoch_before,p.policy_digest, + (SELECT manifest_digest FROM billing_migration_source_manifests WHERE program_id=p.id AND project_id=p.project_id ORDER BY captured_at DESC,id DESC LIMIT 1), + (SELECT mapping_digest FROM billing_migration_mapping_sets WHERE program_id=p.id AND project_id=p.project_id AND status='frozen' ORDER BY version DESC LIMIT 1) + FROM billing_migration_programs p WHERE p.id=$1 AND p.project_id=$2 FOR UPDATE`, lease.ProgramID, lease.ProjectID).Scan(&state, &stateVersion, &epoch, &policy, &manifest, &mapping) + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.ErrStaleState + } + if err != nil { + return err + } + if !validProgramBinding(lease, state, stateVersion) { + return billingmigration.ErrStaleState + } + if epoch != frozen.authorityEpoch { + return billingmigration.ErrAuthorityEpoch + } + if !bytes.Equal(policy, frozen.policyDigest) || !bytes.Equal(manifest, lease.ManifestDigest) || !bytes.Equal(mapping, lease.MappingDigest) { + return billingmigration.ErrStaleDigest + } + var bad int + if err = tx.QueryRow(ctx, `SELECT count(*) FROM billing_migration_program_scopes ps LEFT JOIN billing_migration_authority_scopes a + ON a.project_id=$2 AND a.environment_id=$3 AND a.application_id=ps.application_id AND a.platform=ps.platform + WHERE ps.program_id=$1 AND (a.current_authority IS DISTINCT FROM 'source' OR a.current_epoch IS DISTINCT FROM $4 OR a.active_program_id IS DISTINCT FROM $1)`, lease.ProgramID, lease.ProjectID, frozen.environmentID, frozen.authorityEpoch).Scan(&bad); err != nil { + return err + } + if bad != 0 { + return billingmigration.ErrAuthorityEpoch + } + + cohortParts := [][]byte{} + for _, c := range candidates { + cohortParts = append(cohortParts, []byte(c.scope.applicationID), []byte(c.scope.platform), []byte(c.customerID), c.sourceDigest) + } + cohortDigest := hash("mosaic-migration-evaluation-cohort-v1", cohortParts...) + tag, err := tx.Exec(ctx, `INSERT INTO billing_migration_candidate_evaluations(id,program_id,project_id,job_id,job_kind,state_version,authority_epoch,manifest_digest,mapping_digest,policy_digest,evidence_digest,source_watermark,provider_watermark,shadow_watermark,cohort_digest,evaluation_digest,evaluated_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT(program_id,job_id) DO NOTHING`, evaluationID, lease.ProgramID, lease.ProjectID, lease.JobID, lease.JobKind, lease.ExpectedStateVersion, frozen.authorityEpoch, lease.ManifestDigest, lease.MappingDigest, frozen.policyDigest, frozen.evidenceDigest, frozen.sourceWatermark, providerWatermark, asOf, cohortDigest, evaluationDigest, asOf) + if err != nil { + return fmt.Errorf("insert migration candidate evaluation: %w", err) + } + if tag.RowsAffected() == 0 { + var existing []byte + if err = tx.QueryRow(ctx, `SELECT evaluation_digest FROM billing_migration_candidate_evaluations WHERE program_id=$1 AND job_id=$2`, lease.ProgramID, lease.JobID).Scan(&existing); err != nil { + return err + } + if !bytes.Equal(existing, evaluationDigest) { + return billingmigration.ErrIdempotencyConflict + } + } + + for i := range candidates { + c := &candidates[i] + c.snapshotID = stableID("cesm", evaluationID, c.scope.applicationID, c.scope.platform, c.customerID) + if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "billing-projection:customer:"+c.customerID); err != nil { + return err + } + var existingDigest []byte + err = tx.QueryRow(ctx, `SELECT candidate_digest FROM billing_migration_candidate_snapshots WHERE snapshot_id=$1`, c.snapshotID).Scan(&existingDigest) + if err == nil { + if !bytes.Equal(existingDigest, c.candidateDigest) { + return billingmigration.ErrIdempotencyConflict + } + continue + } + if !errors.Is(err, pgx.ErrNoRows) { + return err + } + var version int64 + if err = tx.QueryRow(ctx, `SELECT COALESCE(max(snapshot_version),0)+1 FROM customer_entitlement_snapshots WHERE billing_customer_id=$1 AND environment_id=$2`, c.customerID, frozen.environmentID).Scan(&version); err != nil { + return err + } + if err = insertCandidateSnapshot(ctx, tx, lease, frozen, *c, version, asOf); err != nil { + return err + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_candidate_snapshots(id,evaluation_id,program_id,project_id,environment_id,application_id,platform,billing_customer_id,snapshot_id,source_evidence_digest,candidate_digest,comparison_digest,source_current_access,mosaic_current_access,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, stableID("mcs", evaluationID, c.scope.applicationID, c.scope.platform, c.customerID), evaluationID, lease.ProgramID, lease.ProjectID, frozen.environmentID, c.scope.applicationID, c.scope.platform, c.customerID, c.snapshotID, c.sourceDigest, c.candidateDigest, c.comparisonDigest, c.sourceCurrentAccess, c.mosaicCurrentAccess, asOf) + if err != nil { + return fmt.Errorf("bind migration candidate snapshot: %w", err) + } + } + return tx.Commit(ctx) +} + +func insertCandidateSnapshot(ctx context.Context, tx pgx.Tx, lease billingmigration.ExecutionLease, frozen frozenProgram, c candidate, version int64, now time.Time) error { + _, err := tx.Exec(ctx, `INSERT INTO customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id,snapshot_version,rule_version,computed_at,as_of,previous_snapshot_id,checksum,change_reason,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,NULL,$9,'migration_candidate',$7)`, c.snapshotID, lease.ProjectID, frozen.environmentID, c.customerID, version, billingprojection.RuleVersion, now, c.snapshot.AsOf, c.snapshot.Checksum) + if err != nil { + return fmt.Errorf("insert migration customer candidate: %w", err) + } + for i, e := range c.snapshot.Entries { + _, err = tx.Exec(ctx, `INSERT INTO customer_entitlement_snapshot_entries(id,project_id,customer_entitlement_snapshot_id,entitlement_id,entitlement_key,state,effective_start,effective_end,end_known,source_count,uncertainty_reason,is_test_source,explanation_code) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, stableID("cee", c.snapshotID, fmt.Sprint(i)), lease.ProjectID, c.snapshotID, e.EntitlementID, e.EntitlementKey, e.State, e.EffectiveStart, e.EffectiveEnd, e.EndKnown, e.SourceCount, e.UncertaintyReason, e.IsTestSource, e.ExplanationCode) + if err != nil { + return fmt.Errorf("insert migration candidate entry: %w", err) + } + } + for i, s := range c.snapshot.Sources { + _, err = tx.Exec(ctx, `INSERT INTO entitlement_sources(id,project_id,environment_id,customer_entitlement_snapshot_id,billing_customer_id,entitlement_id,purchase_lineage_id,product_id,grant_version_id,subscription_instance_id,one_time_purchase_instance_id,source_snapshot_id,source_type,source_state,source_start,source_end,end_known,uncertainty_reason,is_test_source,explanation_code,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,NULLIF($10,''),NULLIF($11,''),NULL,$12,$13,$14,$15,$16,$17,$18,$19,$20)`, stableID("esr", c.snapshotID, fmt.Sprint(i)), lease.ProjectID, frozen.environmentID, c.snapshotID, c.customerID, s.EntitlementID, s.PurchaseLineageID, s.ProductID, s.GrantVersionID, s.SubscriptionInstanceID, s.OneTimePurchaseInstanceID, s.SourceType, s.SourceState, s.SourceStart, s.SourceEnd, s.EndKnown, s.UncertaintyReason, s.IsTestSource, s.ExplanationCode, now) + if err != nil { + return fmt.Errorf("insert migration candidate source: %w", err) + } + } + return nil +} diff --git a/apps/api/internal/platform/billingmigrationobject/cipher.go b/apps/api/internal/platform/billingmigrationobject/cipher.go new file mode 100644 index 00000000..a284042f --- /dev/null +++ b/apps/api/internal/platform/billingmigrationobject/cipher.go @@ -0,0 +1,422 @@ +// Package billingmigrationobject implements Mosaic's streaming authenticated +// source-object envelope. It is intentionally separate from the small-secret +// provider credential envelope because source objects can be 100 MiB. +package billingmigrationobject + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "io" + "sort" + "strings" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +const ( + EnvelopeVersion = 1 + Algorithm = "AES-256-GCM-CHUNKED" + DefaultChunkSize = 256 * 1024 + minChunkSize = 16 * 1024 + maxChunkSize = 4 * 1024 * 1024 +) + +var magic = [8]byte{'M', 'S', 'O', 'B', 'J', '0', '0', '1'} + +type Cipher struct { + activeKeyID string + keys map[string][]byte + chunkSize int + random io.Reader +} + +func NewCipher(keyID string, key []byte, chunkSize int) (*Cipher, error) { + if keyID == "" || len(key) != 32 { + return nil, errors.New("source-object cipher requires a key id and 32-byte key") + } + if chunkSize == 0 { + chunkSize = DefaultChunkSize + } + if chunkSize < minChunkSize || chunkSize > maxChunkSize { + return nil, errors.New("source-object chunk size is outside the allowed range") + } + return &Cipher{activeKeyID: keyID, keys: map[string][]byte{keyID: append([]byte(nil), key...)}, chunkSize: chunkSize, random: rand.Reader}, nil +} + +// NewKeyringCipher constructs a rotation-capable source-object cipher. New +// objects use activeKeyId while every retained key remains available to open +// older immutable objects during the retention and rollback windows. +func NewKeyringCipher(encoded string, chunkSize int) (*Cipher, error) { + active, keys, err := parseKeyring(encoded) + if err != nil { + return nil, err + } + if chunkSize == 0 { + chunkSize = DefaultChunkSize + } + if chunkSize < minChunkSize || chunkSize > maxChunkSize { + return nil, errors.New("source-object chunk size is outside the allowed range") + } + return &Cipher{activeKeyID: active, keys: keys, chunkSize: chunkSize, random: rand.Reader}, nil +} + +// ValidateKeyring checks configuration without retaining or returning key +// material. The format intentionally matches Mosaic's other versioned AES key +// rings while remaining a separate cryptographic category. +func ValidateKeyring(encoded string) error { + _, _, err := parseKeyring(encoded) + return err +} + +func (c *Cipher) ActiveKeyID() string { return c.activeKeyID } + +func (c *Cipher) KeyIDs() []string { + ids := make([]string, 0, len(c.keys)) + for id := range c.keys { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +func (c *Cipher) Encrypt(ctx context.Context, scope billingmigration.SourceObjectScope, plaintext io.Reader, output io.Writer) (billingmigration.SourceObjectEnvelope, error) { + key, ok := c.keys[c.activeKeyID] + if !ok { + return billingmigration.SourceObjectEnvelope{}, errors.New("source-object active key is unavailable") + } + block, err := aes.NewCipher(key) + if err != nil { + return billingmigration.SourceObjectEnvelope{}, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return billingmigration.SourceObjectEnvelope{}, err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := io.ReadFull(c.random, nonce); err != nil { + return billingmigration.SourceObjectEnvelope{}, err + } + aadDigest := sha256.Sum256(scope.AAD()) + plainHash, cipherHash := sha256.New(), sha256.New() + counting := &countWriter{writer: io.MultiWriter(output, cipherHash)} + if err := writeHeader(counting, nonce, aadDigest[:], c.activeKeyID, c.chunkSize); err != nil { + return billingmigration.SourceObjectEnvelope{}, err + } + + limited := &io.LimitedReader{R: plaintext, N: billingmigration.SourceObjectMaxPlaintext + 1} + buffer := make([]byte, c.chunkSize) + var plaintextSize int64 + chunkCount := 0 + for { + if err := ctx.Err(); err != nil { + return billingmigration.SourceObjectEnvelope{}, err + } + n, readErr := io.ReadFull(limited, buffer) + if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF { + return billingmigration.SourceObjectEnvelope{}, readErr + } + if n > 0 { + plaintextSize += int64(n) + if plaintextSize > billingmigration.SourceObjectMaxPlaintext { + return billingmigration.SourceObjectEnvelope{}, billingmigration.ErrSourceObjectTooLarge + } + _, _ = plainHash.Write(buffer[:n]) + sealed := aead.Seal(nil, chunkNonce(nonce, uint32(chunkCount)), buffer[:n], chunkAAD(scope.AAD(), uint32(chunkCount), uint32(n), false)) + if err := binary.Write(counting, binary.BigEndian, uint32(n)); err != nil { + return billingmigration.SourceObjectEnvelope{}, err + } + if _, err := counting.Write(sealed); err != nil { + return billingmigration.SourceObjectEnvelope{}, err + } + chunkCount++ + } + if readErr == io.EOF || readErr == io.ErrUnexpectedEOF { + break + } + } + // An authenticated terminal record distinguishes a complete stream from a + // valid prefix whose final bytes were truncated. + if err := binary.Write(counting, binary.BigEndian, uint32(0)); err != nil { + return billingmigration.SourceObjectEnvelope{}, err + } + terminal := aead.Seal(nil, chunkNonce(nonce, uint32(chunkCount)), nil, chunkAAD(scope.AAD(), uint32(chunkCount), 0, true)) + if _, err := counting.Write(terminal); err != nil { + return billingmigration.SourceObjectEnvelope{}, err + } + return billingmigration.SourceObjectEnvelope{Version: EnvelopeVersion, Algorithm: Algorithm, KeyID: c.activeKeyID, Nonce: nonce, + ChunkSize: c.chunkSize, ChunkCount: chunkCount, AADDigest: aadDigest[:], PlaintextDigest: plainHash.Sum(nil), + PlaintextSize: plaintextSize, CiphertextDigest: cipherHash.Sum(nil), CiphertextSize: counting.count}, nil +} + +func (c *Cipher) Decrypt(ctx context.Context, scope billingmigration.SourceObjectScope, expected billingmigration.SourceObjectEnvelope, input io.Reader, output io.Writer) error { + if expected.Version != EnvelopeVersion || expected.Algorithm != Algorithm { + return billingmigration.ErrSourceObjectCorrupt + } + key, ok := c.keys[expected.KeyID] + if !ok { + return billingmigration.ErrSourceObjectCorrupt + } + cipherHash := sha256.New() + counting := &countReader{reader: io.TeeReader(input, cipherHash)} + nonce, aadDigest, keyID, chunkSize, err := readHeader(counting) + actualAAD := sha256.Sum256(scope.AAD()) + if err != nil || keyID != expected.KeyID || chunkSize != expected.ChunkSize || !bytes.Equal(nonce, expected.Nonce) || !bytes.Equal(aadDigest, expected.AADDigest) || !bytes.Equal(aadDigest, actualAAD[:]) { + return billingmigration.ErrSourceObjectCorrupt + } + block, _ := aes.NewCipher(key) + aead, _ := cipher.NewGCM(block) + plainHash := sha256.New() + plainWriter := io.MultiWriter(output, plainHash) + var plaintextSize int64 + chunkCount := 0 + for { + if err := ctx.Err(); err != nil { + return err + } + var size uint32 + if err := binary.Read(counting, binary.BigEndian, &size); err != nil { + return billingmigration.ErrSourceObjectCorrupt + } + if size > uint32(chunkSize) { + return billingmigration.ErrSourceObjectCorrupt + } + sealed := make([]byte, int(size)+aead.Overhead()) + if _, err := io.ReadFull(counting, sealed); err != nil { + return billingmigration.ErrSourceObjectCorrupt + } + final := size == 0 + opened, err := aead.Open(nil, chunkNonce(nonce, uint32(chunkCount)), sealed, chunkAAD(scope.AAD(), uint32(chunkCount), size, final)) + if err != nil { + return billingmigration.ErrSourceObjectCorrupt + } + if final { + if len(opened) != 0 { + return billingmigration.ErrSourceObjectCorrupt + } + break + } + plaintextSize += int64(len(opened)) + if plaintextSize > billingmigration.SourceObjectMaxPlaintext { + return billingmigration.ErrSourceObjectTooLarge + } + if _, err := plainWriter.Write(opened); err != nil { + return err + } + chunkCount++ + } + var trailing [1]byte + if n, err := counting.Read(trailing[:]); n != 0 || err != io.EOF { + return billingmigration.ErrSourceObjectCorrupt + } + if counting.count != expected.CiphertextSize || plaintextSize != expected.PlaintextSize || chunkCount != expected.ChunkCount || !bytes.Equal(cipherHash.Sum(nil), expected.CiphertextDigest) || !bytes.Equal(plainHash.Sum(nil), expected.PlaintextDigest) { + return billingmigration.ErrSourceObjectCorrupt + } + return nil +} + +func parseKeyring(encoded string) (string, map[string][]byte, error) { + decoder := json.NewDecoder(strings.NewReader(encoded)) + first, err := decoder.Token() + if err != nil || first != json.Delim('{') { + return "", nil, errors.New("invalid source-object keyring") + } + seen := make(map[string]struct{}, 3) + version, active := 0, "" + var encodedKeys map[string]string + for decoder.More() { + nameToken, tokenErr := decoder.Token() + name, ok := nameToken.(string) + if tokenErr != nil || !ok { + return "", nil, errors.New("invalid source-object keyring") + } + if _, duplicate := seen[name]; duplicate { + return "", nil, errors.New("invalid source-object keyring") + } + seen[name] = struct{}{} + switch name { + case "version": + if err := decoder.Decode(&version); err != nil { + return "", nil, errors.New("invalid source-object keyring") + } + case "activeKeyId": + if err := decoder.Decode(&active); err != nil { + return "", nil, errors.New("invalid source-object keyring") + } + case "keys": + encodedKeys, err = decodeKeyMap(decoder) + if err != nil { + return "", nil, err + } + default: + return "", nil, errors.New("invalid source-object keyring") + } + } + if last, err := decoder.Token(); err != nil || last != json.Delim('}') { + return "", nil, errors.New("invalid source-object keyring") + } + if _, err := decoder.Token(); err != io.EOF || version != 1 || !validKeyID(active) || len(encodedKeys) == 0 { + return "", nil, errors.New("invalid source-object keyring") + } + keys := make(map[string][]byte, len(encodedKeys)) + for id, value := range encodedKeys { + if !validKeyID(id) || strings.Contains(value, "=") { + return "", nil, errors.New("invalid source-object keyring") + } + key, err := base64.RawURLEncoding.DecodeString(value) + if err != nil || len(key) != 32 || base64.RawURLEncoding.EncodeToString(key) != value { + return "", nil, errors.New("invalid source-object keyring") + } + keys[id] = append([]byte(nil), key...) + } + if _, ok := keys[active]; !ok { + return "", nil, errors.New("invalid source-object keyring") + } + return active, keys, nil +} + +func decodeKeyMap(decoder *json.Decoder) (map[string]string, error) { + first, err := decoder.Token() + if err != nil || first != json.Delim('{') { + return nil, errors.New("invalid source-object keyring") + } + values := make(map[string]string) + for decoder.More() { + keyToken, tokenErr := decoder.Token() + key, ok := keyToken.(string) + if tokenErr != nil || !ok { + return nil, errors.New("invalid source-object keyring") + } + if _, duplicate := values[key]; duplicate { + return nil, errors.New("invalid source-object keyring") + } + var value string + if err := decoder.Decode(&value); err != nil { + return nil, errors.New("invalid source-object keyring") + } + values[key] = value + } + if last, err := decoder.Token(); err != nil || last != json.Delim('}') { + return nil, errors.New("invalid source-object keyring") + } + return values, nil +} + +func validKeyID(value string) bool { + if len(value) < 1 || len(value) > 64 { + return false + } + for i := range len(value) { + if value[i] < 0x20 || value[i] > 0x7e { + return false + } + } + return true +} + +func writeHeader(w io.Writer, nonce, aadDigest []byte, keyID string, chunkSize int) error { + if _, err := w.Write(magic[:]); err != nil { + return err + } + if err := binary.Write(w, binary.BigEndian, uint16(EnvelopeVersion)); err != nil { + return err + } + if err := binary.Write(w, binary.BigEndian, uint32(chunkSize)); err != nil { + return err + } + if _, err := w.Write(nonce); err != nil { + return err + } + if _, err := w.Write(aadDigest); err != nil { + return err + } + if len(keyID) > 255 { + return errors.New("key id too long") + } + if err := binary.Write(w, binary.BigEndian, uint8(len(keyID))); err != nil { + return err + } + _, err := io.WriteString(w, keyID) + return err +} + +func readHeader(r io.Reader) ([]byte, []byte, string, int, error) { + var got [8]byte + if _, err := io.ReadFull(r, got[:]); err != nil || got != magic { + return nil, nil, "", 0, billingmigration.ErrSourceObjectCorrupt + } + var version uint16 + var chunkSize uint32 + if binary.Read(r, binary.BigEndian, &version) != nil || version != EnvelopeVersion || binary.Read(r, binary.BigEndian, &chunkSize) != nil || chunkSize < minChunkSize || chunkSize > maxChunkSize { + return nil, nil, "", 0, billingmigration.ErrSourceObjectCorrupt + } + nonce, aad := make([]byte, 12), make([]byte, 32) + if _, err := io.ReadFull(r, nonce); err != nil { + return nil, nil, "", 0, err + } + if _, err := io.ReadFull(r, aad); err != nil { + return nil, nil, "", 0, err + } + var keyLen uint8 + if binary.Read(r, binary.BigEndian, &keyLen) != nil || keyLen == 0 { + return nil, nil, "", 0, billingmigration.ErrSourceObjectCorrupt + } + key := make([]byte, keyLen) + if _, err := io.ReadFull(r, key); err != nil { + return nil, nil, "", 0, err + } + return nonce, aad, string(key), int(chunkSize), nil +} + +func chunkNonce(base []byte, index uint32) []byte { + nonce := append([]byte(nil), base...) + tail := binary.BigEndian.Uint32(nonce[len(nonce)-4:]) + binary.BigEndian.PutUint32(nonce[len(nonce)-4:], tail^index) + return nonce +} +func chunkAAD(aad []byte, index, size uint32, final bool) []byte { + result := make([]byte, 0, len(aad)+9) + result = append(result, aad...) + var value [4]byte + binary.BigEndian.PutUint32(value[:], index) + result = append(result, value[:]...) + binary.BigEndian.PutUint32(value[:], size) + result = append(result, value[:]...) + if final { + result = append(result, 1) + } else { + result = append(result, 0) + } + return result +} + +type countWriter struct { + writer io.Writer + count int64 +} + +func (w *countWriter) Write(p []byte) (int, error) { + n, err := w.writer.Write(p) + w.count += int64(n) + return n, err +} + +type countReader struct { + reader io.Reader + count int64 +} + +func (r *countReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + r.count += int64(n) + return n, err +} + +var _ billingmigration.SourceObjectCipher = (*Cipher)(nil) diff --git a/apps/api/internal/platform/billingmigrationobject/cipher_test.go b/apps/api/internal/platform/billingmigrationobject/cipher_test.go new file mode 100644 index 00000000..079e8470 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationobject/cipher_test.go @@ -0,0 +1,141 @@ +package billingmigrationobject + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "testing" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +func TestChunkedEnvelopeRoundTripAndCiphertextDiffers(t *testing.T) { + cipher := testCipher(t) + scope := testScope("project_one") + plaintext := bytes.Repeat([]byte("migration-evidence-"), 5000) + var encrypted bytes.Buffer + metadata, err := cipher.Encrypt(context.Background(), scope, bytes.NewReader(plaintext), &encrypted) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encrypted.Bytes(), plaintext[:256]) { + t.Fatal("ciphertext contains a plaintext prefix") + } + var opened bytes.Buffer + if err := cipher.Decrypt(context.Background(), scope, metadata, bytes.NewReader(encrypted.Bytes()), &opened); err != nil { + t.Fatal(err) + } + if !bytes.Equal(opened.Bytes(), plaintext) { + t.Fatal("roundtrip plaintext differs") + } +} + +func TestChunkedEnvelopeRejectsCrossAADTruncationAndTamper(t *testing.T) { + cipher := testCipher(t) + scope := testScope("project_one") + var encrypted bytes.Buffer + metadata, err := cipher.Encrypt(context.Background(), scope, bytes.NewReader(bytes.Repeat([]byte("x"), 40000)), &encrypted) + if err != nil { + t.Fatal(err) + } + if err := cipher.Decrypt(context.Background(), testScope("project_two"), metadata, bytes.NewReader(encrypted.Bytes()), io.Discard); !errors.Is(err, billingmigration.ErrSourceObjectCorrupt) { + t.Fatalf("cross-AAD error = %v", err) + } + truncated := encrypted.Bytes()[:encrypted.Len()-1] + if err := cipher.Decrypt(context.Background(), scope, metadata, bytes.NewReader(truncated), io.Discard); !errors.Is(err, billingmigration.ErrSourceObjectCorrupt) { + t.Fatalf("truncation error = %v", err) + } + tampered := append([]byte(nil), encrypted.Bytes()...) + tampered[len(tampered)/2] ^= 0x80 + if err := cipher.Decrypt(context.Background(), scope, metadata, bytes.NewReader(tampered), io.Discard); !errors.Is(err, billingmigration.ErrSourceObjectCorrupt) { + t.Fatalf("tamper error = %v", err) + } +} + +func TestChunkedEnvelopeRejectsOversizeWithoutBuffering(t *testing.T) { + cipher := testCipher(t) + reader := io.LimitReader(zeroes{}, billingmigration.SourceObjectMaxPlaintext+1) + if _, err := cipher.Encrypt(context.Background(), testScope("project_one"), reader, io.Discard); !errors.Is(err, billingmigration.ErrSourceObjectTooLarge) { + t.Fatalf("oversize error = %v", err) + } +} + +func TestKeyringRotationRetainsOldObjectDecryption(t *testing.T) { + oldKey := bytes.Repeat([]byte{0x31}, 32) + newKey := bytes.Repeat([]byte{0x32}, 32) + oldCipher, err := NewKeyringCipher(keyring("old", map[string][]byte{"old": oldKey}), 16*1024) + if err != nil { + t.Fatal(err) + } + oldCipher.random = bytes.NewReader(bytes.Repeat([]byte{0x41}, 12)) + var encrypted bytes.Buffer + metadata, err := oldCipher.Encrypt(context.Background(), testScope("project_one"), bytes.NewBufferString("retained migration evidence"), &encrypted) + if err != nil { + t.Fatal(err) + } + rotated, err := NewKeyringCipher(keyring("new", map[string][]byte{"old": oldKey, "new": newKey}), 16*1024) + if err != nil { + t.Fatal(err) + } + if rotated.ActiveKeyID() != "new" || fmt.Sprint(rotated.KeyIDs()) != "[new old]" { + t.Fatalf("rotated keyring active=%q ids=%v", rotated.ActiveKeyID(), rotated.KeyIDs()) + } + var opened bytes.Buffer + if err := rotated.Decrypt(context.Background(), testScope("project_one"), metadata, bytes.NewReader(encrypted.Bytes()), &opened); err != nil { + t.Fatal(err) + } + if opened.String() != "retained migration evidence" { + t.Fatalf("opened = %q", opened.String()) + } +} + +func TestKeyringRejectsUnknownFieldsAndMissingEnvelopeKey(t *testing.T) { + valid := keyring("active", map[string][]byte{"active": bytes.Repeat([]byte{0x51}, 32)}) + if err := ValidateKeyring(valid); err != nil { + t.Fatal(err) + } + if err := ValidateKeyring(`{"version":1,"activeKeyId":"active","keys":{},"extra":true}`); err == nil { + t.Fatal("unknown keyring field was accepted") + } + if err := ValidateKeyring(`{"version":1,"activeKeyId":"active","activeKeyId":"other","keys":{"active":"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI"}}`); err == nil { + t.Fatal("duplicate keyring field was accepted") + } + cipher, err := NewKeyringCipher(valid, 16*1024) + if err != nil { + t.Fatal(err) + } + metadata := billingmigration.SourceObjectEnvelope{Version: EnvelopeVersion, Algorithm: Algorithm, KeyID: "retired"} + if err := cipher.Decrypt(context.Background(), testScope("project_one"), metadata, bytes.NewReader(nil), io.Discard); !errors.Is(err, billingmigration.ErrSourceObjectCorrupt) { + t.Fatalf("missing key error = %v", err) + } +} + +func keyring(active string, keys map[string][]byte) string { + encoded := make(map[string]string, len(keys)) + for id, key := range keys { + encoded[id] = base64.RawURLEncoding.EncodeToString(key) + } + raw, _ := json.Marshal(map[string]any{"version": 1, "activeKeyId": active, "keys": encoded}) + return string(raw) +} + +func testCipher(t *testing.T) *Cipher { + t.Helper() + cipher, err := NewCipher("source-key-one", bytes.Repeat([]byte{0x42}, 32), 16*1024) + if err != nil { + t.Fatal(err) + } + cipher.random = bytes.NewReader(bytes.Repeat([]byte{0x24}, 12)) + return cipher +} +func testScope(project string) billingmigration.SourceObjectScope { + return billingmigration.SourceObjectScope{ProjectID: project, ProgramID: "program_one", ObjectID: "object_one", AdapterVersion: billingmigration.AdapterVersion, SchemaVersion: "v1"} +} + +type zeroes struct{} + +func (zeroes) Read(p []byte) (int, error) { clear(p); return len(p), nil } diff --git a/apps/api/internal/platform/billingmigrationpostgres/capability_assessment.go b/apps/api/internal/platform/billingmigrationpostgres/capability_assessment.go new file mode 100644 index 00000000..41068fa5 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/capability_assessment.go @@ -0,0 +1,90 @@ +package billingmigrationpostgres + +import ( + "bytes" + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +var _ billingmigration.CapabilityAssessmentRepository = (*Repository)(nil) + +func (r *Repository) AppendCapabilityAssessment(ctx context.Context, command billingmigration.CapabilityAssessmentAppend) (billingmigration.CapabilityAssessment, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.CapabilityAssessment{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + assessment, appended, err := appendCapabilityAssessmentTx(ctx, tx, command) + if err != nil { + return assessment, appended, err + } + if err = tx.Commit(ctx); err != nil { + return assessment, appended, err + } + return assessment, appended, nil +} + +func appendCapabilityAssessmentTx(ctx context.Context, tx pgx.Tx, command billingmigration.CapabilityAssessmentAppend) (billingmigration.CapabilityAssessment, bool, error) { + capabilities, err := billingmigration.NormalizeSourceCapabilities(command.Capabilities) + if err != nil || command.AssessmentID == "" || len(command.SourceEvidenceDigest) != 32 || len(command.AssessmentDigest) != 32 { + return billingmigration.CapabilityAssessment{}, false, billingmigration.ErrInvalid + } + expected := billingmigration.SourceCapabilityAssessmentDigest(command.ProgramID, command.ProjectID, command.StateVersion, command.ProviderAPIVersion, capabilities, command.SourceEvidenceDigest) + if !bytes.Equal(expected, command.AssessmentDigest) { + return billingmigration.CapabilityAssessment{}, false, billingmigration.ErrStaleDigest + } + var currentVersion int64 + if err = tx.QueryRow(ctx, `SELECT state_version FROM billing_migration_programs WHERE id=$1 AND project_id=$2 FOR UPDATE`, command.ProgramID, command.ProjectID).Scan(¤tVersion); errors.Is(err, pgx.ErrNoRows) { + return billingmigration.CapabilityAssessment{}, false, billingmigration.ErrNotFound + } else if err != nil { + return billingmigration.CapabilityAssessment{}, false, err + } + if currentVersion != command.StateVersion { + return billingmigration.CapabilityAssessment{}, false, billingmigration.ErrStaleState + } + var latest billingmigration.CapabilityAssessment + err = tx.QueryRow(ctx, `SELECT state_version,provider_api_version,capabilities,assessed_at FROM billing_migration_capability_assessments WHERE program_id=$1 AND project_id=$2 ORDER BY assessed_at DESC,id DESC LIMIT 1`, command.ProgramID, command.ProjectID).Scan(&latest.StateVersion, &latest.ProviderAPIVersion, &latest.Capabilities, &latest.AssessedAt) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return billingmigration.CapabilityAssessment{}, false, err + } + if err == nil && !strictCapabilitySuperset(capabilities, latest.Capabilities) { + latest.ProgramID, latest.Adapter = command.ProgramID, billingmigration.AdapterRevenueCat + return latest, false, nil + } + var assessedAt time.Time + tag, err := tx.Exec(ctx, `INSERT INTO billing_migration_capability_assessments(id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) + VALUES($1,$2,$3,$4,$5,$6,$7,clock_timestamp()) ON CONFLICT(program_id,assessment_digest) DO NOTHING`, command.AssessmentID, command.ProgramID, command.ProjectID, command.StateVersion, command.ProviderAPIVersion, capabilities, command.AssessmentDigest) + if err != nil { + return billingmigration.CapabilityAssessment{}, false, translate(err, "append migration capability assessment") + } + if tag.RowsAffected() == 0 { + err = tx.QueryRow(ctx, `SELECT assessed_at FROM billing_migration_capability_assessments WHERE program_id=$1 AND project_id=$2 AND assessment_digest=$3`, command.ProgramID, command.ProjectID, command.AssessmentDigest).Scan(&assessedAt) + } else { + err = tx.QueryRow(ctx, `SELECT assessed_at FROM billing_migration_capability_assessments WHERE id=$1 AND program_id=$2 AND project_id=$3`, command.AssessmentID, command.ProgramID, command.ProjectID).Scan(&assessedAt) + } + if err != nil { + return billingmigration.CapabilityAssessment{}, false, err + } + return billingmigration.CapabilityAssessment{ProgramID: command.ProgramID, StateVersion: command.StateVersion, Adapter: billingmigration.AdapterRevenueCat, ProviderAPIVersion: command.ProviderAPIVersion, Capabilities: capabilities, AssessedAt: assessedAt}, tag.RowsAffected() == 1, nil +} + +func strictCapabilitySuperset(candidate, baseline []string) bool { + if len(candidate) <= len(baseline) { + return false + } + seen := make(map[string]bool, len(candidate)) + for _, capability := range candidate { + seen[capability] = true + } + for _, capability := range baseline { + if !seen[capability] { + return false + } + } + return true +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/cases.go b/apps/api/internal/platform/billingmigrationpostgres/cases.go new file mode 100644 index 00000000..c56621f6 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/cases.go @@ -0,0 +1,134 @@ +package billingmigrationpostgres + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/jackc/pgx/v5" +) + +var _ billingmigration.OperationsRepository = (*Repository)(nil) + +func operationCommandReplay(ctx context.Context, tx pgx.Tx, programID, kind, key string, requestDigest []byte) (string, bool, error) { + var resource string + var stored []byte + err := tx.QueryRow(ctx, `SELECT resource_id,request_digest FROM billing_migration_command_idempotency WHERE program_id=$1 AND command_kind=$2 AND idempotency_key=$3`, programID, kind, key).Scan(&resource, &stored) + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("read operation idempotency: %w", err) + } + if !bytes.Equal(stored, requestDigest) { + return "", false, billingmigration.ErrIdempotencyConflict + } + return resource, true, nil +} + +func (r *Repository) CreateCase(ctx context.Context, w billingmigration.CaseWrite) (billingmigration.MigrationCase, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.MigrationCase{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + resource, replay, err := operationCommandReplay(ctx, tx, w.Case.ProgramID, "create_case", w.IdempotencyKey, w.RequestDigest) + if err != nil { + return billingmigration.MigrationCase{}, false, err + } + if replay { + c, err := caseByID(ctx, tx, w.Case.ProjectID, w.Case.ProgramID, resource) + return c, true, err + } + var state int64 + var environment, organization string + if err = tx.QueryRow(ctx, `SELECT p.state_version,p.environment_id,pr.organization_id FROM billing_migration_programs p JOIN projects pr ON pr.id=p.project_id WHERE p.id=$1 AND p.project_id=$2 FOR UPDATE`, w.Case.ProgramID, w.Case.ProjectID).Scan(&state, &environment, &organization); err != nil { + return billingmigration.MigrationCase{}, false, translate(err, "lock program for case") + } + if state != w.ExpectedState { + return billingmigration.MigrationCase{}, false, billingmigration.ErrStaleState + } + caseRaw, _ := billingmigration.ParseDigest(w.Case.CaseDigest) + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_cases(id,program_id,project_id,state_version,classification,status,reason,case_digest,opened_at,resolved_at,updated_at,linked_divergence_id,linked_source_record_id) VALUES($1,$2,$3,$4,$5,'open',$6,$7,$8,NULL,$8,NULLIF($9,''),NULLIF($10,''))`, w.Case.CaseID, w.Case.ProgramID, w.Case.ProjectID, w.Case.StateVersion, w.Case.Classification, w.Case.Reason, caseRaw, w.Case.OpenedAt, w.LinkedDivergence, w.LinkedRecord) + if err != nil { + return billingmigration.MigrationCase{}, false, translate(err, "insert migration case") + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_command_idempotency(id,program_id,project_id,command_kind,idempotency_key,request_digest,resource_id,created_at) VALUES('mci_'||$1,$2,$3,'create_case',$4,$5,$1,$6)`, w.Case.CaseID, w.Case.ProgramID, w.Case.ProjectID, w.IdempotencyKey, w.RequestDigest, w.Case.OpenedAt) + if err != nil { + return billingmigration.MigrationCase{}, false, translate(err, "insert case idempotency") + } + metadata, _ := json.Marshal(map[string]any{"classification": w.Case.Classification}) + _, err = tx.Exec(ctx, `INSERT INTO audit_events(id,actor_id,organization_id,project_id,environment_id,action,resource_type,resource_id,metadata,created_at) VALUES('aud_'||$1,$2,$3,$4,$5,'billing.migration.case.created','billing_migration_case',$1,$6,$7)`, w.Case.CaseID, w.ActorID, organization, w.Case.ProjectID, environment, metadata, w.Case.OpenedAt) + if err != nil { + return billingmigration.MigrationCase{}, false, err + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.MigrationCase{}, false, err + } + return w.Case, false, nil +} + +func (r *Repository) TransitionCase(ctx context.Context, w billingmigration.CaseTransitionWrite) (billingmigration.MigrationCase, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.MigrationCase{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + var current []byte + var caseState int64 + var environment, organization string + err = tx.QueryRow(ctx, `SELECT c.case_digest,c.state_version,p.environment_id,pr.organization_id FROM billing_migration_cases c JOIN billing_migration_programs p ON p.id=c.program_id JOIN projects pr ON pr.id=c.project_id WHERE c.id=$1 AND c.program_id=$2 AND c.project_id=$3 FOR UPDATE OF c`, w.CaseID, w.ProgramID, w.ProjectID).Scan(¤t, &caseState, &environment, &organization) + if err != nil { + return billingmigration.MigrationCase{}, translate(err, "lock migration case") + } + if caseState != w.ExpectedStateVersion || !bytes.Equal(current, w.ExpectedCaseDigest) { + return billingmigration.MigrationCase{}, billingmigration.ErrStaleDigest + } + var resolved any = nil + if w.Status == "resolved" || w.Status == "dismissed" { + resolved = w.At + } + tag, err := tx.Exec(ctx, `UPDATE billing_migration_cases SET status=$1,state_version=state_version+1,case_digest=$2,updated_at=$3,resolved_at=$4 WHERE id=$5 AND state_version=$6`, w.Status, w.NewCaseDigest, w.At, resolved, w.CaseID, w.ExpectedStateVersion) + if err != nil || tag.RowsAffected() != 1 { + if err == nil { + return billingmigration.MigrationCase{}, billingmigration.ErrStaleState + } + return billingmigration.MigrationCase{}, err + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_case_actions(id,case_id,program_id,project_id,actor_id,action,before_digest,after_digest,created_at) VALUES('mca_'||substr(encode($1,'hex'),1,24),$2,$3,$4,$5,$6,$1,$7,$8)`, w.ExpectedCaseDigest, w.CaseID, w.ProgramID, w.ProjectID, w.ActorID, "transition:"+w.Status, w.NewCaseDigest, w.At) + if err != nil { + return billingmigration.MigrationCase{}, translate(err, "append case action") + } + metadata, _ := json.Marshal(map[string]any{"status": w.Status, "reason": w.Reason}) + _, err = tx.Exec(ctx, `INSERT INTO audit_events(id,actor_id,organization_id,project_id,environment_id,action,resource_type,resource_id,metadata,created_at) VALUES('aud_mcase_'||substr(encode($1,'hex'),1,20),$2,$3,$4,$5,'billing.migration.case.transitioned','billing_migration_case',$6,$7,$8)`, w.NewCaseDigest, w.ActorID, organization, w.ProjectID, environment, w.CaseID, metadata, w.At) + if err != nil { + return billingmigration.MigrationCase{}, err + } + c, err := caseByID(ctx, tx, w.ProjectID, w.ProgramID, w.CaseID) + if err != nil { + return c, err + } + if err = tx.Commit(ctx); err != nil { + return c, err + } + return c, nil +} + +func caseByID(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, projectID, programID, caseID string) (billingmigration.MigrationCase, error) { + var c billingmigration.MigrationCase + var raw []byte + err := q.QueryRow(ctx, `SELECT id,program_id,project_id,state_version,classification,status,reason,case_digest,opened_at,updated_at,resolved_at,COALESCE(linked_divergence_id,''),COALESCE(linked_source_record_id,'') FROM billing_migration_cases WHERE id=$1 AND program_id=$2 AND project_id=$3`, caseID, programID, projectID).Scan(&c.CaseID, &c.ProgramID, &c.ProjectID, &c.StateVersion, &c.Classification, &c.Status, &c.Reason, &raw, &c.OpenedAt, &c.UpdatedAt, &c.ResolvedAt, &c.LinkedDivergenceID, &c.LinkedSourceRecordID) + if errors.Is(err, pgx.ErrNoRows) { + return c, billingmigration.ErrNotFound + } + if err != nil { + return c, err + } + c.CaseDigest = billingmigration.FormatDigest(raw) + return c, nil +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/completion.go b/apps/api/internal/platform/billingmigrationpostgres/completion.go new file mode 100644 index 00000000..632f5fab --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/completion.go @@ -0,0 +1,497 @@ +package billingmigrationpostgres + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +func (r *Repository) RemoveCredential(ctx context.Context, w billingmigration.CredentialRemovalWrite) (billingmigration.CredentialRemoval, bool, error) { + if !w.IrreversibleAck { + return billingmigration.CredentialRemoval{}, false, billingmigration.ErrInvalid + } + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.CredentialRemoval{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + resource, replay, err := operationCommandReplay(ctx, tx, w.Removal.ProgramID, "remove_credential", w.IdempotencyKey, w.RequestDigest) + if err != nil { + return billingmigration.CredentialRemoval{}, false, err + } + if replay { + var out billingmigration.CredentialRemoval + err = scanRemoval(tx.QueryRow(ctx, `SELECT id,program_id,project_id,credential_id,reason,actor_id,early_removal,removal_digest,removed_at FROM billing_migration_credential_removals WHERE id=$1`, resource), &out) + return out, true, err + } + var state, credentialID, environment, organization string + var stateVersion int64 + var nonce, ciphertext []byte + var removedAt *time.Time + var rollbackDays int + var transitionedAt *time.Time + err = tx.QueryRow(ctx, `SELECT p.state,p.state_version,p.credential_id,p.environment_id,pr.organization_id,p.rollback_window_days,c.nonce,c.ciphertext,c.removed_at,(SELECT max(transitioned_at) FROM billing_migration_authority_transitions WHERE program_id=p.id AND transition_kind='cutover') FROM billing_migration_programs p JOIN projects pr ON pr.id=p.project_id JOIN billing_migration_credentials c ON c.id=p.credential_id AND c.project_id=p.project_id WHERE p.id=$1 AND p.project_id=$2 FOR UPDATE OF p,c`, w.Removal.ProgramID, w.Removal.ProjectID).Scan(&state, &stateVersion, &credentialID, &environment, &organization, &rollbackDays, &nonce, &ciphertext, &removedAt, &transitionedAt) + if err != nil { + return billingmigration.CredentialRemoval{}, false, translate(err, "lock migration credential") + } + if stateVersion != w.ExpectedState { + return billingmigration.CredentialRemoval{}, false, billingmigration.ErrStaleState + } + if removedAt != nil { + return billingmigration.CredentialRemoval{}, false, billingmigration.ErrConflict + } + if state != "stabilizing" && state != "rolled_back" && state != "ready" && state != "cutover_pending" { + return billingmigration.CredentialRemoval{}, false, billingmigration.ErrConflict + } + envelope := sha256.Sum256(append(append([]byte(nil), nonce...), ciphertext...)) + early := transitionedAt != nil && w.Removal.RemovedAt.Before(transitionedAt.Add(time.Duration(rollbackDays)*24*time.Hour)) + removalRaw, _ := billingmigration.ParseDigest(w.Removal.RemovalDigest) + w.Removal.CredentialID = credentialID + w.Removal.Early = early + tag, err := tx.Exec(ctx, `UPDATE billing_migration_credentials SET nonce=NULL,ciphertext=NULL,removed_at=$2,removed_by_actor_id=$3,removal_digest=$4 WHERE id=$1 AND removed_at IS NULL AND nonce IS NOT NULL AND ciphertext IS NOT NULL`, credentialID, w.Removal.RemovedAt, w.Removal.ActorID, removalRaw) + if err != nil || tag.RowsAffected() != 1 { + if err == nil { + return billingmigration.CredentialRemoval{}, false, billingmigration.ErrConflict + } + return billingmigration.CredentialRemoval{}, false, err + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_credential_removals(id,program_id,project_id,credential_id,idempotency_key,expected_state_version,reason,early_removal,irreversible_acknowledged,actor_id,envelope_digest,removal_digest,removed_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,true,$9,$10,$11,$12)`, w.Removal.RemovalID, w.Removal.ProgramID, w.Removal.ProjectID, credentialID, w.IdempotencyKey, w.ExpectedState, w.Removal.Reason, early, w.Removal.ActorID, envelope[:], removalRaw, w.Removal.RemovedAt) + if err != nil { + return billingmigration.CredentialRemoval{}, false, translate(err, "append credential removal") + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_command_idempotency(id,program_id,project_id,command_kind,idempotency_key,request_digest,resource_id,created_at) VALUES('mci_'||$1,$2,$3,'remove_credential',$4,$5,$1,$6)`, w.Removal.RemovalID, w.Removal.ProgramID, w.Removal.ProjectID, w.IdempotencyKey, w.RequestDigest, w.Removal.RemovedAt) + if err != nil { + return billingmigration.CredentialRemoval{}, false, err + } + metadata, _ := json.Marshal(map[string]any{"earlyRemoval": early, "irreversible": true, "reason": w.Removal.Reason}) + _, err = tx.Exec(ctx, `INSERT INTO audit_events(id,actor_id,organization_id,project_id,environment_id,action,resource_type,resource_id,metadata,created_at) VALUES('aud_'||$1,$2,$3,$4,$5,'billing.migration.credential.removed','billing_migration_credential',$6,$7,$8)`, w.Removal.RemovalID, w.Removal.ActorID, organization, w.Removal.ProjectID, environment, credentialID, metadata, w.Removal.RemovedAt) + if err != nil { + return billingmigration.CredentialRemoval{}, false, err + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.CredentialRemoval{}, false, err + } + return w.Removal, false, nil +} +func scanRemoval(row pgx.Row, out *billingmigration.CredentialRemoval) error { + var raw []byte + err := row.Scan(&out.RemovalID, &out.ProgramID, &out.ProjectID, &out.CredentialID, &out.Reason, &out.ActorID, &out.Early, &raw, &out.RemovedAt) + out.RemovalDigest = billingmigration.FormatDigest(raw) + return err +} + +func (r *Repository) ProposeLegalHold(ctx context.Context, w billingmigration.LegalHoldProposalWrite) (billingmigration.LegalHoldProposal, bool, error) { + if (len(w.ExpectedPreviousDigest) == 0 && w.Proposal.ExpectedPreviousCommandDigest != "") || (len(w.ExpectedPreviousDigest) == 32 && billingmigration.FormatDigest(w.ExpectedPreviousDigest) != w.Proposal.ExpectedPreviousCommandDigest) || (len(w.ExpectedPreviousDigest) != 0 && len(w.ExpectedPreviousDigest) != 32) { + return billingmigration.LegalHoldProposal{}, false, billingmigration.ErrInvalid + } + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.LegalHoldProposal{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + var id string + var stored []byte + err = tx.QueryRow(ctx, `SELECT id,request_digest FROM billing_migration_legal_hold_proposals WHERE program_id=$1 AND idempotency_key=$2`, w.Proposal.ProgramID, w.IdempotencyKey).Scan(&id, &stored) + if err == nil { + if !bytes.Equal(stored, w.RequestDigest) { + return billingmigration.LegalHoldProposal{}, false, billingmigration.ErrIdempotencyConflict + } + p, e := legalHoldProposalByID(ctx, tx, id) + return p, true, e + } + if !errors.Is(err, pgx.ErrNoRows) { + return billingmigration.LegalHoldProposal{}, false, err + } + var previousCommand string + var previousDigest []byte + err = tx.QueryRow(ctx, `SELECT COALESCE(h.command,''),h.command_digest FROM billing_migration_programs p LEFT JOIN LATERAL(SELECT command,command_digest FROM billing_migration_legal_hold_commands WHERE program_id=p.id ORDER BY commanded_at DESC,id DESC LIMIT 1)h ON true WHERE p.id=$1 AND p.project_id=$2 FOR UPDATE OF p`, w.Proposal.ProgramID, w.Proposal.ProjectID).Scan(&previousCommand, &previousDigest) + if err != nil { + return billingmigration.LegalHoldProposal{}, false, translate(err, "lock legal hold chain") + } + if (previousCommand == "" && len(w.ExpectedPreviousDigest) != 0) || (previousCommand != "" && !bytes.Equal(previousDigest, w.ExpectedPreviousDigest)) { + return billingmigration.LegalHoldProposal{}, false, billingmigration.ErrStaleDigest + } + if (w.Proposal.Command == "set" && previousCommand == "set") || (w.Proposal.Command == "release" && previousCommand != "set") { + return billingmigration.LegalHoldProposal{}, false, billingmigration.ErrConflict + } + raw, _ := billingmigration.ParseDigest(w.Proposal.ProposalDigest) + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_legal_hold_proposals(id,program_id,project_id,command,reason,external_compliance_reference,proposer_actor_id,expected_previous_command_digest,proposal_digest,idempotency_key,request_digest,status,proposed_at,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending',$12,$13)`, w.Proposal.ProposalID, w.Proposal.ProgramID, w.Proposal.ProjectID, w.Proposal.Command, w.Proposal.Reason, w.Proposal.ExternalComplianceReference, w.Proposal.ProposerActorID, w.ExpectedPreviousDigest, raw, w.IdempotencyKey, w.RequestDigest, w.Proposal.ProposedAt, w.Proposal.ExpiresAt) + if err != nil { + return billingmigration.LegalHoldProposal{}, false, translate(err, "insert legal hold proposal") + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.LegalHoldProposal{}, false, err + } + return w.Proposal, false, nil +} + +func (r *Repository) ApproveLegalHold(ctx context.Context, w billingmigration.LegalHoldApprovalWrite) (billingmigration.LegalHold, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.LegalHold{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + resource, replay, err := operationCommandReplay(ctx, tx, w.ProgramID, "legal_hold_approve", w.IdempotencyKey, w.RequestDigest) + if err != nil { + return billingmigration.LegalHold{}, false, err + } + if replay { + h, e := legalHoldByID(ctx, tx, resource) + return h, true, e + } + var p billingmigration.LegalHoldProposal + var proposalRaw, expectedPrevious []byte + var mode, previousID, previousCommand string + var previousDigest []byte + err = tx.QueryRow(ctx, `SELECT lp.id,lp.program_id,lp.project_id,lp.command,lp.reason,lp.external_compliance_reference,lp.proposer_actor_id,lp.proposal_digest,lp.status,lp.proposed_at,lp.expires_at,lp.expected_previous_command_digest,e.mode,COALESCE(h.id,''),COALESCE(h.command,''),h.command_digest FROM billing_migration_legal_hold_proposals lp JOIN billing_migration_programs mp ON mp.id=lp.program_id JOIN environments e ON e.id=mp.environment_id AND e.project_id=mp.project_id LEFT JOIN LATERAL(SELECT id,command,command_digest FROM billing_migration_legal_hold_commands WHERE program_id=lp.program_id ORDER BY commanded_at DESC,id DESC LIMIT 1)h ON true WHERE lp.id=$1 AND lp.program_id=$2 AND lp.project_id=$3 FOR UPDATE OF lp,mp`, w.ProposalID, w.ProgramID, w.ProjectID).Scan(&p.ProposalID, &p.ProgramID, &p.ProjectID, &p.Command, &p.Reason, &p.ExternalComplianceReference, &p.ProposerActorID, &proposalRaw, &p.Status, &p.ProposedAt, &p.ExpiresAt, &expectedPrevious, &mode, &previousID, &previousCommand, &previousDigest) + if err != nil { + return billingmigration.LegalHold{}, false, translate(err, "lock legal hold proposal") + } + if p.Status != "pending" { + return billingmigration.LegalHold{}, false, billingmigration.ErrConflict + } + if !p.ExpiresAt.After(w.At) { + if _, err = tx.Exec(ctx, `UPDATE billing_migration_legal_hold_proposals SET status='expired' WHERE id=$1 AND status='pending'`, w.ProposalID); err != nil { + return billingmigration.LegalHold{}, false, err + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.LegalHold{}, false, err + } + return billingmigration.LegalHold{}, false, billingmigration.ErrExpiredApproval + } + if !bytes.Equal(proposalRaw, w.ExpectedProposalDigest) { + return billingmigration.LegalHold{}, false, billingmigration.ErrStaleDigest + } + if (previousID == "" && len(expectedPrevious) != 0) || (previousID != "" && !bytes.Equal(previousDigest, expectedPrevious)) { + return billingmigration.LegalHold{}, false, billingmigration.ErrStaleDigest + } + production := mode == "production" + if (production && p.ProposerActorID == w.ApproverActorID) || (!production && p.ProposerActorID != w.ApproverActorID) { + return billingmigration.LegalHold{}, false, billingmigration.ErrForbidden + } + var running bool + err = tx.QueryRow(ctx, `SELECT COALESCE((SELECT status='running' FROM billing_migration_retention_jobs WHERE program_id=$1 FOR UPDATE),false)`, w.ProgramID).Scan(&running) + if err != nil { + return billingmigration.LegalHold{}, false, err + } + if running { + return billingmigration.LegalHold{}, false, billingmigration.ErrConflict + } + sum := sha256.Sum256(append(proposalRaw, []byte("\x1f"+w.ApproverActorID)...)) + holdID := "mlh_" + hex.EncodeToString(sum[:12]) + commandRaw := sha256.Sum256(append(sum[:], []byte("\x1f"+previousID)...)) + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_legal_hold_commands(id,program_id,project_id,proposal_id,command,reason,external_compliance_reference,proposer_actor_id,approver_actor_id,production,previous_command_id,command_digest,commanded_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NULLIF($11,''),$12,$13)`, holdID, w.ProgramID, w.ProjectID, w.ProposalID, p.Command, p.Reason, p.ExternalComplianceReference, p.ProposerActorID, w.ApproverActorID, production, previousID, commandRaw[:], w.At) + if err != nil { + return billingmigration.LegalHold{}, false, translate(err, "append legal hold command") + } + _, err = tx.Exec(ctx, `UPDATE billing_migration_legal_hold_proposals SET status='approved' WHERE id=$1 AND status='pending'`, w.ProposalID) + if err != nil { + return billingmigration.LegalHold{}, false, err + } + _, err = tx.Exec(ctx, `UPDATE billing_migration_retention_jobs SET legal_hold=$2,due_at=CASE WHEN $2 THEN due_at ELSE GREATEST(original_due_at,$3) END,updated_at=$3 WHERE program_id=$1`, w.ProgramID, p.Command == "set", w.At) + if err != nil { + return billingmigration.LegalHold{}, false, err + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_command_idempotency(id,program_id,project_id,command_kind,idempotency_key,request_digest,resource_id,created_at) VALUES('mci_'||$1,$2,$3,'legal_hold_approve',$4,$5,$1,$6)`, holdID, w.ProgramID, w.ProjectID, w.IdempotencyKey, w.RequestDigest, w.At) + if err != nil { + return billingmigration.LegalHold{}, false, err + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.LegalHold{}, false, err + } + return billingmigration.LegalHold{HoldID: holdID, ProposalID: w.ProposalID, ProgramID: w.ProgramID, ProjectID: w.ProjectID, Command: p.Command, Reason: p.Reason, ExternalComplianceReference: p.ExternalComplianceReference, ProposerActorID: p.ProposerActorID, ApproverActorID: w.ApproverActorID, PreviousCommandID: previousID, CommandDigest: billingmigration.FormatDigest(commandRaw[:]), Production: production, CommandedAt: w.At}, false, nil +} + +func legalHoldProposalByID(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, id string) (billingmigration.LegalHoldProposal, error) { + var p billingmigration.LegalHoldProposal + var raw, previous []byte + err := q.QueryRow(ctx, `SELECT id,program_id,project_id,command,reason,external_compliance_reference,proposer_actor_id,expected_previous_command_digest,proposal_digest,status,proposed_at,expires_at FROM billing_migration_legal_hold_proposals WHERE id=$1`, id).Scan(&p.ProposalID, &p.ProgramID, &p.ProjectID, &p.Command, &p.Reason, &p.ExternalComplianceReference, &p.ProposerActorID, &previous, &raw, &p.Status, &p.ProposedAt, &p.ExpiresAt) + if len(previous) == 32 { + p.ExpectedPreviousCommandDigest = billingmigration.FormatDigest(previous) + } + p.ProposalDigest = billingmigration.FormatDigest(raw) + return p, err +} +func legalHoldByID(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, id string) (billingmigration.LegalHold, error) { + var h billingmigration.LegalHold + var raw []byte + err := q.QueryRow(ctx, `SELECT id,proposal_id,program_id,project_id,command,reason,external_compliance_reference,proposer_actor_id,approver_actor_id,production,COALESCE(previous_command_id,''),command_digest,commanded_at FROM billing_migration_legal_hold_commands WHERE id=$1`, id).Scan(&h.HoldID, &h.ProposalID, &h.ProgramID, &h.ProjectID, &h.Command, &h.Reason, &h.ExternalComplianceReference, &h.ProposerActorID, &h.ApproverActorID, &h.Production, &h.PreviousCommandID, &raw, &h.CommandedAt) + h.CommandDigest = billingmigration.FormatDigest(raw) + return h, err +} + +func (r *Repository) CompletionPrerequisites(ctx context.Context, projectID, programID string, now time.Time) (billingmigration.CompletionPrerequisites, error) { + var p billingmigration.CompletionPrerequisites + p.ProgramID, p.ProjectID = programID, projectID + var policy []byte + var removedAt *time.Time + var transitionAt time.Time + var stabilizationDays, rollbackDays, maxAge int + err := r.pool.QueryRow(ctx, `SELECT p.state,p.state_version,p.policy_digest,c.removed_at,p.stabilization_days,p.rollback_window_days,(SELECT max(transitioned_at) FROM billing_migration_authority_transitions WHERE program_id=p.id AND transition_kind='cutover'),rp.watermark_max_age_seconds FROM billing_migration_programs p JOIN billing_migration_credentials c ON c.id=p.credential_id AND c.project_id=p.project_id JOIN billing_migration_readiness_policies rp ON rp.program_id=p.id AND rp.policy_digest=p.policy_digest WHERE p.id=$1 AND p.project_id=$2`, programID, projectID).Scan(&p.State, &p.StateVersion, &policy, &removedAt, &stabilizationDays, &rollbackDays, &transitionAt, &maxAge) + if errors.Is(err, pgx.ErrNoRows) { + return p, billingmigration.ErrNotFound + } + if err != nil { + return p, translate(err, "inspect completion prerequisites") + } + p.PolicyDigest = billingmigration.FormatDigest(policy) + p.CredentialRemoved = removedAt != nil + p.StabilizationEndsAt = transitionAt.Add(time.Duration(stabilizationDays) * 24 * time.Hour) + p.RollbackWindowEndsAt = transitionAt.Add(time.Duration(rollbackDays) * 24 * time.Hour) + err = r.pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_cases WHERE program_id=$1 AND classification IN('critical','blocking') AND status IN('open','in_progress')`, programID).Scan(&p.UnresolvedCriticalBlocking) + if err != nil { + return p, err + } + var authority, stability []byte + err = r.pool.QueryRow(ctx, `WITH current_scopes AS (SELECT a.application_id,a.platform,a.current_epoch,a.authority_digest FROM billing_migration_authority_scopes a WHERE a.active_program_id=$1 AND a.current_authority='mosaic'),latest_sync AS (SELECT DISTINCT ON(o.application_id,o.platform)o.application_id,o.platform,o.observation_digest FROM billing_migration_v2_sync_observations o JOIN current_scopes s USING(application_id,platform) WHERE o.program_id=$1 AND o.sync_result='accepted' AND o.authority_epoch=s.current_epoch AND o.observed_at BETWEEN ($2::timestamptz-($3::integer*interval '1 second')) AND $2::timestamptz ORDER BY o.application_id,o.platform,o.observed_at DESC,o.id DESC) SELECT sha256(convert_to(string_agg(encode(s.authority_digest,'hex'),'' ORDER BY s.application_id,s.platform),'UTF8')),sha256(convert_to(string_agg(encode(o.observation_digest,'hex'),'' ORDER BY o.application_id,o.platform),'UTF8')) FROM current_scopes s JOIN latest_sync o USING(application_id,platform) HAVING count(*)=(SELECT count(*) FROM billing_migration_program_scopes WHERE program_id=$1)`, programID, now, maxAge).Scan(&authority, &stability) + if err == nil { + p.AuthorityDigest = billingmigration.FormatDigest(authority) + p.StabilityEvidenceDigest = billingmigration.FormatDigest(stability) + p.AuthorityStable = true + } else if !errors.Is(err, pgx.ErrNoRows) { + return p, err + } + err = r.pool.QueryRow(ctx, `SELECT NOT EXISTS( + SELECT 1 FROM webhook_destinations + WHERE project_id=$1 + AND environment_id=(SELECT environment_id FROM billing_migration_programs WHERE id=$2) + AND status='active' + AND (contract_version=2 + AND last_successful_test_at >= ($3::timestamptz - ($4::integer * interval '1 second')) + AND last_successful_test_at <= $3::timestamptz) IS NOT TRUE + )`, projectID, programID, now, maxAge).Scan(&p.WebhookReady) + if err != nil { + return p, err + } + p.Eligible = p.State == "stabilizing" && p.CredentialRemoved && p.UnresolvedCriticalBlocking == 0 && p.AuthorityStable && p.WebhookReady && !now.Before(p.StabilizationEndsAt) && !now.Before(p.RollbackWindowEndsAt) + return p, nil +} + +func (r *Repository) CompleteMigration(ctx context.Context, w billingmigration.CompletionWrite) (billingmigration.CompletionReport, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.CompletionReport{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + resource, replay, err := operationCommandReplay(ctx, tx, w.Report.ProgramID, "complete_migration", w.IdempotencyKey, w.RequestDigest) + if err != nil { + return billingmigration.CompletionReport{}, false, err + } + if replay { + report, err := completionByID(ctx, tx, resource) + return report, true, err + } + var state, credentialID, environment, organization string + var version int64 + var stabilizationDays, rollbackDays, maxAge int + var policy []byte + var removedAt *time.Time + var transitionAt time.Time + err = tx.QueryRow(ctx, `SELECT p.state,p.state_version,p.credential_id,p.environment_id,pr.organization_id,p.stabilization_days,p.rollback_window_days,p.policy_digest,c.removed_at,rp.watermark_max_age_seconds FROM billing_migration_programs p JOIN projects pr ON pr.id=p.project_id JOIN billing_migration_credentials c ON c.id=p.credential_id AND c.project_id=p.project_id JOIN billing_migration_readiness_policies rp ON rp.program_id=p.id AND rp.policy_digest=p.policy_digest WHERE p.id=$1 AND p.project_id=$2 FOR UPDATE OF p,c`, w.Report.ProgramID, w.Report.ProjectID).Scan(&state, &version, &credentialID, &environment, &organization, &stabilizationDays, &rollbackDays, &policy, &removedAt, &maxAge) + if err != nil { + return billingmigration.CompletionReport{}, false, translate(err, "lock completion prerequisites") + } + err = tx.QueryRow(ctx, `SELECT max(transitioned_at) FROM billing_migration_authority_transitions WHERE program_id=$1 AND transition_kind='cutover'`, w.Report.ProgramID).Scan(&transitionAt) + if err != nil { + return billingmigration.CompletionReport{}, false, translate(err, "read completion cutover time") + } + if state != "stabilizing" || version != w.ExpectedStateVersion || !bytes.Equal(policy, w.ExpectedPolicyDigest) || removedAt == nil { + return billingmigration.CompletionReport{}, false, billingmigration.ErrConflict + } + stabilizationEnd := transitionAt.Add(time.Duration(stabilizationDays) * 24 * time.Hour) + rollbackEnd := transitionAt.Add(time.Duration(rollbackDays) * 24 * time.Hour) + if w.Report.CompletedAt.Before(stabilizationEnd) || w.Report.CompletedAt.Before(rollbackEnd) { + return billingmigration.CompletionReport{}, false, billingmigration.ErrRollbackWindow + } + var unhealthy int + err = tx.QueryRow(ctx, `SELECT count(*) FROM billing_migration_cases WHERE program_id=$1 AND classification IN('critical','blocking') AND status IN('open','in_progress')`, w.Report.ProgramID).Scan(&unhealthy) + if err != nil || unhealthy != 0 { + return billingmigration.CompletionReport{}, false, billingmigration.ErrConflict + } + var authorityRaw, stabilityRaw []byte + err = tx.QueryRow(ctx, `WITH current_scopes AS ( + SELECT a.application_id,a.platform,a.current_epoch,a.authority_digest + FROM billing_migration_authority_scopes a WHERE a.active_program_id=$1 AND a.current_authority='mosaic' + ), latest_sync AS ( + SELECT DISTINCT ON (o.application_id,o.platform) o.application_id,o.platform,o.observation_digest + FROM billing_migration_v2_sync_observations o JOIN current_scopes s USING(application_id,platform) + WHERE o.program_id=$1 AND o.sync_result='accepted' AND o.authority_epoch=s.current_epoch AND o.observed_at BETWEEN ($2::timestamptz-($3::integer*interval '1 second')) AND $2::timestamptz + ORDER BY o.application_id,o.platform,o.observed_at DESC,o.id DESC + ) SELECT + sha256(convert_to(string_agg(encode(s.authority_digest,'hex'),'' ORDER BY s.application_id,s.platform),'UTF8')), + sha256(convert_to(string_agg(encode(o.observation_digest,'hex'),'' ORDER BY o.application_id,o.platform),'UTF8')) + FROM current_scopes s JOIN latest_sync o USING(application_id,platform) + HAVING count(*)=(SELECT count(*) FROM billing_migration_program_scopes WHERE program_id=$1)`, w.Report.ProgramID, w.Report.CompletedAt, maxAge).Scan(&authorityRaw, &stabilityRaw) + if err != nil || !bytes.Equal(authorityRaw, w.AuthorityDigest) || !bytes.Equal(stabilityRaw, w.StabilityDigest) { + return billingmigration.CompletionReport{}, false, billingmigration.ErrStaleDigest + } + var webhookReady bool + err = tx.QueryRow(ctx, `SELECT NOT EXISTS( + SELECT 1 FROM webhook_destinations + WHERE project_id=$1 AND environment_id=$2 AND status='active' + AND (contract_version=2 + AND last_successful_test_at >= ($3::timestamptz - ($4::integer * interval '1 second')) + AND last_successful_test_at <= $3::timestamptz) IS NOT TRUE + )`, w.Report.ProjectID, environment, w.Report.CompletedAt, maxAge).Scan(&webhookReady) + if err != nil || !webhookReady { + return billingmigration.CompletionReport{}, false, billingmigration.ErrConflict + } + var hold bool + _ = tx.QueryRow(ctx, `SELECT COALESCE((SELECT command='set' FROM billing_migration_legal_hold_commands WHERE program_id=$1 ORDER BY commanded_at DESC,id DESC LIMIT 1),false)`, w.Report.ProgramID).Scan(&hold) + w.Report.StabilizationEndedAt = stabilizationEnd + w.Report.RollbackWindowEndedAt = rollbackEnd + w.Report.CredentialRemovedAt = *removedAt + w.Report.LegalHold = hold + if !hold { + due := w.Report.CompletedAt.Add(30 * 24 * time.Hour) + w.Report.SourceObjectsDeleteAt = &due + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest) VALUES($1,$2,$3,$4,$5,$6,$7,true,$8,$9,$10,$11,$12,$13,$14)`, w.Report.ReportID, w.Report.ProgramID, w.Report.ProjectID, w.Report.StateVersion, w.Report.CompletedAt, stabilizationEnd, rollbackEnd, *removedAt, hold, w.Report.SourceObjectsDeleteAt, w.CompletionDigest, w.AuthorityDigest, w.StabilityDigest, w.ExpectedPolicyDigest) + if err != nil { + return billingmigration.CompletionReport{}, false, translate(err, "append completion report") + } + due := w.Report.CompletedAt.Add(30 * 24 * time.Hour) + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_retention_jobs(id,program_id,project_id,status,legal_hold,due_at,lease_generation,created_at,updated_at,completion_report_id,attempt_count,max_attempts,deletion_identity,original_due_at) VALUES($1,$2,$3,'pending',$4,$5,0,$6,$6,$7,0,8,$8,$5)`, w.RetentionJobID, w.Report.ProgramID, w.Report.ProjectID, hold, due, w.Report.CompletedAt, w.Report.ReportID, w.DeletionIdentity) + if err != nil { + return billingmigration.CompletionReport{}, false, translate(err, "enqueue raw source retention") + } + tag, err := tx.Exec(ctx, `UPDATE billing_migration_programs SET state='completed',state_version=state_version+1,updated_at=$3 WHERE id=$1 AND state_version=$2 AND state='stabilizing'`, w.Report.ProgramID, w.ExpectedStateVersion, w.Report.CompletedAt) + if err != nil || tag.RowsAffected() != 1 { + return billingmigration.CompletionReport{}, false, billingmigration.ErrStaleState + } + outboxTag, err := tx.Exec(ctx, `INSERT INTO billing_migration_transition_outbox(id,program_id,project_id,authority_scope_id,transition_id,event_kind,authority_epoch,status,attempt_count,lease_owner,lease_expires_at,created_at,updated_at,checkpoint_id,completion_report_id,correlation_id,due_at,lease_generation,max_attempts,legacy_event_kind) SELECT 'mto_'||substr(md5($1||':'||a.id),1,20),$2,$3,a.id,NULL,'stabilization_completed',a.current_epoch,'pending',0,NULL,NULL,$4,$4,c.id,$1,$1,$4,0,8,false FROM billing_migration_authority_scopes a CROSS JOIN LATERAL(SELECT id FROM billing_migration_checkpoints WHERE program_id=$2 ORDER BY created_at DESC,id DESC LIMIT 1)c WHERE a.active_program_id=$2`, w.Report.ReportID, w.Report.ProgramID, w.Report.ProjectID, w.Report.CompletedAt) + if err != nil { + return billingmigration.CompletionReport{}, false, translate(err, "enqueue stabilization completion") + } + var scopeCount int64 + if err = tx.QueryRow(ctx, `SELECT count(*) FROM billing_migration_program_scopes WHERE program_id=$1`, w.Report.ProgramID).Scan(&scopeCount); err != nil || outboxTag.RowsAffected() != scopeCount { + return billingmigration.CompletionReport{}, false, billingmigration.ErrConflict + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_command_idempotency(id,program_id,project_id,command_kind,idempotency_key,request_digest,resource_id,created_at) VALUES('mci_'||$1,$2,$3,'complete_migration',$4,$5,$1,$6)`, w.Report.ReportID, w.Report.ProgramID, w.Report.ProjectID, w.IdempotencyKey, w.RequestDigest, w.Report.CompletedAt) + if err != nil { + return billingmigration.CompletionReport{}, false, err + } + metadata, _ := json.Marshal(map[string]any{"retentionDueAt": due, "legalHold": hold}) + _, err = tx.Exec(ctx, `INSERT INTO audit_events(id,actor_id,organization_id,project_id,environment_id,action,resource_type,resource_id,metadata,created_at) VALUES('aud_'||$1,$2,$3,$4,$5,'billing.migration.completed','billing_migration_program',$6,$7,$8)`, w.Report.ReportID, w.ActorID, organization, w.Report.ProjectID, environment, w.Report.ProgramID, metadata, w.Report.CompletedAt) + if err != nil { + return billingmigration.CompletionReport{}, false, err + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.CompletionReport{}, false, err + } + return w.Report, false, nil +} + +func completionByID(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, id string) (billingmigration.CompletionReport, error) { + var r billingmigration.CompletionReport + var completion, authority, stability, policy []byte + err := q.QueryRow(ctx, `SELECT id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest FROM billing_migration_completion_reports WHERE id=$1`, id).Scan(&r.ReportID, &r.ProgramID, &r.ProjectID, &r.StateVersion, &r.CompletedAt, &r.StabilizationEndedAt, &r.RollbackWindowEndedAt, &r.CredentialRemovedAt, &r.LegalHold, &r.SourceObjectsDeleteAt, &completion, &authority, &stability, &policy) + r.CompletionDigest = billingmigration.FormatDigest(completion) + r.AuthorityDigest = billingmigration.FormatDigest(authority) + r.StabilityEvidenceDigest = billingmigration.FormatDigest(stability) + r.PolicyDigest = billingmigration.FormatDigest(policy) + return r, err +} + +func (r *Repository) ClaimRetention(ctx context.Context, c billingmigration.RetentionClaim) (billingmigration.RetentionLease, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return billingmigration.RetentionLease{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + var l billingmigration.RetentionLease + err = tx.QueryRow(ctx, `SELECT id,program_id,project_id,lease_generation+1,attempt_count+1,max_attempts,legal_hold FROM billing_migration_retention_jobs WHERE NOT legal_hold AND status IN('pending','running') AND due_at<=$1 AND attempt_count=max_attempts THEN 'failed' ELSE 'pending' END,due_at=CASE WHEN attempt_count>=max_attempts THEN due_at ELSE $3 END,lease_owner=NULL,lease_expires_at=NULL,last_error_code=CASE WHEN attempt_count>=max_attempts THEN $4 ELSE NULL END,updated_at=$5 WHERE id=$1 AND lease_generation=$2 AND status='running'`, f.JobID, f.Generation, f.RetryAt, f.ErrorCode, f.At) + } + if tagErr != nil { + return tagErr + } + if tag.RowsAffected() != 1 { + return billingmigration.ErrConflict + } + return nil +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/cutover.go b/apps/api/internal/platform/billingmigrationpostgres/cutover.go new file mode 100644 index 00000000..8dab6ba2 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/cutover.go @@ -0,0 +1,771 @@ +package billingmigrationpostgres + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +var _ billingmigration.CutoverRepository = (*Repository)(nil) + +func (r *Repository) PromoteReady(ctx context.Context, projectID, programID string, expected int64, actorID string, now time.Time) (billingmigration.AuthoritativeReadiness, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.AuthoritativeReadiness{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + var state string + if err := tx.QueryRow(ctx, `SELECT state FROM billing_migration_programs WHERE id=$1 AND project_id=$2 AND state_version=$3 FOR UPDATE`, programID, projectID, expected).Scan(&state); errors.Is(err, pgx.ErrNoRows) { + return billingmigration.AuthoritativeReadiness{}, billingmigration.ErrConflict + } else if err != nil { + return billingmigration.AuthoritativeReadiness{}, err + } + if state != billingmigration.StateShadowing { + return billingmigration.AuthoritativeReadiness{}, billingmigration.ErrConflict + } + var warningThreshold int64 + var maxAge int + var policyFrozen time.Time + var appDigest []byte + if err := tx.QueryRow(ctx, `SELECT warning_threshold,watermark_max_age_seconds,frozen_at,application_version_digest FROM billing_migration_readiness_policies WHERE program_id=$1 AND project_id=$2 ORDER BY frozen_at DESC,id LIMIT 1`, programID, projectID).Scan(&warningThreshold, &maxAge, &policyFrozen, &appDigest); errors.Is(err, pgx.ErrNoRows) { + return billingmigration.AuthoritativeReadiness{}, billingmigration.ErrConflict + } else if err != nil { + return billingmigration.AuthoritativeReadiness{}, err + } + input, err := readinessInputTx(ctx, tx, projectID, programID, now.UTC()) + if err != nil { + return billingmigration.AuthoritativeReadiness{}, err + } + var finalDeltaID string + var sourceWatermark, providerWatermark, shadowWatermark, completedAt time.Time + err = tx.QueryRow(ctx, `SELECT id,source_watermark,provider_watermark,shadow_watermark,completed_at FROM billing_migration_final_deltas WHERE program_id=$1 AND project_id=$2 AND state_version=$3 ORDER BY completed_at DESC,id LIMIT 1`, programID, projectID, expected).Scan(&finalDeltaID, &sourceWatermark, &providerWatermark, &shadowWatermark, &completedAt) + input.FinalDeltaCompleted = err == nil + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return billingmigration.AuthoritativeReadiness{}, err + } + now = now.UTC() + freshAfter := now.Add(-time.Duration(maxAge) * time.Second) + input.WatermarksFresh = input.FinalDeltaCompleted && sourceWatermark.After(freshAfter) && providerWatermark.After(freshAfter) && shadowWatermark.After(freshAfter) + var sourceFresh bool + err = tx.QueryRow(ctx, `SELECT assessed_at >= $3 AND capabilities @> ARRAY['read_customers','read_subscriptions']::text[] FROM billing_migration_capability_assessments WHERE program_id=$1 AND project_id=$2 ORDER BY assessed_at DESC,id LIMIT 1`, programID, projectID, policyFrozen).Scan(&sourceFresh) + if errors.Is(err, pgx.ErrNoRows) { + sourceFresh = false + } else if err != nil { + return billingmigration.AuthoritativeReadiness{}, err + } + versionReady, err := versionReadinessTx(ctx, tx, projectID, programID) + if err != nil { + return billingmigration.AuthoritativeReadiness{}, err + } + input.SupportedVersionsAuthorityAware = versionReady + cohortDigest, err := freezeFinalDeltaCohort(ctx, tx, projectID, programID, finalDeltaID, now) + if err != nil { + return billingmigration.AuthoritativeReadiness{}, err + } + assessment, err := billingmigration.AssessReadiness(programID, expected, input) + if err != nil { + return billingmigration.AuthoritativeReadiness{}, err + } + result, err := billingmigration.AssessAuthoritativeReadiness(programID, expected, assessment, sourceFresh, warningThreshold, billingmigration.FormatDigest(appDigest)) + if err != nil { + return billingmigration.AuthoritativeReadiness{}, err + } + assessment = result.Assessment + result.CohortDigest = cohortDigest + readinessRaw, _ := billingmigration.ParseDigest(assessment.ReadinessDigest) + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_readiness_assessments(id,program_id,project_id,state_version,ready,current_access_mapping_percent,current_access_evidence_percent,critical_count,blocking_count,warning_count,informational_count,final_delta_completed,watermarks_fresh,supported_versions_authority_aware,readiness_digest,assessed_at,authoritative,source_capabilities_fresh,warning_threshold,application_version_digest) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,true,$17,$18,$19) ON CONFLICT(program_id,readiness_digest) DO NOTHING`, + "mra_"+assessment.ReadinessDigest[7:23], programID, projectID, expected, assessment.Ready, assessment.CurrentAccessMappingPercent, assessment.CurrentAccessEvidencePercent, assessment.Unresolved.Critical, assessment.Unresolved.Blocking, assessment.Unresolved.Warning, assessment.Unresolved.Informational, assessment.FinalDeltaCompleted, assessment.WatermarksFresh, assessment.SupportedVersionsAuthorityAware, readinessRaw, now, sourceFresh, warningThreshold, appDigest) + if err != nil { + return billingmigration.AuthoritativeReadiness{}, translate(err, "record authoritative readiness") + } + if !assessment.Ready { + if err := tx.Commit(ctx); err != nil { + return result, err + } + return result, billingmigration.ErrConflict + } + command, err := tx.Exec(ctx, `UPDATE billing_migration_programs SET state='ready',state_version=state_version+1,updated_at=$4 WHERE id=$1 AND project_id=$2 AND state_version=$3 AND state='shadowing'`, programID, projectID, expected, now) + if err != nil { + return result, err + } + if command.RowsAffected() != 1 { + return result, billingmigration.ErrConflict + } + return result, tx.Commit(ctx) +} + +func freezeFinalDeltaCohort(ctx context.Context, tx pgx.Tx, projectID, programID, finalDeltaID string, now time.Time) (string, error) { + if finalDeltaID == "" { + return "", billingmigration.ErrConflict + } + rows, err := tx.Query(ctx, `WITH final_delta AS (SELECT manifest_digest,mapping_digest,completed_at FROM billing_migration_final_deltas WHERE id=$3 AND program_id=$1 AND project_id=$2), + frozen AS (SELECT mapping.id FROM billing_migration_mapping_sets mapping,final_delta WHERE mapping.program_id=$1 AND mapping.project_id=$2 AND mapping.status='frozen' AND mapping.mapping_digest=final_delta.mapping_digest ORDER BY mapping.version DESC LIMIT 1) + SELECT DISTINCT customer_id FROM ( + SELECT mapped.target_id customer_id + FROM billing_migration_source_records source + JOIN billing_migration_source_manifests manifest ON manifest.id=source.manifest_id AND manifest.program_id=source.program_id AND manifest.project_id=source.project_id + JOIN final_delta ON final_delta.manifest_digest=manifest.manifest_digest + JOIN billing_migration_mapping_entries mapped ON mapped.mapping_set_id=(SELECT id FROM frozen) + AND mapped.source_identifier=source.source_identifier + AND ((source.source_kind='customer' AND mapped.source_kind IN ('customer_id','original_customer_id')) + OR (source.source_kind IN ('alias','transfer') AND mapped.source_kind='audited_alias')) + JOIN billing_customers customer ON customer.id=mapped.target_id AND customer.project_id=source.project_id + WHERE source.program_id=$1 AND source.project_id=$2 AND source.current_access + UNION + SELECT shadow.billing_customer_id FROM billing_migration_shadow_snapshots shadow,final_delta WHERE shadow.program_id=$1 AND shadow.project_id=$2 AND shadow.created_at<=final_delta.completed_at + ) cohort ORDER BY customer_id`, programID, projectID, finalDeltaID) + if err != nil { + return "", err + } + customers := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return "", err + } + customers = append(customers, id) + } + rows.Close() + formatted, err := billingmigration.FinalDeltaCohortDigest(programID, finalDeltaID, customers) + if err != nil { + return "", billingmigration.ErrConflict + } + raw, _ := billingmigration.ParseDigest(formatted) + setID := "mcs_" + formatted[7:23] + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_final_delta_cohort_sets(id,final_delta_id,program_id,project_id,customer_count,cohort_digest,frozen_at) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(final_delta_id) DO NOTHING`, setID, finalDeltaID, programID, projectID, len(customers), raw, now) + if err != nil { + return "", err + } + var stored []byte + if err := tx.QueryRow(ctx, `SELECT cohort_digest FROM billing_migration_final_delta_cohort_sets WHERE final_delta_id=$1 AND program_id=$2 AND project_id=$3`, finalDeltaID, programID, projectID).Scan(&stored); err != nil || !bytes.Equal(stored, raw) { + return "", billingmigration.ErrConflict + } + for _, customerID := range customers { + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_final_delta_cohort_customers(cohort_set_id,program_id,project_id,billing_customer_id,customer_digest) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, setID, programID, projectID, customerID, raw) + if err != nil { + return "", err + } + } + return formatted, nil +} + +func versionReadinessTx(ctx context.Context, tx pgx.Tx, projectID, programID string) (bool, error) { + var scopeCount, policyCount int + if err := tx.QueryRow(ctx, `SELECT (SELECT count(*) FROM billing_migration_program_scopes WHERE program_id=$1),(SELECT count(*) FROM billing_migration_readiness_policy_scopes WHERE program_id=$1)`, programID).Scan(&scopeCount, &policyCount); err != nil { + return false, err + } + if scopeCount == 0 || policyCount != scopeCount { + return false, nil + } + rows, err := tx.Query(ctx, `SELECT s.application_id,s.platform,ps.minimum_app_version,ps.maximum_app_version,ps.minimum_sdk_version,ps.required_capabilities,ps.serving_requirements_digest,ps.traffic_window_started_at,ps.traffic_window_ended_at,ps.outside_window_accepted,v.application_version,v.supported,v.authority_aware,v.observed_at + FROM billing_migration_program_scopes s JOIN billing_migration_readiness_policy_scopes ps ON ps.program_id=s.program_id AND ps.application_id=s.application_id AND ps.platform=s.platform + LEFT JOIN billing_migration_supported_app_versions v ON v.program_id=s.program_id AND v.application_id=s.application_id AND v.platform=s.platform WHERE s.program_id=$1 ORDER BY s.application_id,s.platform,v.application_version`, programID) + if err != nil { + return false, err + } + defer rows.Close() + type measured struct { + app, platform, min, max, minSDK, version string + required []string + servingDigest []byte + start, end, observed time.Time + outside, supported, aware bool + } + items := []measured{} + for rows.Next() { + var item measured + var version *string + var supported, aware *bool + var observed *time.Time + if err := rows.Scan(&item.app, &item.platform, &item.min, &item.max, &item.minSDK, &item.required, &item.servingDigest, &item.start, &item.end, &item.outside, &version, &supported, &aware, &observed); err != nil { + return false, err + } + if version == nil || len(item.servingDigest) != 32 || bytes.Equal(item.servingDigest, make([]byte, 32)) { + return false, nil + } + item.version = *version + item.supported, item.aware, item.observed = *supported, *aware, *observed + items = append(items, item) + } + if err := rows.Err(); err != nil { + return false, err + } + if len(items) == 0 { + return false, nil + } + for _, item := range items { + expectedServingDigest, err := billingmigration.ServingRequirementsDigest(programID, item.app, item.platform, item.minSDK, item.required) + if err != nil || !bytes.Equal(item.servingDigest, mustDigest(expectedServingDigest)) { + return false, nil + } + inRange, err := billingmigration.SemanticVersionInRange(item.version, item.min, item.max) + if err != nil { + return false, nil + } + inWindow := !item.observed.Before(item.start) && !item.observed.After(item.end) + if !inRange || !inWindow { + if item.outside { + continue + } + return false, nil + } + if !item.supported || !item.aware { + return false, nil + } + obs, err := tx.Query(ctx, `SELECT sdk_version,authority_capabilities FROM billing_migration_v2_sync_observations WHERE program_id=$1 AND project_id=$2 AND application_id=$3 AND platform=$4 AND app_version=$5 AND observed_at BETWEEN $6 AND $7 AND traffic_count>0 AND sync_result='accepted' AND '2'=ANY(supported_contract_versions)`, programID, projectID, item.app, item.platform, item.version, item.start, item.end) + if err != nil { + return false, err + } + qualified := false + for obs.Next() { + var sdk string + var capabilities []string + if err := obs.Scan(&sdk, &capabilities); err != nil { + obs.Close() + return false, err + } + sdkOK, parseErr := billingmigration.SemanticVersionInRange(sdk, item.minSDK, sdk) + if parseErr == nil && sdkOK && containsAll(capabilities, item.required) { + qualified = true + } + } + obs.Close() + if !qualified { + return false, nil + } + } + return true, nil +} + +func containsAll(actual, required []string) bool { + set := map[string]bool{} + for _, value := range actual { + set[value] = true + } + for _, value := range required { + if !set[value] { + return false + } + } + return true +} + +func readinessInputTx(ctx context.Context, tx pgx.Tx, projectID, programID string, now time.Time) (billingmigration.ReadinessInput, error) { + var input billingmigration.ReadinessInput + err := tx.QueryRow(ctx, `WITH current_records AS (SELECT id,source_kind,source_identifier,evidence_kind FROM billing_migration_source_records WHERE project_id=$1 AND program_id=$2 AND current_access), frozen_mapping AS (SELECT id FROM billing_migration_mapping_sets WHERE project_id=$1 AND program_id=$2 AND status='frozen' ORDER BY version DESC LIMIT 1), counts AS (SELECT d.classification,count(*) count FROM billing_migration_divergences d LEFT JOIN billing_migration_divergence_resolutions resolution ON resolution.divergence_id=d.id AND resolution.program_id=d.program_id AND resolution.project_id=d.project_id WHERE d.project_id=$1 AND d.program_id=$2 AND resolution.id IS NULL GROUP BY d.classification) + SELECT CASE WHEN count(*)=0 THEN 0 ELSE 100.0*count(*) FILTER(WHERE EXISTS(SELECT 1 FROM billing_migration_mapping_entries e WHERE e.mapping_set_id=(SELECT id FROM frozen_mapping) AND e.source_identifier=current_records.source_identifier AND ((current_records.source_kind='customer' AND e.source_kind IN ('customer_id','original_customer_id')) OR (current_records.source_kind='alias' AND e.source_kind='audited_alias') OR (current_records.source_kind='subscription' AND e.source_kind IN ('product','entitlement')) OR (current_records.source_kind='transaction' AND e.source_kind='product') OR (current_records.source_kind='transfer' AND e.source_kind='audited_alias'))))/count(*) END, + CASE WHEN count(*)=0 THEN 0 ELSE 100.0*count(*) FILTER(WHERE evidence_kind IN ('provider_signed','provider_validated') OR EXISTS( + SELECT 1 FROM billing_migration_source_access_exception_subjects subject + JOIN billing_migration_source_access_exceptions exception ON exception.id=subject.exception_id AND exception.program_id=subject.program_id AND exception.project_id=subject.project_id + JOIN billing_migration_mapping_entries mapped ON mapped.mapping_set_id=(SELECT id FROM frozen_mapping) AND mapped.target_id=subject.billing_customer_id AND mapped.source_identifier=current_records.source_identifier + WHERE subject.source_record_id=current_records.id AND subject.program_id=$2 AND subject.project_id=$1 + AND exception.approved_at<=$3 AND exception.expires_at>$3 AND exception.identity_ambiguity_count=0 + AND (mapped.application_id IS NULL OR (mapped.application_id=exception.application_id AND mapped.platform=exception.platform)) + AND (SELECT count(DISTINCT bounded.billing_customer_id) FROM billing_migration_source_access_exception_subjects bounded WHERE bounded.exception_id=exception.id)=exception.affected_customer_count + ))/count(*) END, + COALESCE((SELECT count FROM counts WHERE classification='critical'),0),COALESCE((SELECT count FROM counts WHERE classification='blocking'),0),COALESCE((SELECT count FROM counts WHERE classification='warning'),0),COALESCE((SELECT count FROM counts WHERE classification='informational'),0) FROM current_records`, projectID, programID, now).Scan(&input.CurrentAccessMappingPercent, &input.CurrentAccessEvidencePercent, &input.Unresolved.Critical, &input.Unresolved.Blocking, &input.Unresolved.Warning, &input.Unresolved.Informational) + return input, err +} + +func (r *Repository) CreateProposal(ctx context.Context, write billingmigration.ProposalWrite) (billingmigration.CutoverProposal, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.CutoverProposal{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + if stored, replay, err := commandReplay(ctx, tx, write.Proposal.ProgramID, write.ProjectID, "propose_cutover", write.IdempotencyKey, write.RequestDigest); err != nil { + return billingmigration.CutoverProposal{}, false, err + } else if replay { + proposal, readErr := readProposalTx(ctx, tx, write.ProjectID, write.Proposal.ProgramID, stored) + if readErr != nil { + return proposal, true, readErr + } + return proposal, true, tx.Commit(ctx) + } + p := write.Proposal + if p.Command == "cutover" { + if err := verifyPreApprovalDigests(ctx, tx, write.ProjectID, p.ProgramID, p.StateVersion, write.Digests); err != nil { + return billingmigration.CutoverProposal{}, false, err + } + } else if p.Command == "rollback" && write.ExpectedRollback != nil { + binding, digests, err := deriveRollbackPrerequisites(ctx, tx, write.ProjectID, p.ProgramID, p.StateVersion, p.ProposedAt, *write.ExpectedRollback) + if err != nil { + return billingmigration.CutoverProposal{}, false, err + } + p.RollbackBinding, p.Digests, write.Digests = &binding, formattedDigests(digests), digests + } else { + return billingmigration.CutoverProposal{}, false, billingmigration.ErrInvalid + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_cutover_proposals(id,program_id,project_id,state_version,command,proposer_actor_id,reason,scope_digest,manifest_digest,mapping_digest,policy_digest,evidence_digest,readiness_digest,final_watermark_digest,application_version_digest,proposal_digest,status,proposed_at,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,'pending',$17,$18)`, p.ProposalID, p.ProgramID, write.ProjectID, p.StateVersion, p.Command, p.ProposerActorID, p.Reason, write.Digests.Scope, write.Digests.Manifest, write.Digests.Mapping, write.Digests.Policy, write.Digests.Evidence, write.Digests.Readiness, write.Digests.FinalWatermark, write.Digests.ApplicationVersion, write.ProposalDigest, p.ProposedAt, p.ExpiresAt) + if err != nil { + return p, false, translate(err, "create cutover proposal") + } + if p.Command == "rollback" { + checkpoint, authority, prerequisites, scope, parseErr := p.RollbackBinding.Parse() + if parseErr != nil { + return p, false, billingmigration.ErrInvalid + } + b := p.RollbackBinding + transition, _ := billingmigration.ParseDigest(b.CutoverTransitionDigest) + capability, _ := billingmigration.ParseDigest(b.CapabilityAssessmentDigest) + sourceValidation, _ := billingmigration.ParseDigest(b.SourceValidationDigest) + providerValidation, _ := billingmigration.ParseDigest(b.ProviderValidationDigest) + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_rollback_proposal_bindings(proposal_id,program_id,project_id,checkpoint_id,checkpoint_digest,authority_digest,rollback_prerequisites_digest,scope_digest,cutover_transition_id,cutover_transition_digest,cutover_epoch,cutover_transitioned_at,rollback_deadline,credential_id,credential_status,credential_removed,credential_removed_at,capability_assessment_id,capability_assessment_digest,capability_assessed_at,source_validation_id,source_validation_digest,source_validated_at,provider_validation_id,provider_validation_digest,provider_validated_at,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27)`, p.ProposalID, p.ProgramID, write.ProjectID, b.CheckpointID, checkpoint, authority, prerequisites, scope, b.CutoverTransitionID, transition, b.CutoverEpoch, b.CutoverTransitionedAt, b.RollbackDeadline, b.CredentialID, b.CredentialStatus, b.CredentialRemoved, b.CredentialRemovedAt, b.CapabilityAssessmentID, capability, b.CapabilityAssessedAt, b.SourceValidationID, sourceValidation, b.SourceValidatedAt, b.ProviderValidationID, providerValidation, b.ProviderValidatedAt, p.ProposedAt) + if err != nil { + return p, false, translate(err, "bind rollback proposal") + } + } + if err = storeCommand(ctx, tx, p.ProgramID, write.ProjectID, "propose_cutover", write.IdempotencyKey, write.RequestDigest, p.ProposalID, p.ProposedAt); err != nil { + return p, false, err + } + var organizationID, environmentID string + if err := tx.QueryRow(ctx, `SELECT pr.organization_id,mp.environment_id FROM billing_migration_programs mp JOIN projects pr ON pr.id=mp.project_id WHERE mp.id=$1 AND mp.project_id=$2`, p.ProgramID, write.ProjectID).Scan(&organizationID, &environmentID); err != nil { + return p, false, err + } + metadata, _ := json.Marshal(map[string]any{"command": p.Command, "proposalDigest": p.ProposalDigest, "reason": p.Reason}) + if _, err := tx.Exec(ctx, `INSERT INTO audit_events(id,actor_id,organization_id,project_id,environment_id,action,resource_type,resource_id,metadata,created_at) VALUES($1,$2,$3,$4,$5,'billing.migration.cutover.proposed','billing_migration_cutover_proposal',$6,$7,$8)`, "aud_"+p.ProposalID, p.ProposerActorID, organizationID, write.ProjectID, environmentID, p.ProposalID, metadata, p.ProposedAt); err != nil { + return p, false, err + } + return p, false, tx.Commit(ctx) +} + +func (r *Repository) Proposal(ctx context.Context, projectID, programID, proposalID string) (billingmigration.CutoverProposal, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingmigration.CutoverProposal{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + p, err := readProposalTx(ctx, tx, projectID, programID, proposalID) + if err != nil { + return p, err + } + return p, tx.Commit(ctx) +} + +func (r *Repository) ApproveProposal(ctx context.Context, write billingmigration.ApprovalWrite) (billingmigration.MigrationApproval, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.MigrationApproval{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + a := write.Approval + if stored, replay, err := commandReplay(ctx, tx, a.ProgramID, write.ProjectID, "approve_cutover", write.IdempotencyKey, write.RequestDigest); err != nil { + return a, false, err + } else if replay { + var digest []byte + err = tx.QueryRow(ctx, `SELECT id,program_id,state_version,command,proposer_actor_id,approver_actor_id,approval_digest,approved_at,expires_at FROM billing_migration_approvals WHERE id=$1 AND project_id=$2`, stored, write.ProjectID).Scan(&a.ApprovalID, &a.ProgramID, &a.StateVersion, &a.Command, &a.ProposerActorID, &a.ApproverActorID, &digest, &a.ApprovedAt, &a.ExpiresAt) + a.ApprovalDigest = billingmigration.FormatDigest(digest) + if err != nil { + return a, true, err + } + return a, true, tx.Commit(ctx) + } + p, err := readProposalForUpdate(ctx, tx, write.ProjectID, a.ProgramID, write.ProposalID) + if err != nil { + return a, false, err + } + now := a.ApprovedAt + if p.Status != "pending" || !p.ExpiresAt.After(now) { + if p.Status == "pending" { + _, _ = tx.Exec(ctx, `UPDATE billing_migration_cutover_proposals SET status='expired' WHERE id=$1`, p.ProposalID) + _ = tx.Commit(ctx) + } + return a, false, billingmigration.ErrConflict + } + digests, _ := p.Digests.Parse() + if err := verifyProposalPrerequisites(ctx, tx, write.ProjectID, p, digests, now); err != nil { + _, _ = tx.Exec(ctx, `UPDATE billing_migration_cutover_proposals SET status='invalidated',invalidated_at=$2 WHERE id=$1`, p.ProposalID, now) + _ = tx.Commit(ctx) + return a, false, err + } + var mode string + if err := tx.QueryRow(ctx, `SELECT e.mode FROM billing_migration_programs p JOIN environments e ON e.id=p.environment_id AND e.project_id=p.project_id WHERE p.id=$1 AND p.project_id=$2`, a.ProgramID, write.ProjectID).Scan(&mode); err != nil { + return a, false, err + } + if mode == "production" && p.ProposerActorID == a.ApproverActorID { + return a, false, billingmigration.ErrForbidden + } + if a.ProposerActorID != p.ProposerActorID || a.Command != p.Command || a.ExpiresAt != p.ExpiresAt { + return a, false, billingmigration.ErrConflict + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_approvals(id,program_id,project_id,proposal_id,state_version,command,proposer_actor_id,approver_actor_id,approval_digest,approved_at,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, a.ApprovalID, a.ProgramID, write.ProjectID, p.ProposalID, a.StateVersion, a.Command, a.ProposerActorID, a.ApproverActorID, write.ApprovalDigest, a.ApprovedAt, a.ExpiresAt) + if err != nil { + return a, false, translate(err, "approve cutover proposal") + } + _, err = tx.Exec(ctx, `UPDATE billing_migration_cutover_proposals SET status='approved' WHERE id=$1 AND status='pending'`, p.ProposalID) + if err != nil { + return a, false, err + } + if err = storeCommand(ctx, tx, a.ProgramID, write.ProjectID, "approve_cutover", write.IdempotencyKey, write.RequestDigest, a.ApprovalID, a.ApprovedAt); err != nil { + return a, false, err + } + return a, false, tx.Commit(ctx) +} + +func (r *Repository) CreateCheckpoint(ctx context.Context, write billingmigration.CheckpointWrite) (billingmigration.MigrationCheckpoint, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.MigrationCheckpoint{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + c := write.Checkpoint + if stored, replay, err := commandReplay(ctx, tx, c.ProgramID, write.ProjectID, "create_checkpoint", write.IdempotencyKey, write.RequestDigest); err != nil { + return c, false, err + } else if replay { + err = scanCheckpoint(tx.QueryRow(ctx, checkpointSelect+` WHERE c.id=$1 AND c.project_id=$2`, stored, write.ProjectID), &c) + if err != nil { + return c, true, err + } + return c, true, tx.Commit(ctx) + } + if err := verifyPreApprovalDigests(ctx, tx, write.ProjectID, c.ProgramID, c.StateVersion, write.Digests); err != nil { + invalidateApproval(ctx, tx, write.ApprovalID, c.CreatedAt) + _ = tx.Commit(ctx) + return c, false, billingmigration.ErrConflict + } + var approvalDigest []byte + var expires time.Time + var status string + err = tx.QueryRow(ctx, `SELECT a.approval_digest,a.expires_at,p.status FROM billing_migration_approvals a JOIN billing_migration_cutover_proposals p ON p.id=a.proposal_id WHERE a.id=$1 AND a.program_id=$2 AND a.project_id=$3 FOR UPDATE`, write.ApprovalID, c.ProgramID, write.ProjectID).Scan(&approvalDigest, &expires, &status) + if errors.Is(err, pgx.ErrNoRows) { + return c, false, billingmigration.ErrConflict + } else if err != nil { + return c, false, err + } + if !bytes.Equal(approvalDigest, write.ApprovalDigest) || status != "approved" || !expires.After(c.CreatedAt) { + invalidateApproval(ctx, tx, write.ApprovalID, c.CreatedAt) + _ = tx.Commit(ctx) + return c, false, billingmigration.ErrConflict + } + var epoch int64 + var scopeCount, authorityCount, distinctEpochs int + err = tx.QueryRow(ctx, `SELECT (SELECT count(*) FROM billing_migration_program_scopes WHERE program_id=$1),count(a.id) FILTER(WHERE a.active_program_id=$1),count(DISTINCT a.current_epoch) FILTER(WHERE a.active_program_id=$1),COALESCE(min(a.current_epoch) FILTER(WHERE a.active_program_id=$1),0) FROM billing_migration_program_scopes s LEFT JOIN billing_migration_authority_scopes a ON a.project_id=s.project_id AND a.environment_id=s.environment_id AND a.application_id=s.application_id AND a.platform=s.platform WHERE s.program_id=$1`, c.ProgramID).Scan(&scopeCount, &authorityCount, &distinctEpochs, &epoch) + if err != nil { + return c, false, err + } + if scopeCount == 0 || authorityCount != scopeCount || distinctEpochs != 1 { + return c, false, billingmigration.ErrConflict + } + c.AuthorityEpoch = epoch + var cohortSetID string + var finalDeltaJobID string + var finalDeltaGeneration int64 + var cohortDigest []byte + var cohortCustomers int + if err := tx.QueryRow(ctx, `SELECT set.id,set.cohort_digest,set.customer_count,job.id,job.lease_generation FROM billing_migration_final_delta_cohort_sets set JOIN billing_migration_final_deltas delta ON delta.id=set.final_delta_id JOIN billing_migration_final_delta_jobs job ON job.result_final_delta_id=delta.id AND job.program_id=set.program_id WHERE set.program_id=$1 AND set.project_id=$2 AND job.status='completed' ORDER BY delta.completed_at DESC,delta.id DESC LIMIT 1`, c.ProgramID, write.ProjectID).Scan(&cohortSetID, &cohortDigest, &cohortCustomers, &finalDeltaJobID, &finalDeltaGeneration); err != nil || !bytes.Equal(cohortDigest, write.CohortDigest) { + return c, false, billingmigration.ErrConflict + } + c.CohortDigest = billingmigration.FormatDigest(cohortDigest) + var preparedCount int + if err := tx.QueryRow(ctx, `SELECT count(*) FROM billing_migration_final_delta_prepared_pointers WHERE final_delta_job_id=$1 AND lease_generation=$2`, finalDeltaJobID, finalDeltaGeneration).Scan(&preparedCount); err != nil { + return c, false, err + } + if cohortCustomers == 0 || preparedCount != scopeCount*cohortCustomers { + return c, false, billingmigration.ErrConflict + } + var invalidCoverage bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM billing_migration_final_delta_prepared_pointers p WHERE p.final_delta_job_id=$3 AND p.lease_generation=$4 AND NOT EXISTS(SELECT 1 FROM billing_migration_final_delta_cohort_customers c WHERE c.cohort_set_id=$2 AND c.billing_customer_id=p.billing_customer_id)) OR EXISTS(SELECT 1 FROM billing_migration_program_scopes s CROSS JOIN billing_migration_final_delta_cohort_customers c WHERE s.program_id=$1 AND c.cohort_set_id=$2 AND NOT EXISTS(SELECT 1 FROM billing_migration_final_delta_prepared_pointers p WHERE p.final_delta_job_id=$3 AND p.lease_generation=$4 AND p.application_id=s.application_id AND p.platform=s.platform AND p.billing_customer_id=c.billing_customer_id))`, c.ProgramID, cohortSetID, finalDeltaJobID, finalDeltaGeneration).Scan(&invalidCoverage); err != nil { + return c, false, err + } + if invalidCoverage { + return c, false, billingmigration.ErrConflict + } + var scope billingmigration.Scope + scope.ProjectID = write.ProjectID + err = tx.QueryRow(ctx, `SELECT environment_id FROM billing_migration_programs WHERE id=$1 AND project_id=$2`, c.ProgramID, write.ProjectID).Scan(&scope.EnvironmentID) + if err != nil { + return c, false, err + } + rows, err := tx.Query(ctx, `SELECT application_id,platform FROM billing_migration_program_scopes WHERE program_id=$1 ORDER BY application_id,platform`, c.ProgramID) + if err != nil { + return c, false, err + } + for rows.Next() { + var item billingmigration.ScopeItem + if err := rows.Scan(&item.ApplicationID, &item.Platform); err != nil { + rows.Close() + return c, false, err + } + scope.Applications = append(scope.Applications, item) + } + rows.Close() + c.Scope = scope + err = tx.QueryRow(ctx, `SELECT source_watermark,provider_watermark,shadow_watermark FROM billing_migration_final_deltas WHERE program_id=$1 AND project_id=$2 ORDER BY completed_at DESC,id LIMIT 1`, c.ProgramID, write.ProjectID).Scan(&c.SourceWatermark, &c.ProviderWatermark, &c.ShadowWatermark) + if err != nil { + return c, false, billingmigration.ErrConflict + } + expectedStateVersion := c.StateVersion + c.StateVersion++ + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_checkpoints(id,program_id,project_id,state_version,authority_epoch,source_watermark,provider_watermark,shadow_watermark,scope_digest,manifest_digest,mapping_digest,policy_digest,evidence_digest,readiness_digest,final_watermark_digest,application_version_digest,approval_digest,checkpoint_digest,created_at,cohort_digest) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)`, c.CheckpointID, c.ProgramID, write.ProjectID, c.StateVersion, c.AuthorityEpoch, c.SourceWatermark, c.ProviderWatermark, c.ShadowWatermark, write.Digests.Scope, write.Digests.Manifest, write.Digests.Mapping, write.Digests.Policy, write.Digests.Evidence, write.Digests.Readiness, write.Digests.FinalWatermark, write.Digests.ApplicationVersion, write.ApprovalDigest, write.CheckpointDigest, c.CreatedAt, write.CohortDigest) + if err != nil { + return c, false, translate(err, "create migration checkpoint") + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_checkpoint_pointer_maps(id,checkpoint_id,program_id,project_id,environment_id,application_id,platform,billing_customer_id,pointer_role,snapshot_id,absent_current,pointer_digest) + SELECT 'mcm_'||md5($1||':activation:'||p.application_id||':'||p.platform||':'||p.billing_customer_id),$1,p.program_id,p.project_id,p.environment_id,p.application_id,p.platform,p.billing_customer_id,'prepared_activation',p.prepared_snapshot_id,false,p.prepared_digest + FROM billing_migration_final_delta_prepared_pointers p JOIN billing_migration_final_delta_cohort_customers cohort ON cohort.cohort_set_id=$3 AND cohort.billing_customer_id=p.billing_customer_id WHERE p.program_id=$2 AND p.final_delta_job_id=$4 AND p.lease_generation=$5 + UNION ALL + SELECT 'mcm_'||md5($1||':rollback:'||p.application_id||':'||p.platform||':'||p.billing_customer_id),$1,p.program_id,p.project_id,p.environment_id,p.application_id,p.platform,p.billing_customer_id,'rollback_baseline',current.current_snapshot_id,(current.current_snapshot_id IS NULL),decode(md5($1||':rollback:'||p.application_id||':'||p.platform||':'||p.billing_customer_id||':'||COALESCE(current.current_snapshot_id,'absent'))||md5(COALESCE(current.current_snapshot_id,'absent')||':'||$1),'hex') + FROM billing_migration_final_delta_prepared_pointers p LEFT JOIN billing_migration_scope_current_pointers current ON current.project_id=p.project_id AND current.environment_id=p.environment_id AND current.application_id=p.application_id AND current.platform=p.platform AND current.billing_customer_id=p.billing_customer_id + JOIN billing_migration_final_delta_cohort_customers cohort ON cohort.cohort_set_id=$3 AND cohort.billing_customer_id=p.billing_customer_id WHERE p.program_id=$2 AND p.final_delta_job_id=$4 AND p.lease_generation=$5`, c.CheckpointID, c.ProgramID, cohortSetID, finalDeltaJobID, finalDeltaGeneration) + if err != nil { + return c, false, translate(err, "snapshot checkpoint pointers") + } + if err = storeCommand(ctx, tx, c.ProgramID, write.ProjectID, "create_checkpoint", write.IdempotencyKey, write.RequestDigest, c.CheckpointID, c.CreatedAt); err != nil { + return c, false, err + } + tag, err := tx.Exec(ctx, `UPDATE billing_migration_programs SET state='cutover_pending',state_version=state_version+1,updated_at=$4 WHERE id=$1 AND project_id=$2 AND state='ready' AND state_version=$3`, c.ProgramID, write.ProjectID, expectedStateVersion, c.CreatedAt) + if err != nil { + return c, false, err + } + if tag.RowsAffected() != 1 { + return c, false, billingmigration.ErrConflict + } + return c, false, tx.Commit(ctx) +} + +func commandReplay(ctx context.Context, tx pgx.Tx, programID, projectID, commandKind, key string, requestDigest []byte) (string, bool, error) { + var resourceID string + var storedDigest []byte + err := tx.QueryRow(ctx, `SELECT resource_id,request_digest FROM billing_migration_command_idempotency WHERE program_id=$1 AND project_id=$2 AND command_kind=$3 AND idempotency_key=$4`, programID, projectID, commandKind, key).Scan(&resourceID, &storedDigest) + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, err + } + if !bytes.Equal(storedDigest, requestDigest) { + return "", false, billingmigration.ErrConflict + } + return resourceID, true, nil +} + +func storeCommand(ctx context.Context, tx pgx.Tx, programID, projectID, commandKind, key string, requestDigest []byte, resourceID string, createdAt time.Time) error { + _, err := tx.Exec(ctx, `INSERT INTO billing_migration_command_idempotency(id,program_id,project_id,command_kind,idempotency_key,request_digest,resource_id,created_at) VALUES('mci_'||md5($1||':'||$3||':'||$4),$1,$2,$3,$4,$5,$6,$7)`, programID, projectID, commandKind, key, requestDigest, resourceID, createdAt) + if err == nil { + return nil + } + return translate(err, "store cutover command idempotency") +} + +func readProposalTx(ctx context.Context, tx pgx.Tx, projectID, programID, proposalID string) (billingmigration.CutoverProposal, error) { + return readProposal(ctx, tx, projectID, programID, proposalID, false) +} + +func readProposalForUpdate(ctx context.Context, tx pgx.Tx, projectID, programID, proposalID string) (billingmigration.CutoverProposal, error) { + return readProposal(ctx, tx, projectID, programID, proposalID, true) +} + +func readProposal(ctx context.Context, tx pgx.Tx, projectID, programID, proposalID string, lock bool) (billingmigration.CutoverProposal, error) { + query := `SELECT id,program_id,state_version,command,proposer_actor_id,reason,scope_digest,manifest_digest,mapping_digest,policy_digest,evidence_digest,readiness_digest,final_watermark_digest,application_version_digest,proposal_digest,status,proposed_at,expires_at FROM billing_migration_cutover_proposals WHERE id=$1 AND program_id=$2 AND project_id=$3` + if lock { + query += ` FOR UPDATE` + } + var p billingmigration.CutoverProposal + var scope, manifest, mapping, policy, evidence, readiness, watermark, appVersion, proposal []byte + err := tx.QueryRow(ctx, query, proposalID, programID, projectID).Scan(&p.ProposalID, &p.ProgramID, &p.StateVersion, &p.Command, &p.ProposerActorID, &p.Reason, &scope, &manifest, &mapping, &policy, &evidence, &readiness, &watermark, &appVersion, &proposal, &p.Status, &p.ProposedAt, &p.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return p, billingmigration.ErrNotFound + } + if err != nil { + return p, err + } + p.Digests = billingmigration.PreApprovalDigests{Scope: billingmigration.FormatDigest(scope), Manifest: billingmigration.FormatDigest(manifest), Mapping: billingmigration.FormatDigest(mapping), Policy: billingmigration.FormatDigest(policy), Evidence: billingmigration.FormatDigest(evidence), Readiness: billingmigration.FormatDigest(readiness), FinalWatermark: billingmigration.FormatDigest(watermark), ApplicationVersion: billingmigration.FormatDigest(appVersion)} + p.ProposalDigest = billingmigration.FormatDigest(proposal) + if p.Command == "rollback" { + var binding billingmigration.RollbackProposalBinding + var checkpoint, authority, prerequisites, bindingScope []byte + var transition, capability, sourceValidation, providerValidation []byte + if err := tx.QueryRow(ctx, `SELECT checkpoint_id,checkpoint_digest,authority_digest,rollback_prerequisites_digest,scope_digest,cutover_transition_id,cutover_transition_digest,cutover_epoch,cutover_transitioned_at,rollback_deadline,credential_id,credential_status,credential_removed,credential_removed_at,capability_assessment_id,capability_assessment_digest,capability_assessed_at,source_validation_id,source_validation_digest,source_validated_at,provider_validation_id,provider_validation_digest,provider_validated_at FROM billing_migration_rollback_proposal_bindings WHERE proposal_id=$1 AND program_id=$2 AND project_id=$3`, proposalID, programID, projectID).Scan(&binding.CheckpointID, &checkpoint, &authority, &prerequisites, &bindingScope, &binding.CutoverTransitionID, &transition, &binding.CutoverEpoch, &binding.CutoverTransitionedAt, &binding.RollbackDeadline, &binding.CredentialID, &binding.CredentialStatus, &binding.CredentialRemoved, &binding.CredentialRemovedAt, &binding.CapabilityAssessmentID, &capability, &binding.CapabilityAssessedAt, &binding.SourceValidationID, &sourceValidation, &binding.SourceValidatedAt, &binding.ProviderValidationID, &providerValidation, &binding.ProviderValidatedAt); err != nil { + return p, err + } + binding.CheckpointDigest = billingmigration.FormatDigest(checkpoint) + binding.AuthorityDigest = billingmigration.FormatDigest(authority) + binding.RollbackPrerequisitesDigest = billingmigration.FormatDigest(prerequisites) + binding.ScopeDigest = billingmigration.FormatDigest(bindingScope) + binding.CutoverTransitionDigest = billingmigration.FormatDigest(transition) + binding.CapabilityAssessmentDigest = billingmigration.FormatDigest(capability) + binding.SourceValidationDigest = billingmigration.FormatDigest(sourceValidation) + binding.ProviderValidationDigest = billingmigration.FormatDigest(providerValidation) + p.RollbackBinding = &binding + } + return p, nil +} + +func verifyProposalPrerequisites(ctx context.Context, tx pgx.Tx, projectID string, proposal billingmigration.CutoverProposal, digests billingmigration.ParsedDigests, operationAt time.Time) error { + if proposal.Command == "cutover" { + return verifyPreApprovalDigests(ctx, tx, projectID, proposal.ProgramID, proposal.StateVersion, digests) + } + if proposal.Command != "rollback" || proposal.RollbackBinding == nil { + return billingmigration.ErrInvalid + } + expected := billingmigration.RollbackExpectedBinding{CheckpointID: proposal.RollbackBinding.CheckpointID, CheckpointDigest: proposal.RollbackBinding.CheckpointDigest, AuthorityDigest: proposal.RollbackBinding.AuthorityDigest, RollbackPrerequisitesDigest: proposal.RollbackBinding.RollbackPrerequisitesDigest} + _, _, err := deriveRollbackPrerequisites(ctx, tx, projectID, proposal.ProgramID, proposal.StateVersion, operationAt, expected) + return err +} + +func deriveRollbackPrerequisites(ctx context.Context, tx pgx.Tx, projectID, programID string, stateVersion int64, operationAt time.Time, expected billingmigration.RollbackExpectedBinding) (billingmigration.RollbackProposalBinding, billingmigration.ParsedDigests, error) { + var binding billingmigration.RollbackProposalBinding + var d billingmigration.ParsedDigests + var state, credentialID string + var rollbackDays int + if err := tx.QueryRow(ctx, `SELECT state,scope_digest,credential_id,rollback_window_days FROM billing_migration_programs WHERE id=$1 AND project_id=$2 AND state_version=$3 FOR UPDATE`, programID, projectID, stateVersion).Scan(&state, &d.Scope, &credentialID, &rollbackDays); errors.Is(err, pgx.ErrNoRows) || state != billingmigration.StateStabilizing { + return binding, d, billingmigration.ErrStaleRollbackPrerequisites + } else if err != nil { + return binding, d, err + } + binding.CheckpointID, binding.CheckpointDigest = expected.CheckpointID, expected.CheckpointDigest + var checkpoint []byte + if err := tx.QueryRow(ctx, `SELECT checkpoint_digest,manifest_digest,mapping_digest,policy_digest,evidence_digest,readiness_digest,final_watermark_digest,application_version_digest FROM billing_migration_checkpoints WHERE id=$1 AND program_id=$2 AND project_id=$3`, expected.CheckpointID, programID, projectID).Scan(&checkpoint, &d.Manifest, &d.Mapping, &d.Policy, &d.Evidence, &d.Readiness, &d.FinalWatermark, &d.ApplicationVersion); errors.Is(err, pgx.ErrNoRows) || !bytes.Equal(checkpoint, mustDigest(expected.CheckpointDigest)) { + return binding, d, billingmigration.ErrStaleCheckpoint + } else if err != nil { + return binding, d, err + } + binding.ScopeDigest = billingmigration.FormatDigest(d.Scope) + rows, err := tx.Query(ctx, `SELECT authority.authority_digest,authority.current_epoch FROM billing_migration_program_scopes scope JOIN billing_migration_authority_scopes authority ON authority.project_id=scope.project_id AND authority.environment_id=scope.environment_id AND authority.application_id=scope.application_id AND authority.platform=scope.platform AND authority.active_program_id=scope.program_id AND authority.current_authority='mosaic' WHERE scope.program_id=$1 AND scope.project_id=$2 ORDER BY scope.application_id,scope.platform`, programID, projectID) + if err != nil { + return binding, d, err + } + var authorities []string + var authorityEpoch *int64 + for rows.Next() { + var raw []byte + var epoch int64 + if err := rows.Scan(&raw, &epoch); err != nil { + rows.Close() + return binding, d, err + } + if authorityEpoch != nil && *authorityEpoch != epoch { + rows.Close() + return binding, d, billingmigration.ErrStaleAuthority + } + if authorityEpoch == nil { + authorityEpoch = &epoch + } + authorities = append(authorities, billingmigration.FormatDigest(raw)) + } + rows.Close() + var scopeCount, activeCount int + if err := tx.QueryRow(ctx, `SELECT (SELECT count(*) FROM billing_migration_program_scopes WHERE program_id=$1 AND project_id=$2),(SELECT count(*) FROM billing_migration_authority_scopes WHERE active_program_id=$1 AND project_id=$2 AND current_authority='mosaic')`, programID, projectID).Scan(&scopeCount, &activeCount); err != nil { + return binding, d, err + } + if scopeCount == 0 || len(authorities) != scopeCount || activeCount != scopeCount { + return binding, d, billingmigration.ErrStaleAuthority + } + binding.AuthorityDigest, err = billingmigration.AuthoritySetDigest(programID, binding.ScopeDigest, authorities) + if err != nil || binding.AuthorityDigest != expected.AuthorityDigest { + return binding, d, billingmigration.ErrStaleAuthority + } + var transitionDigest []byte + var transitionCount, epochCount, timeCount int + if err := tx.QueryRow(ctx, `SELECT count(*),count(DISTINCT to_epoch),count(DISTINCT transitioned_at),max(to_epoch),max(transitioned_at) FROM billing_migration_authority_transitions WHERE program_id=$1 AND project_id=$2 AND transition_kind='cutover'`, programID, projectID).Scan(&transitionCount, &epochCount, &timeCount, &binding.CutoverEpoch, &binding.CutoverTransitionedAt); err != nil || transitionCount != scopeCount || epochCount != 1 || timeCount != 1 { + return binding, d, billingmigration.ErrStaleAuthority + } + if authorityEpoch == nil || *authorityEpoch != binding.CutoverEpoch { + return binding, d, billingmigration.ErrStaleAuthority + } + if err := tx.QueryRow(ctx, `SELECT id,transition_digest FROM billing_migration_authority_transitions WHERE program_id=$1 AND project_id=$2 AND transition_kind='cutover' ORDER BY id DESC LIMIT 1`, programID, projectID).Scan(&binding.CutoverTransitionID, &transitionDigest); err != nil { + return binding, d, billingmigration.ErrStaleAuthority + } + binding.CutoverTransitionDigest = billingmigration.FormatDigest(transitionDigest) + binding.RollbackDeadline = binding.CutoverTransitionedAt.Add(time.Duration(rollbackDays) * 24 * time.Hour) + if operationAt.After(binding.RollbackDeadline) { + return binding, d, billingmigration.ErrStaleRollbackPrerequisites + } + binding.CredentialID = credentialID + var envelopePresent bool + if err := tx.QueryRow(ctx, `SELECT status,(removed_at IS NOT NULL),removed_at,(nonce IS NOT NULL AND ciphertext IS NOT NULL) FROM billing_migration_credentials WHERE id=$1 AND project_id=$2`, credentialID, projectID).Scan(&binding.CredentialStatus, &binding.CredentialRemoved, &binding.CredentialRemovedAt, &envelopePresent); err != nil { + return binding, d, billingmigration.ErrStaleRollbackPrerequisites + } + if binding.CredentialStatus != "active" || binding.CredentialRemoved || binding.CredentialRemovedAt != nil || !envelopePresent { + return binding, d, billingmigration.ErrStaleRollbackPrerequisites + } + var raw []byte + if err := tx.QueryRow(ctx, `SELECT id,assessment_digest,assessed_at FROM billing_migration_capability_assessments WHERE program_id=$1 AND project_id=$2 ORDER BY assessed_at DESC,id DESC LIMIT 1`, programID, projectID).Scan(&binding.CapabilityAssessmentID, &raw, &binding.CapabilityAssessedAt); err != nil { + return binding, d, billingmigration.ErrStaleRollbackPrerequisites + } + binding.CapabilityAssessmentDigest = billingmigration.FormatDigest(raw) + if err := tx.QueryRow(ctx, `SELECT id,result_digest,attempted_at FROM billing_migration_validation_attempts WHERE program_id=$1 AND project_id=$2 AND attempt_kind='source_validation' AND status='succeeded' ORDER BY attempted_at DESC,id DESC LIMIT 1`, programID, projectID).Scan(&binding.SourceValidationID, &raw, &binding.SourceValidatedAt); err != nil { + return binding, d, billingmigration.ErrStaleRollbackPrerequisites + } + binding.SourceValidationDigest = billingmigration.FormatDigest(raw) + if err := tx.QueryRow(ctx, `SELECT id,result_digest,attempted_at FROM billing_migration_validation_attempts WHERE program_id=$1 AND project_id=$2 AND attempt_kind='provider_validation' AND status='succeeded' ORDER BY attempted_at DESC,id DESC LIMIT 1`, programID, projectID).Scan(&binding.ProviderValidationID, &raw, &binding.ProviderValidatedAt); err != nil { + return binding, d, billingmigration.ErrStaleRollbackPrerequisites + } + binding.ProviderValidationDigest = billingmigration.FormatDigest(raw) + binding.RollbackPrerequisitesDigest, err = billingmigration.RollbackPrerequisitesDigest(programID, stateVersion, binding) + if err != nil || binding.RollbackPrerequisitesDigest != expected.RollbackPrerequisitesDigest { + return binding, d, billingmigration.ErrStaleRollbackPrerequisites + } + return binding, d, nil +} + +func formattedDigests(d billingmigration.ParsedDigests) billingmigration.PreApprovalDigests { + return billingmigration.PreApprovalDigests{Scope: billingmigration.FormatDigest(d.Scope), Manifest: billingmigration.FormatDigest(d.Manifest), Mapping: billingmigration.FormatDigest(d.Mapping), Policy: billingmigration.FormatDigest(d.Policy), Evidence: billingmigration.FormatDigest(d.Evidence), Readiness: billingmigration.FormatDigest(d.Readiness), FinalWatermark: billingmigration.FormatDigest(d.FinalWatermark), ApplicationVersion: billingmigration.FormatDigest(d.ApplicationVersion)} +} + +func mustDigest(value string) []byte { + raw, _ := billingmigration.ParseDigest(value) + return raw +} + +func verifyPreApprovalDigests(ctx context.Context, tx pgx.Tx, projectID, programID string, stateVersion int64, d billingmigration.ParsedDigests) error { + var state string + var scope []byte + if err := tx.QueryRow(ctx, `SELECT state,scope_digest FROM billing_migration_programs WHERE id=$1 AND project_id=$2 AND state_version=$3 FOR UPDATE`, programID, projectID, stateVersion).Scan(&state, &scope); errors.Is(err, pgx.ErrNoRows) || state != billingmigration.StateReady { + return billingmigration.ErrConflict + } else if err != nil { + return err + } + if !bytes.Equal(scope, d.Scope) { + return billingmigration.ErrConflict + } + var manifest, mapping, evidence, watermark []byte + if err := tx.QueryRow(ctx, `SELECT manifest_digest,mapping_digest,evidence_digest,final_watermark_digest FROM billing_migration_final_deltas WHERE program_id=$1 AND project_id=$2 ORDER BY completed_at DESC,id DESC LIMIT 1`, programID, projectID).Scan(&manifest, &mapping, &evidence, &watermark); err != nil { + return billingmigration.ErrConflict + } + var policy, appVersion []byte + if err := tx.QueryRow(ctx, `SELECT policy_digest,application_version_digest FROM billing_migration_readiness_policies WHERE program_id=$1 AND project_id=$2 ORDER BY frozen_at DESC,id DESC LIMIT 1`, programID, projectID).Scan(&policy, &appVersion); err != nil { + return billingmigration.ErrConflict + } + var readiness []byte + var ready bool + if err := tx.QueryRow(ctx, `SELECT readiness_digest,ready FROM billing_migration_readiness_assessments WHERE program_id=$1 AND project_id=$2 AND authoritative ORDER BY assessed_at DESC,id DESC LIMIT 1`, programID, projectID).Scan(&readiness, &ready); err != nil || !ready { + return billingmigration.ErrConflict + } + actual := [][]byte{manifest, mapping, policy, evidence, readiness, watermark, appVersion} + expected := [][]byte{d.Manifest, d.Mapping, d.Policy, d.Evidence, d.Readiness, d.FinalWatermark, d.ApplicationVersion} + for i := range actual { + if !bytes.Equal(actual[i], expected[i]) { + return billingmigration.ErrConflict + } + } + return nil +} + +func invalidateApproval(ctx context.Context, tx pgx.Tx, approvalID string, invalidatedAt time.Time) { + _, _ = tx.Exec(ctx, `UPDATE billing_migration_cutover_proposals p SET status='invalidated',invalidated_at=$2 FROM billing_migration_approvals a WHERE a.id=$1 AND a.proposal_id=p.id AND p.status='approved'`, approvalID, invalidatedAt) +} + +const checkpointSelect = `SELECT c.id,c.program_id,c.state_version,p.environment_id,c.authority_epoch,c.source_watermark,c.provider_watermark,c.shadow_watermark,c.manifest_digest,c.mapping_digest,c.policy_digest,c.readiness_digest,c.checkpoint_digest,c.cohort_digest,c.created_at,COALESCE((SELECT jsonb_agg(jsonb_build_object('applicationId',s.application_id,'platform',s.platform) ORDER BY s.application_id,s.platform) FROM billing_migration_program_scopes s WHERE s.program_id=c.program_id),'[]'::jsonb) FROM billing_migration_checkpoints c JOIN billing_migration_programs p ON p.id=c.program_id AND p.project_id=c.project_id` + +func scanCheckpoint(row rowScanner, checkpoint *billingmigration.MigrationCheckpoint) error { + var manifest, mapping, policy, readiness, checkpointDigest, cohortDigest, applicationsJSON []byte + checkpoint.Scope.Applications = nil + err := row.Scan(&checkpoint.CheckpointID, &checkpoint.ProgramID, &checkpoint.StateVersion, &checkpoint.Scope.EnvironmentID, &checkpoint.AuthorityEpoch, &checkpoint.SourceWatermark, &checkpoint.ProviderWatermark, &checkpoint.ShadowWatermark, &manifest, &mapping, &policy, &readiness, &checkpointDigest, &cohortDigest, &checkpoint.CreatedAt, &applicationsJSON) + if err != nil { + return err + } + checkpoint.ManifestDigest, checkpoint.MappingDigest = billingmigration.FormatDigest(manifest), billingmigration.FormatDigest(mapping) + checkpoint.PolicyDigest, checkpoint.ReadinessDigest = billingmigration.FormatDigest(policy), billingmigration.FormatDigest(readiness) + checkpoint.CheckpointDigest = billingmigration.FormatDigest(checkpointDigest) + checkpoint.CohortDigest = billingmigration.FormatDigest(cohortDigest) + return json.Unmarshal(applicationsJSON, &checkpoint.Scope.Applications) +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go new file mode 100644 index 00000000..fdc4e6f9 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go @@ -0,0 +1,398 @@ +package billingmigrationpostgres_test + +import ( + "context" + "database/sql" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +func TestCutoverPreparationRequiresAuthoritativeEvidenceAndDistinctProductionApproval(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err := goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err := goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("apply migrations: %v", err) + } + seedMigrationTenant(t, ctx, db) + if _, err := db.ExecContext(ctx, `INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) VALUES('org_one','owner_two','owner',now(),now())`); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + seedReadyProgram(t, ctx, db, now) + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + service := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return now })) + + if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 3}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("stale readiness CAS error = %v", err) + } + if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 4}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("unsigned current-access evidence error = %v", err) + } + insertExceptionFixture(t, ctx, pool, "expired", "app_one", "ios", 1, now.Add(-2*time.Hour), now.Add(-time.Hour)) + if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 4}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("expired source-access exception error = %v", err) + } + insertExceptionFixture(t, ctx, pool, "wrong_scope", "app_two", "android", 1, now.Add(-time.Minute), now.Add(time.Hour)) + if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 4}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("scope-mismatched source-access exception error = %v", err) + } + insertExceptionFixture(t, ctx, pool, "overcount", "app_one", "ios", 2, now.Add(-time.Minute), now.Add(time.Hour)) + if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 4}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("aggregate-only source-access exception error = %v", err) + } + insertExceptionFixture(t, ctx, pool, "valid", "app_one", "ios", 1, now.Add(-time.Minute), now.Add(time.Hour)) + if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 4}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("measured app version without v2 observation error = %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('sync_rejected_android','program_ready','project_one','app_two','android','2.10.0+android.1','2.1.0-rc.1',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],10,0,'rejected',decode(repeat('83',32),'hex'),$1)`, now); err != nil { + t.Fatal(err) + } + if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 4}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("rejected in-window v2 observation error = %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('sync_ready_android','program_ready','project_one','app_two','android','2.10.0+android.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],10,0,'accepted',decode(repeat('77',32),'hex'),$1)`, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_runs(id,program_id,project_id,run_kind,state_version,manifest_digest,mapping_digest,policy_digest,source_watermark,provider_watermark,shadow_watermark,critical_count,blocking_count,warning_count,informational_count,run_digest,completed_at) VALUES('run_old','program_ready','project_one','shadow',4,decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('71',32),'hex'),'s','p','m',0,1,0,0,decode(repeat('84',32),'hex'),$1::timestamptz-interval '2 minutes'),('run_new','program_ready','project_one','shadow',4,decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('71',32),'hex'),'s','p','m',0,0,0,0,decode(repeat('85',32),'hex'),$1::timestamptz-interval '1 minute')`, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_divergences(id,program_id,project_id,run_id,state_version,classification,reason,evidence_digest,classification_rule_version,observed_at) VALUES('divergence_old','program_ready','project_one','run_old',4,'blocking','mapping_missing',decode(repeat('86',32),'hex'),'v1',$1::timestamptz-interval '2 minutes')`, now); err != nil { + t.Fatal(err) + } + if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 4}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("older unresolved divergence disappeared behind latest run: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_divergence_resolutions(id,divergence_id,program_id,project_id,resolution,actor_id,reason,resolution_digest,resolved_at) VALUES('resolution_old','divergence_old','program_ready','project_one','revalidated','owner_one','verified exact state',decode(repeat('87',32),'hex'),$1)`, now); err != nil { + t.Fatal(err) + } + readiness, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 4}) + if err != nil || !readiness.Assessment.Ready || !readiness.SourceCapabilitiesFresh { + t.Fatalf("promote ready = %#v err=%v", readiness, err) + } + var cohortCount, collisionCount int + if err := pool.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE billing_customer_id='customer_two') FROM billing_migration_final_delta_cohort_customers WHERE program_id='program_ready'`).Scan(&cohortCount, &collisionCount); err != nil || cohortCount != 1 || collisionCount != 0 { + t.Fatalf("identity-only final cohort count=%d collision=%d err=%v", cohortCount, collisionCount, err) + } + digests := billingmigration.PreApprovalDigests{ + Scope: billingmigration.FormatDigest(bytesOf(0x61)), Manifest: billingmigration.FormatDigest(bytesOf(0x65)), + Mapping: billingmigration.FormatDigest(bytesOf(0x66)), Policy: billingmigration.FormatDigest(bytesOf(0x71)), + Evidence: billingmigration.FormatDigest(bytesOf(0x67)), Readiness: readiness.Assessment.ReadinessDigest, + FinalWatermark: billingmigration.FormatDigest(bytesOf(0x68)), ApplicationVersion: billingmigration.FormatDigest(bytesOf(0x70)), + } + stale := digests + stale.Manifest = billingmigration.FormatDigest(bytesOf(0x7f)) + if _, _, err := service.ProposeCutover(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.ProposeCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: "proposal-stale", Command: "cutover", ExpectedStateVersion: 5, ExpectedDigests: stale, Reason: "operator reviewed", ExpiresAt: now.Add(time.Hour)}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("stale proposal digest error = %v", err) + } + proposalInput := billingmigration.ProposeCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: "proposal-valid", Command: "cutover", ExpectedStateVersion: 5, ExpectedDigests: digests, Reason: "operator reviewed", ExpiresAt: now.Add(time.Hour)} + proposal, replay, err := service.ProposeCutover(ctx, billingmigration.Actor{ID: "owner_one"}, proposalInput) + if err != nil || replay { + t.Fatalf("proposal = %#v replay=%v err=%v", proposal, replay, err) + } + replayedProposal, replay, err := service.ProposeCutover(ctx, billingmigration.Actor{ID: "owner_one"}, proposalInput) + if err != nil || !replay || replayedProposal.Reason != proposalInput.Reason || replayedProposal.ProposalID != proposal.ProposalID { + t.Fatalf("proposal replay = %#v replay=%v err=%v", replayedProposal, replay, err) + } + var auditReason string + if err := pool.QueryRow(ctx, `SELECT metadata->>'reason' FROM audit_events WHERE resource_id=$1 AND action='billing.migration.cutover.proposed'`, proposal.ProposalID).Scan(&auditReason); err != nil || auditReason != proposalInput.Reason { + t.Fatalf("proposal audit reason=%q err=%v", auditReason, err) + } + if _, _, err := service.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: proposal.ProposalID, IdempotencyKey: "approval-self", ExpectedStateVersion: 5}); !errors.Is(err, billingmigration.ErrForbidden) { + t.Fatalf("production self-approval error = %v", err) + } + approval, replay, err := service.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: proposal.ProposalID, IdempotencyKey: "approval-valid", ExpectedStateVersion: 5}) + if err != nil || replay { + t.Fatalf("approval = %#v replay=%v err=%v", approval, replay, err) + } + // A stale prepared row from an earlier run gives the legacy table the right + // cardinality but must not satisfy the latest final-delta binding. + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_scope_prepared_pointers(program_id,project_id,environment_id,application_id,platform,billing_customer_id,prepared_snapshot_id,prepared_digest,prepared_at) VALUES('program_ready','project_one','environment_one','app_two','android','customer_one','snapshot_activation',decode(repeat('79',32),'hex'),$1)`, now.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + if _, _, err := service.CreateCheckpoint(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.CreateCheckpointInput{ProjectID: "project_one", ProgramID: "program_ready", ApprovalID: approval.ApprovalID, IdempotencyKey: "checkpoint-partial", ExpectedStateVersion: 5, ExpectedDigests: digests, ApprovalDigest: approval.ApprovalDigest, CohortDigest: readiness.CohortDigest}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("partial prepared-pointer coverage error = %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_final_delta_prepared_pointers(final_delta_job_id,lease_generation,program_id,project_id,environment_id,application_id,platform,billing_customer_id,prepared_snapshot_id,prepared_digest,prepared_at) VALUES('delta_job_ready',1,'program_ready','project_one','environment_one','app_two','android','customer_one','snapshot_activation',decode(repeat('79',32),'hex'),$1)`, now); err != nil { + t.Fatalf("complete prepared-pointer coverage: %v", err) + } + if _, err := pool.Exec(ctx, `DELETE FROM billing_migration_scope_current_pointers WHERE project_id='project_one' AND environment_id='environment_one' AND application_id='app_two' AND platform='android' AND billing_customer_id='customer_one'`); err != nil { + t.Fatal(err) + } + checkpointInput := billingmigration.CreateCheckpointInput{ProjectID: "project_one", ProgramID: "program_ready", ApprovalID: approval.ApprovalID, IdempotencyKey: "checkpoint-valid", ExpectedStateVersion: 5, ExpectedDigests: digests, ApprovalDigest: approval.ApprovalDigest, CohortDigest: readiness.CohortDigest} + checkpoint, replay, err := service.CreateCheckpoint(ctx, billingmigration.Actor{ID: "owner_two"}, checkpointInput) + if err != nil || replay || checkpoint.AuthorityEpoch != 0 { + t.Fatalf("checkpoint = %#v replay=%v err=%v", checkpoint, replay, err) + } + replayedCheckpoint, replay, err := service.CreateCheckpoint(ctx, billingmigration.Actor{ID: "owner_two"}, checkpointInput) + if err != nil || !replay || replayedCheckpoint.CheckpointID != checkpoint.CheckpointID || replayedCheckpoint.StateVersion != 6 { + t.Fatalf("checkpoint replay = %#v replay=%v err=%v", replayedCheckpoint, replay, err) + } + var programState string + var programVersion int64 + if err := pool.QueryRow(ctx, `SELECT state,state_version FROM billing_migration_programs WHERE id='program_ready'`).Scan(&programState, &programVersion); err != nil || programState != billingmigration.StateCutoverPending || programVersion != 6 { + t.Fatalf("checkpoint CAS state=%q version=%d err=%v", programState, programVersion, err) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_authority_scopes SET current_authority='mosaic',current_epoch=1,authority_digest=decode(repeat('90',32),'hex'),updated_at=$1 WHERE active_program_id='program_ready'`, now); err != nil { + t.Fatalf("seed stabilizing rollback prerequisites: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_authority_transitions(id,program_id,project_id,authority_scope_id,from_authority,to_authority,from_epoch,to_epoch,transition_kind,transition_digest,transitioned_at) VALUES('transition_android','program_ready','project_one','authority_ready_android','source','mosaic',0,1,'cutover',decode(repeat('92',32),'hex'),$1),('transition_ios','program_ready','project_one','authority_ready_ios','source','mosaic',0,1,'cutover',decode(repeat('91',32),'hex'),$1)`, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_validation_attempts(id,program_id,project_id,attempt_kind,status,record_count,result_digest,attempted_at) VALUES('validation_source','program_ready','project_one','source_validation','succeeded',1,decode(repeat('93',32),'hex'),$1),('validation_provider','program_ready','project_one','provider_validation','succeeded',1,decode(repeat('94',32),'hex'),$1)`, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_programs SET state='stabilizing',state_version=7,updated_at=$1 WHERE id='program_ready'`, now); err != nil { + t.Fatal(err) + } + scopeDigest := billingmigration.FormatDigest(bytesOf(0x61)) + authorityDigest, err := billingmigration.AuthoritySetDigest("program_ready", scopeDigest, []string{billingmigration.FormatDigest(bytesOf(0x90)), billingmigration.FormatDigest(bytesOf(0x90))}) + if err != nil { + t.Fatal(err) + } + rollbackBinding := billingmigration.RollbackProposalBinding{CheckpointID: checkpoint.CheckpointID, CheckpointDigest: checkpoint.CheckpointDigest, AuthorityDigest: authorityDigest, ScopeDigest: scopeDigest, CutoverTransitionID: "transition_ios", CutoverTransitionDigest: billingmigration.FormatDigest(bytesOf(0x91)), CutoverEpoch: 1, CutoverTransitionedAt: now, RollbackDeadline: now.Add(7 * 24 * time.Hour), CredentialID: "credential_ready", CredentialStatus: "active", CapabilityAssessmentID: "capability_ready", CapabilityAssessmentDigest: billingmigration.FormatDigest(bytesOf(0x72)), CapabilityAssessedAt: now.Add(-time.Minute), SourceValidationID: "validation_source", SourceValidationDigest: billingmigration.FormatDigest(bytesOf(0x93)), SourceValidatedAt: now, ProviderValidationID: "validation_provider", ProviderValidationDigest: billingmigration.FormatDigest(bytesOf(0x94)), ProviderValidatedAt: now} + rollbackPrerequisites, err := billingmigration.RollbackPrerequisitesDigest("program_ready", 7, rollbackBinding) + if err != nil { + t.Fatal(err) + } + rollbackInput := func(key, checkpointDigest, authority, prerequisites string) billingmigration.ProposeRollbackInput { + return billingmigration.ProposeRollbackInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: key, CheckpointID: checkpoint.CheckpointID, ExpectedStateVersion: 7, ExpectedCheckpointDigest: checkpointDigest, ExpectedAuthorityDigest: authority, ExpectedRollbackPrerequisitesDigest: prerequisites, Reason: "verified rollback baseline", ExpiresAt: now.Add(time.Hour)} + } + deadlineService := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return rollbackBinding.RollbackDeadline })) + boundaryInput := rollbackInput("rollback-at-deadline", checkpoint.CheckpointDigest, authorityDigest, rollbackPrerequisites) + boundaryInput.ExpiresAt = rollbackBinding.RollbackDeadline.Add(time.Hour) + boundaryProposal, _, err := deadlineService.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, boundaryInput) + if err != nil { + t.Fatalf("rollback proposal at exact deadline: %v", err) + } + if _, _, err := deadlineService.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: boundaryProposal.ProposalID, IdempotencyKey: "rollback-approval-at-deadline", ExpectedStateVersion: 7}); err != nil { + t.Fatalf("rollback approval at exact deadline: %v", err) + } + afterDeadlineService := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return rollbackBinding.RollbackDeadline.Add(time.Nanosecond) })) + afterDeadlineInput := boundaryInput + afterDeadlineInput.IdempotencyKey = "rollback-after-deadline" + if _, _, err := afterDeadlineService.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, afterDeadlineInput); !errors.Is(err, billingmigration.ErrStaleRollbackPrerequisites) { + t.Fatalf("rollback proposal after deadline error=%v", err) + } + nearDeadlineService := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return rollbackBinding.RollbackDeadline.Add(-time.Hour) })) + lateApprovalInput := rollbackInput("rollback-late-approval", checkpoint.CheckpointDigest, authorityDigest, rollbackPrerequisites) + lateApprovalInput.ExpiresAt = rollbackBinding.RollbackDeadline.Add(time.Hour) + lateApprovalProposal, _, err := nearDeadlineService.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, lateApprovalInput) + if err != nil { + t.Fatal(err) + } + if _, _, err := afterDeadlineService.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: lateApprovalProposal.ProposalID, IdempotencyKey: "rollback-after-deadline-approval", ExpectedStateVersion: 7}); !errors.Is(err, billingmigration.ErrStaleRollbackPrerequisites) { + t.Fatalf("rollback approval after deadline error=%v", err) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_credentials SET status='revoked',revoked_at=$2 WHERE id=$1`, "credential_ready", now); err != nil { + t.Fatal(err) + } + if _, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollbackInput("rollback-revoked-before-proposal", checkpoint.CheckpointDigest, authorityDigest, rollbackPrerequisites)); !errors.Is(err, billingmigration.ErrStaleRollbackPrerequisites) { + t.Fatalf("revoked credential before proposal error=%v", err) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_credentials SET status='active',revoked_at=NULL WHERE id=$1`, "credential_ready"); err != nil { + t.Fatal(err) + } + rollbackProposal, replay, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollbackInput("rollback-proposal", checkpoint.CheckpointDigest, authorityDigest, rollbackPrerequisites)) + if err != nil || replay || rollbackProposal.RollbackBinding == nil { + t.Fatalf("rollback proposal = %#v replay=%v err=%v", rollbackProposal, replay, err) + } + rollbackApproval, replay, err := service.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: rollbackProposal.ProposalID, IdempotencyKey: "rollback-approval", ExpectedStateVersion: 7}) + if err != nil || replay || rollbackApproval.Command != "rollback" { + t.Fatalf("rollback approval = %#v replay=%v err=%v", rollbackApproval, replay, err) + } + var boundPrerequisites []byte + if err := pool.QueryRow(ctx, `SELECT rollback_prerequisites_digest FROM billing_migration_rollback_proposal_bindings WHERE proposal_id=$1`, rollbackProposal.ProposalID).Scan(&boundPrerequisites); err != nil || billingmigration.FormatDigest(boundPrerequisites) != rollbackPrerequisites { + t.Fatalf("rollback binding digest=%q err=%v", billingmigration.FormatDigest(boundPrerequisites), err) + } + staleCheckpoint := billingmigration.FormatDigest(bytesOf(0x99)) + if _, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollbackInput("rollback-stale-checkpoint", staleCheckpoint, authorityDigest, rollbackPrerequisites)); !errors.Is(err, billingmigration.ErrStaleCheckpoint) { + t.Fatalf("stale rollback checkpoint error=%v", err) + } + staleAuthority := billingmigration.FormatDigest(bytesOf(0x9a)) + if _, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollbackInput("rollback-stale-authority", checkpoint.CheckpointDigest, staleAuthority, rollbackPrerequisites)); !errors.Is(err, billingmigration.ErrStaleAuthority) { + t.Fatalf("stale rollback authority error=%v", err) + } + revokedDriftProposal, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollbackInput("rollback-revoked-drift", checkpoint.CheckpointDigest, authorityDigest, rollbackPrerequisites)) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_credentials SET status='revoked',revoked_at=$2 WHERE id=$1`, "credential_ready", now); err != nil { + t.Fatal(err) + } + if _, _, err := service.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: revokedDriftProposal.ProposalID, IdempotencyKey: "rollback-revoked-drift-approval", ExpectedStateVersion: 7}); !errors.Is(err, billingmigration.ErrStaleRollbackPrerequisites) { + t.Fatalf("credential revoked before approval error=%v", err) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_credentials SET status='active',revoked_at=NULL WHERE id=$1`, "credential_ready"); err != nil { + t.Fatal(err) + } + driftingProposal, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollbackInput("rollback-drifting", checkpoint.CheckpointDigest, authorityDigest, rollbackPrerequisites)) + if err != nil { + t.Fatalf("create drifting rollback proposal: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_validation_attempts(id,program_id,project_id,attempt_kind,status,record_count,result_digest,attempted_at) VALUES('validation_source_new','program_ready','project_one','source_validation','succeeded',1,decode(repeat('95',32),'hex'),$1)`, now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if _, _, err := service.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: driftingProposal.ProposalID, IdempotencyKey: "rollback-source-drift-approval", ExpectedStateVersion: 7}); !errors.Is(err, billingmigration.ErrStaleRollbackPrerequisites) { + t.Fatalf("advanced source validation error=%v", err) + } + rollbackBinding.SourceValidationID, rollbackBinding.SourceValidationDigest, rollbackBinding.SourceValidatedAt = "validation_source_new", billingmigration.FormatDigest(bytesOf(0x95)), now.Add(time.Minute) + rollbackPrerequisites, err = billingmigration.RollbackPrerequisitesDigest("program_ready", 7, rollbackBinding) + if err != nil { + t.Fatal(err) + } + capabilityProposal, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollbackInput("rollback-capability-drift", checkpoint.CheckpointDigest, authorityDigest, rollbackPrerequisites)) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_capability_assessments(id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) VALUES('capability_new','program_ready','project_one',7,'v2',ARRAY['read_customers'],decode(repeat('96',32),'hex'),$1)`, now.Add(2*time.Minute)); err != nil { + t.Fatal(err) + } + if _, _, err := service.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: capabilityProposal.ProposalID, IdempotencyKey: "rollback-capability-drift-approval", ExpectedStateVersion: 7}); !errors.Is(err, billingmigration.ErrStaleRollbackPrerequisites) { + t.Fatalf("advanced capability assessment error=%v", err) + } + rollbackBinding.CapabilityAssessmentID, rollbackBinding.CapabilityAssessmentDigest, rollbackBinding.CapabilityAssessedAt = "capability_new", billingmigration.FormatDigest(bytesOf(0x96)), now.Add(2*time.Minute) + rollbackPrerequisites, err = billingmigration.RollbackPrerequisitesDigest("program_ready", 7, rollbackBinding) + if err != nil { + t.Fatal(err) + } + providerProposal, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollbackInput("rollback-provider-drift", checkpoint.CheckpointDigest, authorityDigest, rollbackPrerequisites)) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_validation_attempts(id,program_id,project_id,attempt_kind,status,record_count,result_digest,attempted_at) VALUES('validation_provider_new','program_ready','project_one','provider_validation','succeeded',1,decode(repeat('97',32),'hex'),$1)`, now.Add(3*time.Minute)); err != nil { + t.Fatal(err) + } + if _, _, err := service.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: providerProposal.ProposalID, IdempotencyKey: "rollback-provider-drift-approval", ExpectedStateVersion: 7}); !errors.Is(err, billingmigration.ErrStaleRollbackPrerequisites) { + t.Fatalf("advanced provider validation error=%v", err) + } + rollbackBinding.ProviderValidationID, rollbackBinding.ProviderValidationDigest, rollbackBinding.ProviderValidatedAt = "validation_provider_new", billingmigration.FormatDigest(bytesOf(0x97)), now.Add(3*time.Minute) + rollbackPrerequisites, err = billingmigration.RollbackPrerequisitesDigest("program_ready", 7, rollbackBinding) + if err != nil { + t.Fatal(err) + } + credentialProposal, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollbackInput("rollback-credential-drift", checkpoint.CheckpointDigest, authorityDigest, rollbackPrerequisites)) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_credentials SET nonce=NULL,ciphertext=NULL,removed_at=$2,removed_by_actor_id='owner_one',removal_digest=decode(repeat('98',32),'hex') WHERE id=$1`, "credential_ready", now.Add(4*time.Minute)); err != nil { + t.Fatal(err) + } + if _, _, err := service.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: credentialProposal.ProposalID, IdempotencyKey: "rollback-credential-drift-approval", ExpectedStateVersion: 7}); !errors.Is(err, billingmigration.ErrStaleRollbackPrerequisites) { + t.Fatalf("removed migration credential error=%v", err) + } + if _, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollbackInput("rollback-removed-before-proposal", checkpoint.CheckpointDigest, authorityDigest, rollbackPrerequisites)); !errors.Is(err, billingmigration.ErrStaleRollbackPrerequisites) { + t.Fatalf("removed credential before proposal error=%v", err) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_checkpoints SET authority_epoch=1 WHERE id=$1`, checkpoint.CheckpointID); err == nil { + t.Fatal("immutable checkpoint accepted an update") + } + var rollbackBaseline, activation string + var absentBaselines int + if err := pool.QueryRow(ctx, `SELECT max(snapshot_id) FILTER(WHERE pointer_role='rollback_baseline'),max(snapshot_id) FILTER(WHERE pointer_role='prepared_activation'),count(*) FILTER(WHERE pointer_role='rollback_baseline' AND absent_current) FROM billing_migration_checkpoint_pointer_maps WHERE checkpoint_id=$1`, checkpoint.CheckpointID).Scan(&rollbackBaseline, &activation, &absentBaselines); err != nil { + t.Fatal(err) + } + if rollbackBaseline != "snapshot_baseline" || activation != "snapshot_activation" || absentBaselines != 1 { + t.Fatalf("checkpoint pointers rollback=%q activation=%q absent=%d", rollbackBaseline, activation, absentBaselines) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_programs SET state='shadowing',state_version=8 WHERE id='program_ready'`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_final_deltas(id,program_id,project_id,state_version,manifest_digest,mapping_digest,evidence_digest,final_watermark_digest,source_watermark,provider_watermark,shadow_watermark,delta_digest,completed_at) VALUES('delta_unsupported','program_ready','project_one',6,decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('67',32),'hex'),decode(repeat('96',32),'hex'),$1,$1,$1,decode(repeat('97',32),'hex'),$1)`, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_supported_app_versions(id,program_id,project_id,application_id,platform,application_version,supported,authority_aware,observation_digest,observed_at) VALUES('version_unsupported','program_ready','project_one','app_one','ios','2.10.0',false,false,decode(repeat('98',32),'hex'),$1)`, now); err != nil { + t.Fatal(err) + } + if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 6}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("unsupported in-window version bypassed by outside-window flag: %v", err) + } + if err := goose.DownContext(ctx, db, "."); err == nil || !strings.Contains(err.Error(), "immutable cohort or rollback proposal evidence exists") { + t.Fatalf("migration 54 evidence guard error=%v", err) + } + version, err := goose.GetDBVersionContext(ctx, db) + if err != nil || version != 54 { + t.Fatalf("guarded rollback version=%d err=%v", version, err) + } +} + +func seedReadyProgram(t *testing.T, ctx context.Context, db *sql.DB, now time.Time) { + t.Helper() + statement := ` + INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) VALUES('app_two','project_one','Android App','android','com.example.android',$1,$1); + INSERT INTO billing_customers(id,project_id,status,created_at,updated_at) VALUES('customer_two','project_one','active',$1,$1); + INSERT INTO products(id,project_id,key,internal_name,type,status,metadata_source,readiness_ready,created_at,updated_at) VALUES('customer_two','project_one','collision_product','Collision Product','subscription','connected','provider',true,$1,$1); + INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) + VALUES('credential_ready','project_one','revenuecat','rc','active',1,'AES-256-GCM','key',decode(repeat('01',12),'hex'),decode(repeat('02',16),'hex'),decode(repeat('03',32),'hex'),'owner_one',$1); + INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) + VALUES('program_ready','project_one','environment_one','revenuecat','v2','credential_ready','shadowing',4,0,7,7,decode(repeat('61',32),'hex'),decode(repeat('62',32),'hex'),'ready-program',decode(repeat('63',32),'hex'),'owner_one',$1,$1); + INSERT INTO billing_migration_program_scopes(program_id,project_id,environment_id,application_id,platform,created_at) VALUES('program_ready','project_one','environment_one','app_one','ios',$1),('program_ready','project_one','environment_one','app_two','android',$1); + INSERT INTO billing_migration_authority_scopes(id,project_id,environment_id,application_id,platform,current_authority,current_epoch,active_program_id,authority_digest,updated_at) VALUES('authority_ready_ios','project_one','environment_one','app_one','ios','source',0,'program_ready',decode(repeat('64',32),'hex'),$1),('authority_ready_android','project_one','environment_one','app_two','android','source',0,'program_ready',decode(repeat('64',32),'hex'),$1); + INSERT INTO billing_migration_capability_assessments(id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) VALUES('capability_ready','program_ready','project_one',4,'v2',ARRAY['read_customers','read_subscriptions'],decode(repeat('72',32),'hex'),$1-interval '1 minute'); + INSERT INTO billing_migration_source_manifests(id,program_id,project_id,state_version,adapter_version,provider_api_version,schema_version,record_count,current_access_record_count,object_key,object_checksum,object_size_bytes,object_encryption,manifest_digest,captured_at) VALUES('manifest_ready','program_ready','project_one',4,'v2','v2','v1',1,1,'private/key',decode(repeat('73',32),'hex'),1,'AES-256-GCM',decode(repeat('65',32),'hex'),$1); + INSERT INTO billing_migration_source_records(id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,record_digest,current_access,normalization_schema_version,evidence_kind,observed_at,created_at) VALUES('record_ready','program_ready','project_one','manifest_ready','customer','customer-ready','1',decode(repeat('74',32),'hex'),true,'v1','trusted_source_export',$1,$1); + INSERT INTO billing_migration_mapping_sets(id,program_id,project_id,version,status,mapping_digest,expected_program_state_version,created_by_actor_id,created_at,frozen_at) VALUES('mapping_ready','program_ready','project_one',1,'frozen',decode(repeat('66',32),'hex'),4,'owner_one',$1,$1); + INSERT INTO billing_migration_mapping_entries(id,mapping_set_id,program_id,project_id,source_kind,source_identifier,target_id,match_kind,application_id,platform,created_at) VALUES('mapping_entry_ready','mapping_ready','program_ready','project_one','customer_id','customer-ready','customer_one','exact','app_one','ios',$1),('mapping_entry_collision','mapping_ready','program_ready','project_one','product','customer-ready','customer_two','exact','app_one','ios',$1); + INSERT INTO billing_migration_readiness_policies(id,program_id,project_id,state_version,warning_threshold,watermark_max_age_seconds,supported_version_window_start,application_version_digest,policy_digest,frozen_at) VALUES('policy_ready','program_ready','project_one',4,0,3600,$1-interval '1 hour',decode(repeat('70',32),'hex'),decode(repeat('71',32),'hex'),$1-interval '10 minutes'); + INSERT INTO billing_migration_readiness_policy_scopes(id,policy_id,program_id,project_id,application_id,platform,minimum_app_version,maximum_app_version,traffic_window_started_at,traffic_window_ended_at,outside_window_accepted,outside_window_reason,minimum_sdk_version,required_capabilities,serving_requirements_digest) VALUES('policy_scope_ready_ios','policy_ready','program_ready','project_one','app_one','ios','2.9.9','2.10.0',$1-interval '1 hour',$1+interval '1 hour',true,'reviewed outside-range traffic','2.1.0-rc.1',ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],sha256(convert_to(concat_ws(chr(31),'program_ready','app_one','ios','2.1.0-rc.1','authority_epoch'||chr(30)||'authority_scope'||chr(30)||'mosaic_authoritative_targeting'||chr(30)||'urgent_authority_sync'),'UTF8'))),('policy_scope_ready_android','policy_ready','program_ready','project_one','app_two','android','2.9.9','2.10.0',$1-interval '1 hour',$1+interval '1 hour',true,'reviewed outside-range traffic','2.1.0-rc.1',ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],sha256(convert_to(concat_ws(chr(31),'program_ready','app_two','android','2.1.0-rc.1','authority_epoch'||chr(30)||'authority_scope'||chr(30)||'mosaic_authoritative_targeting'||chr(30)||'urgent_authority_sync'),'UTF8'))); + INSERT INTO billing_migration_supported_app_versions(id,program_id,project_id,application_id,platform,application_version,supported,authority_aware,observation_digest,observed_at) VALUES('version_ready_ios','program_ready','project_one','app_one','ios','2.10.0+ios.1',true,true,decode(repeat('75',32),'hex'),$1),('version_ready_android','program_ready','project_one','app_two','android','2.10.0+android.1',true,true,decode(repeat('75',32),'hex'),$1); + INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('sync_ready_ios','program_ready','project_one','app_one','ios','2.10.0+ios.1','2.1.0+build.7',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],10,0,'accepted',decode(repeat('76',32),'hex'),$1); + INSERT INTO customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id,snapshot_version,rule_version,computed_at,as_of,checksum,change_reason,created_at) VALUES('snapshot_baseline','project_one','environment_one','customer_one',1,1,$1,$1,decode(repeat('78',32),'hex'),'migration_baseline',$1),('snapshot_activation','project_one','environment_one','customer_one',2,1,$1,$1,decode(repeat('79',32),'hex'),'migration_prepared',$1); + INSERT INTO billing_migration_scope_current_pointers(project_id,environment_id,application_id,platform,billing_customer_id,current_snapshot_id,authority_epoch,updated_at) VALUES('project_one','environment_one','app_one','ios','customer_one','snapshot_baseline',0,$1),('project_one','environment_one','app_two','android','customer_one','snapshot_baseline',0,$1); + INSERT INTO billing_migration_scope_prepared_pointers(program_id,project_id,environment_id,application_id,platform,billing_customer_id,prepared_snapshot_id,prepared_digest,prepared_at) VALUES('program_ready','project_one','environment_one','app_one','ios','customer_one','snapshot_activation',decode(repeat('79',32),'hex'),$1); + INSERT INTO billing_migration_final_deltas(id,program_id,project_id,state_version,manifest_digest,mapping_digest,evidence_digest,final_watermark_digest,source_watermark,provider_watermark,shadow_watermark,delta_digest,completed_at) VALUES('delta_ready','program_ready','project_one',4,decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('67',32),'hex'),decode(repeat('68',32),'hex'),$1-interval '1 minute',$1-interval '1 minute',$1-interval '1 minute',decode(repeat('69',32),'hex'),$1-interval '30 seconds'); + INSERT INTO billing_migration_final_delta_jobs(id,program_id,project_id,idempotency_key,request_digest,expected_program_state_version,manifest_digest,mapping_digest,evidence_digest,status,result_final_delta_id,due_at,lease_generation,attempt_count,max_attempts,created_at,updated_at) VALUES('delta_job_ready','program_ready','project_one','delta-ready',decode(repeat('80',32),'hex'),4,decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('67',32),'hex'),'completed','delta_ready',$1,1,1,3,$1,$1); + INSERT INTO billing_migration_final_delta_prepared_pointers(final_delta_job_id,lease_generation,program_id,project_id,environment_id,application_id,platform,billing_customer_id,prepared_snapshot_id,prepared_digest,prepared_at) VALUES('delta_job_ready',1,'program_ready','project_one','environment_one','app_one','ios','customer_one','snapshot_activation',decode(repeat('79',32),'hex'),$1);` + statement = strings.ReplaceAll(statement, "$1", "TIMESTAMPTZ '"+now.Format(time.RFC3339)+"'") + _, err := db.ExecContext(ctx, statement) + if err != nil { + t.Fatal(err) + } +} + +func insertExceptionFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix, applicationID, platform string, affected int, approvedAt, expiresAt time.Time) { + t.Helper() + caseID, exceptionID := "case_"+suffix, "exception_"+suffix + _, err := pool.Exec(ctx, `INSERT INTO billing_migration_cases(id,program_id,project_id,state_version,classification,status,reason,case_digest,opened_at) VALUES($1,'program_ready','project_one',4,'blocking','in_progress','reviewed source evidence',decode(repeat('80',32),'hex'),$2)`, caseID, approvedAt) + if err == nil { + _, err = pool.Exec(ctx, `INSERT INTO billing_migration_source_access_exceptions(id,case_id,program_id,project_id,application_id,platform,reason,affected_customer_count,rollback_treatment,identity_ambiguity_count,proposer_actor_id,approver_actor_id,approved_at,expires_at,exception_digest) VALUES($1,$2,'program_ready','project_one',$3,$4,'reviewed exact evidence',$5,'restore source authority',0,'owner_one','owner_two',$6,$7,decode(repeat('81',32),'hex'))`, exceptionID, caseID, applicationID, platform, affected, approvedAt, expiresAt) + } + if err == nil { + _, err = pool.Exec(ctx, `INSERT INTO billing_migration_source_access_exception_subjects(id,exception_id,program_id,project_id,source_record_id,billing_customer_id,subject_digest,created_at) VALUES($1,$2,'program_ready','project_one','record_ready','customer_one',decode(repeat('82',32),'hex'),$3)`, "subject_"+suffix, exceptionID, approvedAt) + } + if err != nil { + t.Fatal(err) + } +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/evidence.go b/apps/api/internal/platform/billingmigrationpostgres/evidence.go new file mode 100644 index 00000000..da191874 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/evidence.go @@ -0,0 +1,524 @@ +package billingmigrationpostgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +var _ billingmigration.EvidenceRepository = (*Repository)(nil) + +func (r *Repository) AppendManifest(ctx context.Context, expectedStateVersion int64, write billingmigration.ManifestWrite) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin migration manifest: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := requireProgramVersionStateTx(ctx, tx, write.ProjectID, write.Manifest.ProgramID, expectedStateVersion, + billingmigration.StateMapping, billingmigration.StateImporting, billingmigration.StateDryRun, billingmigration.StateShadowing); err != nil { + return err + } + manifest := write.Manifest + command, err := tx.Exec(ctx, `INSERT INTO billing_migration_source_manifests( + id,program_id,project_id,state_version,adapter_version,provider_api_version,schema_version, + record_count,current_access_record_count,object_key,object_checksum,object_size_bytes, + object_encryption,manifest_digest,source_watermark,captured_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'AES-256-GCM',$13,$14,$15)`, + manifest.ManifestID, manifest.ProgramID, write.ProjectID, manifest.StateVersion, + manifest.AdapterVersion, manifest.ProviderAPIVersion, manifest.SchemaVersion, + manifest.RecordCount, manifest.CurrentAccessRecordCount, write.ObjectKey, write.ObjectChecksum, + write.ObjectSizeBytes, write.ManifestDigest, write.SourceWatermark, manifest.CapturedAt) + if err != nil { + return translate(err, "insert migration manifest") + } + if command.RowsAffected() != 1 { + return billingmigration.ErrConflict + } + return tx.Commit(ctx) +} + +func (r *Repository) ListManifests(ctx context.Context, projectID, programID string, limit int) ([]billingmigration.SourceManifest, error) { + rows, err := r.pool.Query(ctx, `SELECT program_id,state_version,id,adapter_version,provider_api_version, + schema_version,record_count,current_access_record_count,object_checksum,manifest_digest,captured_at + FROM billing_migration_source_manifests WHERE project_id=$1 AND program_id=$2 + ORDER BY captured_at DESC,id LIMIT $3`, projectID, programID, limit) + if err != nil { + return nil, fmt.Errorf("list migration manifests: %w", err) + } + defer rows.Close() + items := make([]billingmigration.SourceManifest, 0) + for rows.Next() { + var item billingmigration.SourceManifest + var checksum, manifestDigest []byte + if err := rows.Scan(&item.ProgramID, &item.StateVersion, &item.ManifestID, &item.AdapterVersion, + &item.ProviderAPIVersion, &item.SchemaVersion, &item.RecordCount, &item.CurrentAccessRecordCount, + &checksum, &manifestDigest, &item.CapturedAt); err != nil { + return nil, err + } + item.ObjectChecksum, item.ManifestDigest = billingmigration.FormatDigest(checksum), billingmigration.FormatDigest(manifestDigest) + items = append(items, item) + } + return items, rows.Err() +} + +func (r *Repository) CreateMappingSet(ctx context.Context, expectedStateVersion int64, write billingmigration.MappingSetWrite) error { + if len(write.MappingSet.Entries) > 10000 { + return billingmigration.ErrInvalid + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin mapping set: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := requireProgramVersionStateTx(ctx, tx, write.ProjectID, write.MappingSet.ProgramID, expectedStateVersion, billingmigration.StateMapping); err != nil { + return err + } + mapping := write.MappingSet + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_mapping_sets( + id,program_id,project_id,version,status,mapping_digest,expected_program_state_version, + created_by_actor_id,created_at,frozen_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,NULL)`, + mapping.MappingSetID, mapping.ProgramID, write.ProjectID, mapping.Version, "draft", + write.MappingDigest, expectedStateVersion, write.ActorID, write.CreatedAt) + if err != nil { + return translate(err, "insert migration mapping set") + } + for index, entry := range mapping.Entries { + _, err := tx.Exec(ctx, `INSERT INTO billing_migration_mapping_entries( + id,mapping_set_id,program_id,project_id,source_kind,source_identifier,target_id, + match_kind,application_id,platform,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9)`, + fmt.Sprintf("mme_%s_%d", mapping.MappingSetID, index), mapping.MappingSetID, mapping.ProgramID, + write.ProjectID, entry.SourceKind, entry.SourceIdentifier, entry.TargetID, entry.MatchKind, write.CreatedAt) + if err != nil { + return translate(err, "insert migration mapping entry") + } + } + return tx.Commit(ctx) +} + +func (r *Repository) FreezeMappingSet(ctx context.Context, projectID, programID, mappingSetID string, expectedStateVersion int64, at time.Time) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + if err := requireProgramVersionStateTx(ctx, tx, projectID, programID, expectedStateVersion, billingmigration.StateMapping); err != nil { + return err + } + command, err := tx.Exec(ctx, `UPDATE billing_migration_mapping_sets SET status='frozen',frozen_at=$4 + WHERE id=$1 AND program_id=$2 AND project_id=$3 AND status='draft'`, mappingSetID, programID, projectID, at) + if err != nil { + return translate(err, "freeze migration mapping set") + } + if command.RowsAffected() != 1 { + return billingmigration.ErrConflict + } + command, err = tx.Exec(ctx, `UPDATE billing_migration_programs SET state=$4,state_version=state_version+1,updated_at=$5 + WHERE id=$1 AND project_id=$2 AND state_version=$3 AND state='mapping'`, programID, projectID, expectedStateVersion, billingmigration.StateImporting, at) + if err != nil { + return translate(err, "advance migration to importing") + } + if command.RowsAffected() != 1 { + return billingmigration.ErrConflict + } + return tx.Commit(ctx) +} + +func (r *Repository) ListMappingSets(ctx context.Context, projectID, programID string, limit int) ([]billingmigration.MappingSet, error) { + rows, err := r.pool.Query(ctx, `SELECT id,version,status,mapping_digest,expected_program_state_version + FROM billing_migration_mapping_sets WHERE project_id=$1 AND program_id=$2 ORDER BY version DESC LIMIT $3`, projectID, programID, limit) + if err != nil { + return nil, fmt.Errorf("list migration mapping sets: %w", err) + } + items := make([]billingmigration.MappingSet, 0) + for rows.Next() { + var item billingmigration.MappingSet + var mappingDigest []byte + item.ProgramID = programID + if err := rows.Scan(&item.MappingSetID, &item.Version, &item.Status, &mappingDigest, &item.StateVersion); err != nil { + return nil, err + } + item.MappingDigest = billingmigration.FormatDigest(mappingDigest) + items = append(items, item) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + for index := range items { + entryRows, err := r.pool.Query(ctx, `SELECT source_kind,source_identifier,target_id,match_kind + FROM billing_migration_mapping_entries WHERE mapping_set_id=$1 ORDER BY source_kind,source_identifier,id`, items[index].MappingSetID) + if err != nil { + return nil, err + } + for entryRows.Next() { + var entry billingmigration.MappingEntry + if err := entryRows.Scan(&entry.SourceKind, &entry.SourceIdentifier, &entry.TargetID, &entry.MatchKind); err != nil { + entryRows.Close() + return nil, err + } + items[index].Entries = append(items[index].Entries, entry) + } + if err := entryRows.Err(); err != nil { + entryRows.Close() + return nil, err + } + entryRows.Close() + } + return items, nil +} + +func (r *Repository) CreateImportBatch(ctx context.Context, expectedStateVersion int64, write billingmigration.ImportBatchWrite) (bool, error) { + if write.Batch.RecordCount < 0 || write.Batch.RecordCount > 1000 { + return false, billingmigration.ErrInvalid + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("begin migration import batch: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := requireProgramVersionStateTx(ctx, tx, write.ProjectID, write.Batch.ProgramID, expectedStateVersion, billingmigration.StateImporting); err != nil { + return false, err + } + batch := write.Batch + command, err := tx.Exec(ctx, `INSERT INTO billing_migration_import_batches( + id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest, + expected_program_state_version,status,record_count,validated_count,quarantined_count, + cursor_before,cursor_after,attempt_count,created_at,updated_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9,0,0,$10,'',0,$11,$11) + ON CONFLICT (program_id,idempotency_key) DO NOTHING`, + batch.BatchID, batch.ProgramID, write.ProjectID, write.ManifestID, write.MappingSetID, + batch.IdempotencyKey, write.RequestDigest, expectedStateVersion, batch.RecordCount, + write.CursorBefore, write.CreatedAt) + if err != nil { + return false, translate(err, "insert migration import batch") + } + if command.RowsAffected() == 1 { + return false, tx.Commit(ctx) + } + var existing []byte + readErr := tx.QueryRow(ctx, `SELECT request_digest FROM billing_migration_import_batches + WHERE program_id=$1 AND idempotency_key=$2`, batch.ProgramID, batch.IdempotencyKey).Scan(&existing) + if readErr == nil && string(existing) == string(write.RequestDigest) { + return true, tx.Commit(ctx) + } + return false, billingmigration.ErrConflict +} + +func (r *Repository) ListImportBatches(ctx context.Context, projectID, programID string, limit int) ([]billingmigration.ImportBatch, error) { + rows, err := r.pool.Query(ctx, `SELECT program_id,expected_program_state_version,id,idempotency_key,status, + record_count,validated_count,quarantined_count FROM billing_migration_import_batches + WHERE project_id=$1 AND program_id=$2 ORDER BY created_at DESC,id LIMIT $3`, projectID, programID, limit) + if err != nil { + return nil, fmt.Errorf("list migration import batches: %w", err) + } + defer rows.Close() + items := make([]billingmigration.ImportBatch, 0) + for rows.Next() { + var item billingmigration.ImportBatch + if err := rows.Scan(&item.ProgramID, &item.StateVersion, &item.BatchID, &item.IdempotencyKey, &item.Status, &item.RecordCount, &item.ValidatedCount, &item.QuarantinedCount); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (r *Repository) ImportBatch(ctx context.Context, projectID, programID, batchID string) (billingmigration.ImportBatch, error) { + return r.readImportBatch(ctx, projectID, programID, "id", batchID) +} + +func (r *Repository) ImportBatchByIdempotency(ctx context.Context, projectID, programID, key string) (billingmigration.ImportBatch, error) { + return r.readImportBatch(ctx, projectID, programID, "idempotency_key", key) +} + +func (r *Repository) readImportBatch(ctx context.Context, projectID, programID, column, value string) (billingmigration.ImportBatch, error) { + var item billingmigration.ImportBatch + query := `SELECT program_id,expected_program_state_version,id,idempotency_key,status, + record_count,validated_count,quarantined_count FROM billing_migration_import_batches + WHERE project_id=$1 AND program_id=$2 AND ` + column + `=$3` + err := r.pool.QueryRow(ctx, query, projectID, programID, value). + Scan(&item.ProgramID, &item.StateVersion, &item.BatchID, &item.IdempotencyKey, &item.Status, &item.RecordCount, &item.ValidatedCount, &item.QuarantinedCount) + if errors.Is(err, pgx.ErrNoRows) { + return item, billingmigration.ErrNotFound + } + if err != nil { + return item, fmt.Errorf("read migration import batch: %w", err) + } + return item, nil +} + +func (r *Repository) LeaseImportBatch(ctx context.Context, workerID string, now, leaseUntil time.Time) (billingmigration.ImportBatch, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingmigration.ImportBatch{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + var batch billingmigration.ImportBatch + err = tx.QueryRow(ctx, `SELECT id,program_id,expected_program_state_version,idempotency_key, + status,record_count,validated_count,quarantined_count,lease_generation FROM billing_migration_import_batches + WHERE status='pending' OR (status='running' AND lease_expires_at <= $1) + ORDER BY updated_at,id FOR UPDATE SKIP LOCKED LIMIT 1`, now). + Scan(&batch.BatchID, &batch.ProgramID, &batch.StateVersion, &batch.IdempotencyKey, + &batch.Status, &batch.RecordCount, &batch.ValidatedCount, &batch.QuarantinedCount, &batch.LeaseGeneration) + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.ImportBatch{}, false, nil + } + if err != nil { + return billingmigration.ImportBatch{}, false, err + } + _, err = tx.Exec(ctx, `UPDATE billing_migration_import_batches SET status='running',lease_owner=$2, + lease_expires_at=$3,attempt_count=attempt_count+1,lease_generation=lease_generation+1,updated_at=$1 WHERE id=$4`, now, workerID, leaseUntil, batch.BatchID) + if err != nil { + return billingmigration.ImportBatch{}, false, err + } + if err := tx.Commit(ctx); err != nil { + return billingmigration.ImportBatch{}, false, err + } + batch.Status = "running" + batch.LeaseGeneration++ + return batch, true, nil +} + +func (r *Repository) CompleteImportBatch(ctx context.Context, projectID, programID, batchID, workerID string, leaseGeneration int64, cursorAfter string, validated, quarantined int, now time.Time) error { + command, err := r.pool.Exec(ctx, `UPDATE billing_migration_import_batches SET status='completed', + validated_count=$6,quarantined_count=$7,cursor_after=$8,lease_owner=NULL,lease_expires_at=NULL,updated_at=$9 + WHERE id=$1 AND program_id=$2 AND project_id=$3 AND status='running' AND lease_owner=$4 + AND lease_generation=$5 AND lease_expires_at > $9 AND $6::integer+$7::integer <= record_count`, batchID, programID, projectID, + workerID, leaseGeneration, validated, quarantined, cursorAfter, now) + if err != nil { + return translate(err, "complete migration import batch") + } + if command.RowsAffected() != 1 { + return billingmigration.ErrConflict + } + return nil +} + +func (r *Repository) QueueRun(ctx context.Context, expectedStateVersion int64, write billingmigration.RunJobWrite) (bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + var existingDigest []byte + replayErr := tx.QueryRow(ctx, `SELECT request_digest FROM billing_migration_run_jobs + WHERE program_id=$1 AND project_id=$2 AND idempotency_key=$3`, write.Job.ProgramID, write.ProjectID, write.IdempotencyKey).Scan(&existingDigest) + if replayErr == nil { + if string(existingDigest) != string(write.RequestDigest) { + return false, billingmigration.ErrConflict + } + return true, tx.Commit(ctx) + } + if !errors.Is(replayErr, pgx.ErrNoRows) { + return false, replayErr + } + allowedState, nextState := billingmigration.StateImporting, billingmigration.StateDryRun + allowedStates := []string{billingmigration.StateImporting, billingmigration.StateDryRun} + if write.Job.RunKind == "shadow" { + allowedState, nextState = billingmigration.StateDryRun, billingmigration.StateShadowing + allowedStates = []string{billingmigration.StateDryRun, billingmigration.StateShadowing} + } + if err := requireProgramVersionStateTx(ctx, tx, write.ProjectID, write.Job.ProgramID, expectedStateVersion, allowedStates...); err != nil { + return false, err + } + command, err := tx.Exec(ctx, `INSERT INTO billing_migration_run_jobs(id,program_id,project_id,run_kind,idempotency_key, + request_digest,expected_program_state_version,manifest_digest,mapping_digest,policy_digest,status,created_at,updated_at) + SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending',$11,$11 + WHERE EXISTS(SELECT 1 FROM billing_migration_source_manifests WHERE program_id=$2 AND project_id=$3 AND manifest_digest=$8) + AND EXISTS(SELECT 1 FROM billing_migration_mapping_sets WHERE program_id=$2 AND project_id=$3 AND mapping_digest=$9 AND status='frozen') + AND EXISTS(SELECT 1 FROM billing_migration_programs WHERE id=$2 AND project_id=$3 AND policy_digest=$10) + ON CONFLICT(program_id,idempotency_key) DO NOTHING`, + write.Job.RunJobID, write.Job.ProgramID, write.ProjectID, write.Job.RunKind, write.IdempotencyKey, write.RequestDigest, + expectedStateVersion, write.ManifestDigest, write.MappingDigest, write.PolicyDigest, write.CreatedAt) + if err != nil { + return false, translate(err, "queue migration run") + } + if command.RowsAffected() == 1 { + transition, transitionErr := tx.Exec(ctx, `UPDATE billing_migration_programs SET state=$4,state_version=state_version+1,updated_at=$5 + WHERE id=$1 AND project_id=$2 AND state_version=$3 AND state=$6`, write.Job.ProgramID, write.ProjectID, + expectedStateVersion, nextState, write.CreatedAt, allowedState) + if transitionErr != nil { + return false, translate(transitionErr, "advance migration run state") + } + if transition.RowsAffected() == 0 { + if err := requireProgramVersionStateTx(ctx, tx, write.ProjectID, write.Job.ProgramID, expectedStateVersion, nextState); err != nil { + return false, err + } + } + return false, tx.Commit(ctx) + } + return false, billingmigration.ErrInvalid +} + +func (r *Repository) RunJob(ctx context.Context, projectID, programID, jobID string) (billingmigration.RunJob, error) { + return r.readRunJob(ctx, projectID, programID, "id", jobID) +} + +func (r *Repository) RunJobByIdempotency(ctx context.Context, projectID, programID, key string) (billingmigration.RunJob, error) { + return r.readRunJob(ctx, projectID, programID, "idempotency_key", key) +} + +func (r *Repository) readRunJob(ctx context.Context, projectID, programID, column, value string) (billingmigration.RunJob, error) { + var item billingmigration.RunJob + var result *string + query := `SELECT program_id,expected_program_state_version,id,run_kind,status,result_run_id + FROM billing_migration_run_jobs WHERE project_id=$1 AND program_id=$2 AND ` + column + `=$3` + err := r.pool.QueryRow(ctx, query, projectID, programID, value). + Scan(&item.ProgramID, &item.StateVersion, &item.RunJobID, &item.RunKind, &item.Status, &result) + if errors.Is(err, pgx.ErrNoRows) { + return item, billingmigration.ErrNotFound + } + if err != nil { + return item, err + } + if result != nil { + item.ResultRunID = *result + } + return item, nil +} + +func (r *Repository) RecordRun(ctx context.Context, expectedStateVersion int64, write billingmigration.RunWrite) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + if err := requireProgramVersionStateTx(ctx, tx, write.ProjectID, write.Run.ProgramID, expectedStateVersion, + billingmigration.StateDryRun, billingmigration.StateShadowing); err != nil { + return err + } + run := write.Run + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_runs(id,program_id,project_id,run_kind,state_version, + manifest_digest,mapping_digest,policy_digest,source_watermark,provider_watermark,shadow_watermark, + critical_count,blocking_count,warning_count,informational_count,run_digest,completed_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, + run.RunID, run.ProgramID, write.ProjectID, run.RunKind, run.StateVersion, + write.ManifestDigest, write.MappingDigest, write.PolicyDigest, write.SourceWatermark, + write.ProviderWatermark, write.ShadowWatermark, run.Divergences.Critical, + run.Divergences.Blocking, run.Divergences.Warning, run.Divergences.Informational, + write.RunDigest, run.CompletedAt) + if err != nil { + return translate(err, "insert migration run") + } + for _, item := range write.Divergences { + divergence := item.Divergence + _, err := tx.Exec(ctx, `INSERT INTO billing_migration_divergences(id,program_id,project_id,run_id, + state_version,classification,reason,evidence_digest,classification_rule_version,observed_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, divergence.DivergenceID, divergence.ProgramID, + write.ProjectID, run.RunID, divergence.StateVersion, divergence.Classification, + divergence.Reason, item.EvidenceDigest, divergence.ClassificationRuleVersion, divergence.ObservedAt) + if err != nil { + return translate(err, "insert migration divergence") + } + } + return tx.Commit(ctx) +} + +func (r *Repository) ListDivergences(ctx context.Context, projectID, programID string, limit int) ([]billingmigration.Divergence, error) { + rows, err := r.pool.Query(ctx, `SELECT program_id,state_version,id,classification,reason,observed_at,classification_rule_version + FROM billing_migration_divergences WHERE project_id=$1 AND program_id=$2 ORDER BY observed_at DESC,id LIMIT $3`, projectID, programID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]billingmigration.Divergence, 0) + for rows.Next() { + var item billingmigration.Divergence + if err := rows.Scan(&item.ProgramID, &item.StateVersion, &item.DivergenceID, &item.Classification, &item.Reason, &item.ObservedAt, &item.ClassificationRuleVersion); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (r *Repository) ReadinessInput(ctx context.Context, projectID, programID string) (billingmigration.ReadinessInput, error) { + var input billingmigration.ReadinessInput + err := r.pool.QueryRow(ctx, `WITH current_records AS ( + SELECT source_kind,source_identifier,evidence_kind FROM billing_migration_source_records WHERE project_id=$1 AND program_id=$2 AND current_access + ), frozen_mapping AS (SELECT id FROM billing_migration_mapping_sets WHERE project_id=$1 AND program_id=$2 AND status='frozen' ORDER BY version DESC LIMIT 1), + latest_run AS (SELECT id FROM billing_migration_runs WHERE project_id=$1 AND program_id=$2 ORDER BY completed_at DESC,id LIMIT 1), + counts AS (SELECT classification,count(*) count FROM billing_migration_divergences WHERE run_id=(SELECT id FROM latest_run) GROUP BY classification) + SELECT CASE WHEN count(*)=0 THEN 0 ELSE 100.0*count(*) FILTER(WHERE EXISTS( + SELECT 1 FROM billing_migration_mapping_entries e + WHERE e.mapping_set_id=(SELECT id FROM frozen_mapping) + AND e.source_identifier=current_records.source_identifier + AND ((current_records.source_kind='customer' AND e.source_kind IN ('customer_id','original_customer_id')) + OR (current_records.source_kind='alias' AND e.source_kind='audited_alias') + OR (current_records.source_kind='subscription' AND e.source_kind IN ('product','entitlement')) + OR (current_records.source_kind='transaction' AND e.source_kind='product') + OR (current_records.source_kind='transfer' AND e.source_kind='audited_alias')) + ))/count(*) END, + CASE WHEN count(*)=0 THEN 0 ELSE 100.0*count(*) FILTER(WHERE evidence_kind IN ('provider_signed','provider_validated'))/count(*) END, + COALESCE((SELECT count FROM counts WHERE classification='critical'),0),COALESCE((SELECT count FROM counts WHERE classification='blocking'),0),COALESCE((SELECT count FROM counts WHERE classification='warning'),0),COALESCE((SELECT count FROM counts WHERE classification='informational'),0) + FROM current_records`, projectID, programID).Scan(&input.CurrentAccessMappingPercent, &input.CurrentAccessEvidencePercent, &input.Unresolved.Critical, &input.Unresolved.Blocking, &input.Unresolved.Warning, &input.Unresolved.Informational) + return input, err +} + +func (r *Repository) RecordReadiness(ctx context.Context, expectedStateVersion int64, write billingmigration.ReadinessWrite) error { + if len(write.Assessment.ReadinessDigest) < 16 { + return billingmigration.ErrInvalid + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin migration readiness: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := requireProgramVersionStateTx(ctx, tx, write.ProjectID, write.Assessment.ProgramID, expectedStateVersion, billingmigration.StateShadowing); err != nil { + return err + } + a := write.Assessment + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_readiness_assessments(id,program_id,project_id, + state_version,ready,current_access_mapping_percent,current_access_evidence_percent,critical_count, + blocking_count,warning_count,informational_count,final_delta_completed,watermarks_fresh, + supported_versions_authority_aware,readiness_digest,assessed_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, + "mra_"+a.ReadinessDigest[:16], a.ProgramID, write.ProjectID, a.StateVersion, a.Ready, + a.CurrentAccessMappingPercent, a.CurrentAccessEvidencePercent, a.Unresolved.Critical, + a.Unresolved.Blocking, a.Unresolved.Warning, a.Unresolved.Informational, a.FinalDeltaCompleted, + a.WatermarksFresh, a.SupportedVersionsAuthorityAware, write.ReadinessDigest, write.AssessedAt) + if err != nil { + return translate(err, "insert migration readiness") + } + return tx.Commit(ctx) +} + +func (r *Repository) LatestReadiness(ctx context.Context, projectID, programID string) (billingmigration.ReadinessAssessment, error) { + var a billingmigration.ReadinessAssessment + var digest []byte + err := r.pool.QueryRow(ctx, `SELECT program_id,state_version,ready,current_access_mapping_percent,current_access_evidence_percent, + critical_count,blocking_count,warning_count,informational_count,final_delta_completed,watermarks_fresh,supported_versions_authority_aware,readiness_digest + FROM billing_migration_readiness_assessments WHERE project_id=$1 AND program_id=$2 ORDER BY assessed_at DESC,id LIMIT 1`, projectID, programID). + Scan(&a.ProgramID, &a.StateVersion, &a.Ready, &a.CurrentAccessMappingPercent, &a.CurrentAccessEvidencePercent, &a.Unresolved.Critical, &a.Unresolved.Blocking, &a.Unresolved.Warning, &a.Unresolved.Informational, &a.FinalDeltaCompleted, &a.WatermarksFresh, &a.SupportedVersionsAuthorityAware, &digest) + if errors.Is(err, pgx.ErrNoRows) { + return a, billingmigration.ErrNotFound + } + if err != nil { + return a, err + } + a.ReadinessDigest = billingmigration.FormatDigest(digest) + return a, nil +} + +func requireProgramVersionStateTx(ctx context.Context, tx pgx.Tx, projectID, programID string, expected int64, allowedStates ...string) error { + var state string + err := tx.QueryRow(ctx, `SELECT state FROM billing_migration_programs WHERE id=$1 AND project_id=$2 AND state_version=$3 FOR UPDATE`, programID, projectID, expected).Scan(&state) + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.ErrConflict + } + if err != nil { + return fmt.Errorf("read migration program version: %w", err) + } + for _, allowed := range allowedStates { + if state == allowed { + return nil + } + } + return billingmigration.ErrConflict +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/execution.go b/apps/api/internal/platform/billingmigrationpostgres/execution.go new file mode 100644 index 00000000..62f728a2 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/execution.go @@ -0,0 +1,502 @@ +package billingmigrationpostgres + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +type lockedScope struct { + id, applicationID, platform, authority string + epoch int64 + authorityDigest []byte +} + +func (r *Repository) ExecuteCutover(ctx context.Context, write billingmigration.ExecuteCutoverWrite) (billingmigration.AuthorityExecution, bool, error) { + return retryExecution(ctx, func() (billingmigration.AuthorityExecution, bool, error) { return r.executeCutoverOnce(ctx, write) }) +} + +func (r *Repository) executeCutoverOnce(ctx context.Context, write billingmigration.ExecuteCutoverWrite) (billingmigration.AuthorityExecution, bool, error) { + input := write.Input + result := executionResult(input.ProgramID, write.ExecutionID, "cutover", billingmigration.StateStabilizing, input.ExpectedStateVersion+1, input.ExpectedAuthorityEpoch+1, write.ExecutedAt) + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return result, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + if _, replay, replayErr := commandReplay(ctx, tx, input.ProgramID, input.ProjectID, "execute_cutover", input.IdempotencyKey, write.RequestDigest); replayErr != nil { + if errors.Is(replayErr, billingmigration.ErrConflict) { + return result, false, billingmigration.ErrIdempotencyConflict + } + return result, false, replayErr + } else if replay { + if err = loadExecutionReplay(ctx, tx, input.ProgramID, input.ProjectID, "execute_cutover", input.IdempotencyKey, "cutover", &result); err != nil { + return result, true, err + } + return result, true, tx.Commit(ctx) + } + + var state, environmentID string + var stateVersion int64 + var scopeDigest []byte + if err = tx.QueryRow(ctx, `SELECT state,state_version,environment_id,scope_digest FROM billing_migration_programs WHERE id=$1 AND project_id=$2 FOR UPDATE`, input.ProgramID, input.ProjectID).Scan(&state, &stateVersion, &environmentID, &scopeDigest); errors.Is(err, pgx.ErrNoRows) { + return result, false, billingmigration.ErrNotFound + } else if err != nil { + return result, false, err + } + if state != billingmigration.StateCutoverPending || stateVersion != input.ExpectedStateVersion { + return result, false, billingmigration.ErrStaleState + } + if environmentID != input.Scope.EnvironmentID || !bytes.Equal(scopeDigest, write.Digests.Scope) { + return result, false, billingmigration.ErrStaleDigest + } + + var command, status string + var expiresAt time.Time + var approvalDigest, checkpointDigest, manifest, mapping, policy, evidence, readiness, watermark, appVersion []byte + err = tx.QueryRow(ctx, `SELECT proposal.command,proposal.status,approval.expires_at,approval.approval_digest, + checkpoint.checkpoint_digest,checkpoint.manifest_digest,checkpoint.mapping_digest,checkpoint.policy_digest, + checkpoint.evidence_digest,checkpoint.readiness_digest,checkpoint.final_watermark_digest,checkpoint.application_version_digest + FROM billing_migration_approvals approval + JOIN billing_migration_cutover_proposals proposal ON proposal.id=approval.proposal_id AND proposal.program_id=approval.program_id AND proposal.project_id=approval.project_id + JOIN billing_migration_checkpoints checkpoint ON checkpoint.program_id=approval.program_id AND checkpoint.project_id=approval.project_id AND checkpoint.approval_digest=approval.approval_digest + WHERE approval.id=$1 AND approval.program_id=$2 AND approval.project_id=$3 AND checkpoint.id=$4 + FOR UPDATE OF proposal,approval,checkpoint`, input.ApprovalID, input.ProgramID, input.ProjectID, input.CheckpointID).Scan(&command, &status, &expiresAt, &approvalDigest, &checkpointDigest, &manifest, &mapping, &policy, &evidence, &readiness, &watermark, &appVersion) + if errors.Is(err, pgx.ErrNoRows) { + return result, false, billingmigration.ErrStaleDigest + } else if err != nil { + return result, false, err + } + if command != "cutover" || status != "approved" || !bytes.Equal(approvalDigest, write.Digests.Approval) { + return result, false, billingmigration.ErrStaleDigest + } + if !expiresAt.After(write.ExecutedAt) { + return result, false, billingmigration.ErrExpiredApproval + } + actual := [][]byte{scopeDigest, manifest, mapping, policy, evidence, readiness, watermark, appVersion, approvalDigest} + expected := [][]byte{write.Digests.Scope, write.Digests.Manifest, write.Digests.Mapping, write.Digests.Policy, write.Digests.Evidence, write.Digests.Readiness, write.Digests.FinalWatermark, write.Digests.ApplicationVersion, write.Digests.Approval} + for i := range actual { + if !bytes.Equal(actual[i], expected[i]) { + return result, false, billingmigration.ErrStaleDigest + } + } + if err = verifyCutoverFreshness(ctx, tx, input.ProjectID, input.ProgramID, input.CheckpointID, write.Digests, write.ExecutedAt); err != nil { + return result, false, err + } + + scopes, err := lockAuthorityScopes(ctx, tx, input.ProjectID, input.ProgramID) + if err != nil { + return result, false, err + } + if !sameScope(input.Scope, environmentID, scopes) { + return result, false, billingmigration.ErrStaleDigest + } + for _, scope := range scopes { + if scope.authority != "source" || scope.epoch != input.ExpectedAuthorityEpoch { + return result, false, billingmigration.ErrAuthorityEpoch + } + } + if err = verifyPointerCoverage(ctx, tx, input.ProjectID, input.ProgramID, input.CheckpointID); err != nil { + return result, false, err + } + if err = lockCurrentPointers(ctx, tx, input.ProjectID, environmentID, scopes); err != nil { + return result, false, err + } + + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_scope_current_pointers(project_id,environment_id,application_id,platform,billing_customer_id,current_snapshot_id,authority_epoch,updated_at) + SELECT map.project_id,map.environment_id,map.application_id,map.platform,map.billing_customer_id,map.snapshot_id,$3,$4 + FROM billing_migration_checkpoint_pointer_maps map WHERE map.checkpoint_id=$1 AND map.program_id=$2 AND map.pointer_role='prepared_activation' + ON CONFLICT(project_id,environment_id,application_id,platform,billing_customer_id) DO UPDATE SET current_snapshot_id=excluded.current_snapshot_id,authority_epoch=excluded.authority_epoch,updated_at=excluded.updated_at`, input.CheckpointID, input.ProgramID, input.ExpectedAuthorityEpoch+1, write.ExecutedAt) + if err != nil { + return result, false, err + } + for _, scope := range scopes { + transitionID := billingmigration.DeterministicTransitionID(write.ExecutionID, scope.id) + transitionDigest := billingmigration.CanonicalTransitionDigest(input.ProgramID, scope.id, "source", "mosaic", scope.epoch, scope.epoch+1, "cutover", write.ExecutedAt) + newAuthorityDigest := billingmigration.CanonicalAuthorityDigest(input.ProjectID, environmentID, scope.applicationID, scope.platform, "mosaic", scope.epoch+1, input.ProgramID) + tag, updateErr := tx.Exec(ctx, `UPDATE billing_migration_authority_scopes SET current_authority='mosaic',current_epoch=$3,authority_digest=$4,updated_at=$5 WHERE id=$1 AND project_id=$2 AND current_authority='source' AND current_epoch=$6 AND authority_digest=$7`, scope.id, input.ProjectID, scope.epoch+1, newAuthorityDigest, write.ExecutedAt, scope.epoch, scope.authorityDigest) + if updateErr != nil { + return result, false, updateErr + } + if tag.RowsAffected() != 1 { + return result, false, billingmigration.ErrConcurrentTransition + } + if _, err = tx.Exec(ctx, `INSERT INTO billing_migration_authority_transitions(id,program_id,project_id,authority_scope_id,from_authority,to_authority,from_epoch,to_epoch,transition_kind,transition_digest,transitioned_at) VALUES($1,$2,$3,$4,'source','mosaic',$5,$6,'cutover',$7,$8)`, transitionID, input.ProgramID, input.ProjectID, scope.id, scope.epoch, scope.epoch+1, transitionDigest, write.ExecutedAt); err != nil { + return result, false, err + } + if _, err = tx.Exec(ctx, `INSERT INTO billing_migration_transition_outbox(id,program_id,project_id,authority_scope_id,transition_id,event_kind,authority_epoch,status,created_at,updated_at) VALUES('mto_'||substr(md5($1||':'||$2),1,20),$3,$4,$2,$1,'authority_changed',$5,'pending',$6,$6)`, transitionID, scope.id, input.ProgramID, input.ProjectID, scope.epoch+1, write.ExecutedAt); err != nil { + return result, false, err + } + result.TransitionIDs = append(result.TransitionIDs, transitionID) + } + if err = finishExecution(ctx, tx, input.ProjectID, input.ProgramID, input.ExpectedStateVersion, billingmigration.StateCutoverPending, billingmigration.StateStabilizing, write.ExecutionID, "execute_cutover", input.IdempotencyKey, write.RequestDigest, write.ActorID, input.Reason, len(scopes), input.ExpectedAuthorityEpoch+1, write.ExecutedAt); err != nil { + return result, false, err + } + if err = tx.Commit(ctx); err != nil { + return result, false, translateExecutionError(err) + } + return result, false, nil +} + +func (r *Repository) ExecuteRollback(ctx context.Context, write billingmigration.ExecuteRollbackWrite) (billingmigration.AuthorityExecution, bool, error) { + return retryExecution(ctx, func() (billingmigration.AuthorityExecution, bool, error) { return r.executeRollbackOnce(ctx, write) }) +} + +func (r *Repository) executeRollbackOnce(ctx context.Context, write billingmigration.ExecuteRollbackWrite) (billingmigration.AuthorityExecution, bool, error) { + input := write.Input + result := executionResult(input.ProgramID, write.ExecutionID, "rollback", "rolled_back", input.ExpectedStateVersion+1, input.ExpectedAuthorityEpoch+1, write.ExecutedAt) + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return result, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + if _, replay, replayErr := commandReplay(ctx, tx, input.ProgramID, input.ProjectID, "execute_rollback", input.IdempotencyKey, write.RequestDigest); replayErr != nil { + if errors.Is(replayErr, billingmigration.ErrConflict) { + return result, false, billingmigration.ErrIdempotencyConflict + } + return result, false, replayErr + } else if replay { + if err = loadExecutionReplay(ctx, tx, input.ProgramID, input.ProjectID, "execute_rollback", input.IdempotencyKey, "rollback", &result); err != nil { + return result, true, err + } + return result, true, tx.Commit(ctx) + } + var state, environmentID string + var version int64 + var scopeDigest []byte + if err = tx.QueryRow(ctx, `SELECT state,state_version,environment_id,scope_digest FROM billing_migration_programs WHERE id=$1 AND project_id=$2 FOR UPDATE`, input.ProgramID, input.ProjectID).Scan(&state, &version, &environmentID, &scopeDigest); errors.Is(err, pgx.ErrNoRows) { + return result, false, billingmigration.ErrNotFound + } else if err != nil { + return result, false, err + } + if state != billingmigration.StateStabilizing || version != input.ExpectedStateVersion { + return result, false, billingmigration.ErrStaleState + } + if environmentID != input.Scope.EnvironmentID { + return result, false, billingmigration.ErrStaleDigest + } + var command, status string + var expires, deadline time.Time + var approvalDigest, checkpointDigest, authorityDigest, prerequisiteDigest, bindingScope []byte + var credentialID, credentialStatus, capabilityID, sourceID, providerID string + var removed bool + var removedAt *time.Time + var capabilityDigest, sourceDigest, providerDigest []byte + var capabilityAt, sourceAt, providerAt time.Time + err = tx.QueryRow(ctx, `SELECT proposal.command,proposal.status,approval.expires_at,approval.approval_digest,binding.checkpoint_digest,binding.authority_digest,binding.rollback_prerequisites_digest,binding.scope_digest,binding.rollback_deadline,binding.credential_id,binding.credential_status,binding.credential_removed,binding.credential_removed_at,binding.capability_assessment_id,binding.capability_assessment_digest,binding.capability_assessed_at,binding.source_validation_id,binding.source_validation_digest,binding.source_validated_at,binding.provider_validation_id,binding.provider_validation_digest,binding.provider_validated_at + FROM billing_migration_approvals approval JOIN billing_migration_cutover_proposals proposal ON proposal.id=approval.proposal_id JOIN billing_migration_rollback_proposal_bindings binding ON binding.proposal_id=proposal.id + WHERE approval.id=$1 AND approval.program_id=$2 AND approval.project_id=$3 AND proposal.command='rollback' AND binding.checkpoint_id=$4 FOR UPDATE OF proposal,approval,binding`, input.ApprovalID, input.ProgramID, input.ProjectID, input.CheckpointID).Scan(&command, &status, &expires, &approvalDigest, &checkpointDigest, &authorityDigest, &prerequisiteDigest, &bindingScope, &deadline, &credentialID, &credentialStatus, &removed, &removedAt, &capabilityID, &capabilityDigest, &capabilityAt, &sourceID, &sourceDigest, &sourceAt, &providerID, &providerDigest, &providerAt) + if errors.Is(err, pgx.ErrNoRows) { + return result, false, billingmigration.ErrStaleRollbackPrerequisites + } else if err != nil { + return result, false, err + } + if command != "rollback" || status != "approved" || !bytes.Equal(approvalDigest, write.Digests.Approval) { + return result, false, billingmigration.ErrStaleDigest + } + if !expires.After(write.ExecutedAt) { + return result, false, billingmigration.ErrExpiredApproval + } + if write.ExecutedAt.After(deadline) { + return result, false, billingmigration.ErrRollbackWindow + } + if !bytes.Equal(checkpointDigest, write.Digests.Checkpoint) || !bytes.Equal(authorityDigest, write.Digests.Authority) || !bytes.Equal(prerequisiteDigest, write.Digests.RollbackPrerequisites) || !bytes.Equal(bindingScope, scopeDigest) { + return result, false, billingmigration.ErrStaleRollbackPrerequisites + } + if err = verifyRollbackPrerequisites(ctx, tx, input.ProjectID, input.ProgramID, credentialID, credentialStatus, removed, removedAt, capabilityID, capabilityDigest, capabilityAt, sourceID, sourceDigest, sourceAt, providerID, providerDigest, providerAt, write.ExecutedAt); err != nil { + return result, false, err + } + scopes, err := lockAuthorityScopes(ctx, tx, input.ProjectID, input.ProgramID) + if err != nil { + return result, false, err + } + if !sameScope(input.Scope, environmentID, scopes) { + return result, false, billingmigration.ErrStaleDigest + } + var authorityValues []string + for _, scope := range scopes { + if scope.authority != "mosaic" || scope.epoch != input.ExpectedAuthorityEpoch { + return result, false, billingmigration.ErrAuthorityEpoch + } + authorityValues = append(authorityValues, billingmigration.FormatDigest(scope.authorityDigest)) + } + setDigest, digestErr := billingmigration.AuthoritySetDigest(input.ProgramID, billingmigration.FormatDigest(scopeDigest), authorityValues) + if digestErr != nil || setDigest != input.ExpectedDigests.Authority { + return result, false, billingmigration.ErrStaleAuthority + } + if err = verifyRollbackMaps(ctx, tx, input.ProjectID, input.ProgramID, input.CheckpointID); err != nil { + return result, false, err + } + if err = lockCurrentPointers(ctx, tx, input.ProjectID, environmentID, scopes); err != nil { + return result, false, err + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_scope_current_pointers(project_id,environment_id,application_id,platform,billing_customer_id,current_snapshot_id,authority_epoch,updated_at) + SELECT project_id,environment_id,application_id,platform,billing_customer_id,snapshot_id,$3,$4 FROM billing_migration_checkpoint_pointer_maps WHERE checkpoint_id=$1 AND program_id=$2 AND pointer_role='rollback_baseline' AND NOT absent_current + ON CONFLICT(project_id,environment_id,application_id,platform,billing_customer_id) DO UPDATE SET current_snapshot_id=excluded.current_snapshot_id,authority_epoch=excluded.authority_epoch,updated_at=excluded.updated_at`, input.CheckpointID, input.ProgramID, input.ExpectedAuthorityEpoch+1, write.ExecutedAt) + if err != nil { + return result, false, err + } + _, err = tx.Exec(ctx, `DELETE FROM billing_migration_scope_current_pointers current USING billing_migration_program_scopes scope WHERE scope.program_id=$1 AND current.project_id=scope.project_id AND current.environment_id=scope.environment_id AND current.application_id=scope.application_id AND current.platform=scope.platform AND NOT EXISTS(SELECT 1 FROM billing_migration_checkpoint_pointer_maps map WHERE map.checkpoint_id=$2 AND map.program_id=$1 AND map.pointer_role='rollback_baseline' AND NOT map.absent_current AND map.application_id=current.application_id AND map.platform=current.platform AND map.billing_customer_id=current.billing_customer_id)`, input.ProgramID, input.CheckpointID) + if err != nil { + return result, false, err + } + for _, scope := range scopes { + transitionID := billingmigration.DeterministicTransitionID(write.ExecutionID, scope.id) + td := billingmigration.CanonicalTransitionDigest(input.ProgramID, scope.id, "mosaic", "source_rollback", scope.epoch, scope.epoch+1, "rollback", write.ExecutedAt) + ad := billingmigration.CanonicalAuthorityDigest(input.ProjectID, environmentID, scope.applicationID, scope.platform, "source_rollback", scope.epoch+1, input.ProgramID) + tag, e := tx.Exec(ctx, `UPDATE billing_migration_authority_scopes SET current_authority='source_rollback',current_epoch=$3,authority_digest=$4,updated_at=$5 WHERE id=$1 AND project_id=$2 AND current_authority='mosaic' AND current_epoch=$6 AND authority_digest=$7`, scope.id, input.ProjectID, scope.epoch+1, ad, write.ExecutedAt, scope.epoch, scope.authorityDigest) + if e != nil { + return result, false, e + } + if tag.RowsAffected() != 1 { + return result, false, billingmigration.ErrConcurrentTransition + } + if _, e = tx.Exec(ctx, `INSERT INTO billing_migration_authority_transitions(id,program_id,project_id,authority_scope_id,from_authority,to_authority,from_epoch,to_epoch,transition_kind,transition_digest,transitioned_at) VALUES($1,$2,$3,$4,'mosaic','source_rollback',$5,$6,'rollback',$7,$8)`, transitionID, input.ProgramID, input.ProjectID, scope.id, scope.epoch, scope.epoch+1, td, write.ExecutedAt); e != nil { + return result, false, e + } + if _, e = tx.Exec(ctx, `INSERT INTO billing_migration_transition_outbox(id,program_id,project_id,authority_scope_id,transition_id,event_kind,authority_epoch,status,created_at,updated_at) VALUES('mto_'||substr(md5($1||':'||$2),1,20),$3,$4,$2,$1,'rollback_changed',$5,'pending',$6,$6)`, transitionID, scope.id, input.ProgramID, input.ProjectID, scope.epoch+1, write.ExecutedAt); e != nil { + return result, false, e + } + result.TransitionIDs = append(result.TransitionIDs, transitionID) + } + if err = finishExecution(ctx, tx, input.ProjectID, input.ProgramID, input.ExpectedStateVersion, billingmigration.StateStabilizing, "rolled_back", write.ExecutionID, "execute_rollback", input.IdempotencyKey, write.RequestDigest, write.ActorID, input.Reason, len(scopes), input.ExpectedAuthorityEpoch+1, write.ExecutedAt); err != nil { + return result, false, err + } + if err = tx.Commit(ctx); err != nil { + return result, false, translateExecutionError(err) + } + return result, false, nil +} + +func verifyCutoverFreshness(ctx context.Context, tx pgx.Tx, projectID, programID, checkpointID string, d billingmigration.ParsedCutoverCommandDigests, now time.Time) error { + var ready, authoritative, watermarksFresh bool + var rd []byte + var assessed time.Time + if err := tx.QueryRow(ctx, `SELECT ready,authoritative,watermarks_fresh,readiness_digest,assessed_at FROM billing_migration_readiness_assessments WHERE program_id=$1 AND project_id=$2 ORDER BY assessed_at DESC,id DESC LIMIT 1`, programID, projectID).Scan(&ready, &authoritative, &watermarksFresh, &rd, &assessed); err != nil || !ready || !authoritative || !watermarksFresh || !bytes.Equal(rd, d.Readiness) { + return billingmigration.ErrStaleDigest + } + var maxAge int + var source, provider, shadow, completed time.Time + var manifest, mapping, evidence, watermark []byte + if err := tx.QueryRow(ctx, `SELECT policy.watermark_max_age_seconds,delta.source_watermark,delta.provider_watermark,delta.shadow_watermark,delta.completed_at,delta.manifest_digest,delta.mapping_digest,delta.evidence_digest,delta.final_watermark_digest FROM billing_migration_readiness_policies policy CROSS JOIN LATERAL(SELECT * FROM billing_migration_final_deltas WHERE program_id=$1 AND project_id=$2 ORDER BY completed_at DESC,id DESC LIMIT 1)delta WHERE policy.program_id=$1 AND policy.project_id=$2 ORDER BY policy.frozen_at DESC,policy.id DESC LIMIT 1`, programID, projectID).Scan(&maxAge, &source, &provider, &shadow, &completed, &manifest, &mapping, &evidence, &watermark); err != nil { + return billingmigration.ErrStaleDigest + } + if now.Sub(source) > time.Duration(maxAge)*time.Second || now.Sub(provider) > time.Duration(maxAge)*time.Second || now.Sub(shadow) > time.Duration(maxAge)*time.Second || now.Sub(completed) > time.Duration(maxAge)*time.Second || now.Sub(assessed) > time.Duration(maxAge)*time.Second { + return billingmigration.ErrStaleDigest + } + for i, pair := range [][2][]byte{{manifest, d.Manifest}, {mapping, d.Mapping}, {evidence, d.Evidence}, {watermark, d.FinalWatermark}} { + if !bytes.Equal(pair[0], pair[1]) { + _ = i + return billingmigration.ErrStaleDigest + } + } + var invalid bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM billing_migration_cases c WHERE c.program_id=$1 AND c.project_id=$2 AND c.classification IN('critical','blocking') AND c.status NOT IN('resolved','dismissed') AND NOT EXISTS(SELECT 1 FROM billing_migration_source_access_exceptions e WHERE e.case_id=c.id AND e.approved_at<=$3 AND e.expires_at>$3 AND e.identity_ambiguity_count=0 AND e.proposer_actor_id<>e.approver_actor_id))`, programID, projectID, now).Scan(&invalid); err != nil { + return err + } + if invalid { + return billingmigration.ErrStaleDigest + } + return nil +} + +func verifyPointerCoverage(ctx context.Context, tx pgx.Tx, projectID, programID, checkpointID string) error { + var invalid bool + if err := tx.QueryRow(ctx, `WITH frozen_cohort AS ( + SELECT customer.billing_customer_id + FROM billing_migration_checkpoints checkpoint + JOIN billing_migration_final_delta_cohort_sets cohort_set ON cohort_set.program_id=checkpoint.program_id AND cohort_set.project_id=checkpoint.project_id AND cohort_set.cohort_digest=checkpoint.cohort_digest + JOIN billing_migration_final_delta_cohort_customers customer ON customer.cohort_set_id=cohort_set.id AND customer.program_id=cohort_set.program_id AND customer.project_id=cohort_set.project_id + WHERE checkpoint.id=$1 AND checkpoint.program_id=$2 AND checkpoint.project_id=$3 + ), expected AS ( + SELECT scope.project_id,scope.environment_id,scope.application_id,scope.platform,customer.billing_customer_id,role.pointer_role + FROM billing_migration_program_scopes scope CROSS JOIN frozen_cohort customer + CROSS JOIN (VALUES('prepared_activation'::text),('rollback_baseline'::text)) role(pointer_role) + WHERE scope.program_id=$2 AND scope.project_id=$3 + ), actual AS ( + SELECT project_id,environment_id,application_id,platform,billing_customer_id,pointer_role + FROM billing_migration_checkpoint_pointer_maps WHERE checkpoint_id=$1 AND program_id=$2 + ) + SELECT + NOT EXISTS(SELECT 1 FROM frozen_cohort) + OR EXISTS(SELECT * FROM expected EXCEPT SELECT * FROM actual) + OR EXISTS(SELECT * FROM actual EXCEPT SELECT * FROM expected) + OR EXISTS( + SELECT 1 FROM billing_migration_checkpoint_pointer_maps map + LEFT JOIN customer_entitlement_snapshots snapshot + ON snapshot.id=map.snapshot_id AND snapshot.project_id=map.project_id + AND snapshot.environment_id=map.environment_id AND snapshot.billing_customer_id=map.billing_customer_id + WHERE map.checkpoint_id=$1 AND map.program_id=$2 AND ( + map.project_id<>$3 + OR (map.pointer_role='prepared_activation' AND (map.snapshot_id IS NULL OR map.absent_current OR snapshot.id IS NULL)) + OR (map.pointer_role='rollback_baseline' AND ( + (map.absent_current AND map.snapshot_id IS NOT NULL) + OR (NOT map.absent_current AND (map.snapshot_id IS NULL OR snapshot.id IS NULL)) + )) + ) + )`, checkpointID, programID, projectID).Scan(&invalid); err != nil { + return billingmigration.ErrPointerCoverage + } + if invalid { + return billingmigration.ErrPointerCoverage + } + return nil +} + +func verifyRollbackMaps(ctx context.Context, tx pgx.Tx, projectID, programID, checkpointID string) error { + return verifyPointerCoverage(ctx, tx, projectID, programID, checkpointID) +} + +func verifyRollbackPrerequisites(ctx context.Context, tx pgx.Tx, projectID, programID, credentialID, credentialStatus string, removed bool, removedAt *time.Time, capabilityID string, capabilityDigest []byte, capabilityAt time.Time, sourceID string, sourceDigest []byte, sourceAt time.Time, providerID string, providerDigest []byte, providerAt, now time.Time) error { + if credentialStatus != "active" || removed || removedAt != nil { + return billingmigration.ErrRollbackPrerequisite + } + var status string + var dbRemoved *time.Time + var envelope bool + if err := tx.QueryRow(ctx, `SELECT status,removed_at,(nonce IS NOT NULL AND ciphertext IS NOT NULL) FROM billing_migration_credentials WHERE id=$1 AND project_id=$2`, credentialID, projectID).Scan(&status, &dbRemoved, &envelope); err != nil || status != "active" || dbRemoved != nil || !envelope { + return billingmigration.ErrRollbackPrerequisite + } + var maxAge int + if err := tx.QueryRow(ctx, `SELECT watermark_max_age_seconds FROM billing_migration_readiness_policies WHERE program_id=$1 AND project_id=$2 ORDER BY frozen_at DESC,id DESC LIMIT 1`, programID, projectID).Scan(&maxAge); err != nil { + return billingmigration.ErrRollbackPrerequisite + } + if now.Sub(capabilityAt) > time.Duration(maxAge)*time.Second || now.Sub(sourceAt) > time.Duration(maxAge)*time.Second || now.Sub(providerAt) > time.Duration(maxAge)*time.Second { + return billingmigration.ErrRollbackPrerequisite + } + var id string + var raw []byte + var at time.Time + if err := tx.QueryRow(ctx, `SELECT id,assessment_digest,assessed_at FROM billing_migration_capability_assessments WHERE program_id=$1 AND project_id=$2 ORDER BY assessed_at DESC,id DESC LIMIT 1`, programID, projectID).Scan(&id, &raw, &at); err != nil || id != capabilityID || !bytes.Equal(raw, capabilityDigest) || !at.Equal(capabilityAt) { + return billingmigration.ErrRollbackPrerequisite + } + for _, v := range []struct { + kind, id string + digest []byte + at time.Time + }{{"source_validation", sourceID, sourceDigest, sourceAt}, {"provider_validation", providerID, providerDigest, providerAt}} { + if err := tx.QueryRow(ctx, `SELECT id,result_digest,attempted_at FROM billing_migration_validation_attempts WHERE program_id=$1 AND project_id=$2 AND attempt_kind=$3 AND status='succeeded' ORDER BY attempted_at DESC,id DESC LIMIT 1`, programID, projectID, v.kind).Scan(&id, &raw, &at); err != nil || id != v.id || !bytes.Equal(raw, v.digest) || !at.Equal(v.at) { + return billingmigration.ErrRollbackPrerequisite + } + } + return nil +} + +func lockAuthorityScopes(ctx context.Context, tx pgx.Tx, projectID, programID string) ([]lockedScope, error) { + rows, err := tx.Query(ctx, `SELECT authority.id,scope.application_id,scope.platform,authority.current_authority,authority.current_epoch,authority.authority_digest FROM billing_migration_program_scopes scope JOIN billing_migration_authority_scopes authority ON authority.project_id=scope.project_id AND authority.environment_id=scope.environment_id AND authority.application_id=scope.application_id AND authority.platform=scope.platform WHERE scope.program_id=$1 AND scope.project_id=$2 AND authority.active_program_id=$1 ORDER BY scope.application_id,scope.platform FOR UPDATE OF authority`, programID, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []lockedScope + for rows.Next() { + var v lockedScope + if err = rows.Scan(&v.id, &v.applicationID, &v.platform, &v.authority, &v.epoch, &v.authorityDigest); err != nil { + return nil, err + } + result = append(result, v) + } + if len(result) == 0 { + return nil, billingmigration.ErrAuthorityEpoch + } + return result, rows.Err() +} + +func lockCurrentPointers(ctx context.Context, tx pgx.Tx, projectID, environmentID string, scopes []lockedScope) error { + for _, scope := range scopes { + rows, err := tx.Query(ctx, `SELECT billing_customer_id FROM billing_migration_scope_current_pointers WHERE project_id=$1 AND environment_id=$2 AND application_id=$3 AND platform=$4 ORDER BY billing_customer_id FOR UPDATE`, projectID, environmentID, scope.applicationID, scope.platform) + if err != nil { + return err + } + rows.Close() + } + return nil +} + +func sameScope(input billingmigration.Scope, environmentID string, scopes []lockedScope) bool { + if input.EnvironmentID != environmentID || len(input.Applications) != len(scopes) { + return false + } + for i, v := range scopes { + if input.Applications[i].ApplicationID != v.applicationID || input.Applications[i].Platform != v.platform { + return false + } + } + return true +} + +func finishExecution(ctx context.Context, tx pgx.Tx, projectID, programID string, expected int64, from, to, executionID, kind, key string, requestDigest []byte, actorID, reason string, scopeCount int, epoch int64, at time.Time) error { + tag, err := tx.Exec(ctx, `UPDATE billing_migration_programs SET state=$4,state_version=state_version+1,updated_at=$5 WHERE id=$1 AND project_id=$2 AND state_version=$3 AND state=$6`, programID, projectID, expected, to, at, from) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return billingmigration.ErrConcurrentTransition + } + if err = storeCommand(ctx, tx, programID, projectID, kind, key, requestDigest, executionID, at); err != nil { + return err + } + var organizationID, environmentID string + if err = tx.QueryRow(ctx, `SELECT project.organization_id,program.environment_id FROM billing_migration_programs program JOIN projects project ON project.id=program.project_id WHERE program.id=$1 AND program.project_id=$2`, programID, projectID).Scan(&organizationID, &environmentID); err != nil { + return err + } + metadata, _ := json.Marshal(map[string]any{"command": kind, "reason": reason, "scopeCount": scopeCount, "authorityEpoch": epoch, "requestDigest": billingmigration.FormatDigest(requestDigest)}) + _, err = tx.Exec(ctx, `INSERT INTO audit_events(id,actor_id,organization_id,project_id,environment_id,action,resource_type,resource_id,metadata,created_at) VALUES('aud_'||$1,$2,$3,$4,$5,$6,'billing_migration_program',$7,$8,$9)`, executionID, actorID, organizationID, projectID, environmentID, "billing.migration."+kind, programID, metadata, at) + return err +} + +func executionResult(programID, executionID, command, state string, version, epoch int64, at time.Time) billingmigration.AuthorityExecution { + return billingmigration.AuthorityExecution{ProgramID: programID, ExecutionID: executionID, Command: command, State: state, StateVersion: version, AuthorityEpoch: epoch, ExecutedAt: at} +} +func loadExecutionReplay(ctx context.Context, tx pgx.Tx, programID, projectID, commandKind, key, transitionKind string, result *billingmigration.AuthorityExecution) error { + if err := tx.QueryRow(ctx, `SELECT created_at FROM billing_migration_command_idempotency WHERE program_id=$1 AND project_id=$2 AND command_kind=$3 AND idempotency_key=$4`, programID, projectID, commandKind, key).Scan(&result.ExecutedAt); err != nil { + return err + } + rows, err := tx.Query(ctx, `SELECT id FROM billing_migration_authority_transitions WHERE program_id=$1 AND project_id=$2 AND transition_kind=$3 AND transitioned_at=$4 ORDER BY authority_scope_id`, programID, projectID, transitionKind, result.ExecutedAt) + if err != nil { + return err + } + defer rows.Close() + result.TransitionIDs = nil + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return err + } + result.TransitionIDs = append(result.TransitionIDs, id) + } + if len(result.TransitionIDs) == 0 { + return billingmigration.ErrConcurrentTransition + } + return rows.Err() +} + +func retryExecution(ctx context.Context, operation func() (billingmigration.AuthorityExecution, bool, error)) (billingmigration.AuthorityExecution, bool, error) { + var result billingmigration.AuthorityExecution + var replay bool + var err error + for attempt := 0; attempt < 3; attempt++ { + result, replay, err = operation() + if !isSerializationFailure(err) { + return result, replay, err + } + } + return result, replay, billingmigration.ErrConcurrentTransition +} +func isSerializationFailure(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "40001" +} +func translateExecutionError(err error) error { + if isSerializationFailure(err) { + return err + } + return err +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/execution_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/execution_integration_test.go new file mode 100644 index 00000000..c20a263b --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/execution_integration_test.go @@ -0,0 +1,430 @@ +package billingmigrationpostgres_test + +import ( + "context" + "database/sql" + "errors" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +func TestMain(m *testing.M) { + code := m.Run() + if databaseURL := os.Getenv("DATABASE_TEST_URL"); databaseURL != "" { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + pool, err := pgxpool.New(ctx, databaseURL) + if err == nil { + _, err = pool.Exec(ctx, `DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public`) + pool.Close() + } + cancel() + if err != nil && code == 0 { + code = 1 + } + } + os.Exit(code) +} + +func TestAuthorityExecutionIsAtomicAndRollbackRestoresCheckpointPointers(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + checkpointID, approvalDigest, checkpointDigest := seedExecutionCheckpoint(t, ctx, db, now) + service := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return now })) + scope := billingmigration.Scope{ProjectID: "project_one", EnvironmentID: "environment_one", Applications: []billingmigration.ScopeItem{{ApplicationID: "app_two", Platform: "android"}, {ApplicationID: "app_one", Platform: "ios"}}} + cutover := billingmigration.ExecuteCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: "execute-cutover", ExpectedStateVersion: 6, Reason: "Production cutover after final owner review", Scope: scope, CheckpointID: checkpointID, ApprovalID: "approval_execute", ExpectedAuthorityEpoch: 0, ExpectedDigests: billingmigration.CutoverCommandDigests{Scope: digestByte(0x61), Manifest: digestByte(0x65), Mapping: digestByte(0x66), Policy: digestByte(0x71), Evidence: digestByte(0x67), Readiness: digestByte(0x88), FinalWatermark: digestByte(0x68), ApplicationVersion: digestByte(0x70), Approval: approvalDigest}} + staleState := cutover + staleState.IdempotencyKey = "stale-state" + staleState.ExpectedStateVersion = 5 + if _, _, staleErr := service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, staleState); !errors.Is(staleErr, billingmigration.ErrStaleState) { + t.Fatalf("stale state error=%v", staleErr) + } + staleDigest := cutover + staleDigest.IdempotencyKey = "stale-digest" + staleDigest.ExpectedDigests.Manifest = digestByte(0xff) + if _, _, staleErr := service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, staleDigest); !errors.Is(staleErr, billingmigration.ErrStaleDigest) { + t.Fatalf("stale digest error=%v", staleErr) + } + staleEpoch := cutover + staleEpoch.IdempotencyKey = "stale-epoch" + staleEpoch.ExpectedAuthorityEpoch = 1 + if _, _, staleErr := service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, staleEpoch); !errors.Is(staleErr, billingmigration.ErrAuthorityEpoch) { + t.Fatalf("authority epoch error=%v", staleErr) + } + expiredService := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return now.Add(2 * time.Hour) })) + expired := cutover + expired.IdempotencyKey = "expired-approval" + if _, _, staleErr := expiredService.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, expired); !errors.Is(staleErr, billingmigration.ErrExpiredApproval) { + t.Fatalf("expired approval error=%v", staleErr) + } + result, replay, err := service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, cutover) + if err != nil || replay || result.State != billingmigration.StateStabilizing || result.AuthorityEpoch != 1 || len(result.TransitionIDs) != 2 { + t.Fatalf("cutover result=%#v replay=%v err=%v", result, replay, err) + } + replayed, replay, err := service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, cutover) + if err != nil || !replay || replayed.ExecutionID != result.ExecutionID || len(replayed.TransitionIDs) != 2 { + t.Fatalf("cutover replay=%#v replay=%v err=%v", replayed, replay, err) + } + different := cutover + different.Reason = "different reviewed reason" + if _, _, err = service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, different); !errors.Is(err, billingmigration.ErrIdempotencyConflict) { + t.Fatalf("different request same key error=%v", err) + } + var state string + var version int64 + var mosaicAuthorities, outbox, audits int + if err = pool.QueryRow(ctx, `SELECT state,state_version FROM billing_migration_programs WHERE id='program_ready'`).Scan(&state, &version); err != nil || state != "stabilizing" || version != 7 { + t.Fatalf("program state=%s version=%d err=%v", state, version, err) + } + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_authority_scopes WHERE active_program_id='program_ready' AND current_authority='mosaic' AND current_epoch=1`).Scan(&mosaicAuthorities); err != nil || mosaicAuthorities != 2 { + t.Fatalf("mosaic authority count=%d err=%v", mosaicAuthorities, err) + } + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_transition_outbox WHERE program_id='program_ready' AND event_kind='authority_changed'`).Scan(&outbox); err != nil || outbox != 2 { + t.Fatalf("cutover outbox=%d err=%v", outbox, err) + } + if err = pool.QueryRow(ctx, `SELECT count(*) FROM audit_events WHERE resource_id='program_ready' AND action='billing.migration.execute_cutover'`).Scan(&audits); err != nil || audits != 1 { + t.Fatalf("cutover audit=%d err=%v", audits, err) + } + var iosPointer, androidPointer string + if err = pool.QueryRow(ctx, `SELECT current_snapshot_id FROM billing_migration_scope_current_pointers WHERE application_id='app_one' AND platform='ios' AND billing_customer_id='customer_one'`).Scan(&iosPointer); err != nil || iosPointer != "snapshot_activation" { + t.Fatalf("ios activation pointer=%q err=%v", iosPointer, err) + } + if err = pool.QueryRow(ctx, `SELECT current_snapshot_id FROM billing_migration_scope_current_pointers WHERE application_id='app_two' AND platform='android' AND billing_customer_id='customer_one'`).Scan(&androidPointer); err != nil || androidPointer != "snapshot_activation" { + t.Fatalf("android activation pointer=%q err=%v", androidPointer, err) + } + + if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_validation_attempts(id,program_id,project_id,attempt_kind,status,record_count,result_digest,attempted_at) VALUES('validation_source_execute','program_ready','project_one','source_validation','succeeded',1,decode(repeat('93',32),'hex'),$1),('validation_provider_execute','program_ready','project_one','provider_validation','succeeded',1,decode(repeat('94',32),'hex'),$1)`, now); err != nil { + t.Fatal(err) + } + if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_scope_current_pointers(project_id,environment_id,application_id,platform,billing_customer_id,current_snapshot_id,authority_epoch,updated_at) VALUES('project_one','environment_one','app_one','ios','customer_two','snapshot_mosaic_only',1,$1)`, now); err != nil { + t.Fatal(err) + } + var authorityDigests []string + rows, err := pool.Query(ctx, `SELECT authority_digest FROM billing_migration_authority_scopes WHERE active_program_id='program_ready' ORDER BY application_id,platform`) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var raw []byte + if err = rows.Scan(&raw); err != nil { + t.Fatal(err) + } + authorityDigests = append(authorityDigests, billingmigration.FormatDigest(raw)) + } + rows.Close() + authoritySet, err := billingmigration.AuthoritySetDigest("program_ready", digestByte(0x61), authorityDigests) + if err != nil { + t.Fatal(err) + } + var rollbackTransitionID string + if err = pool.QueryRow(ctx, `SELECT id FROM billing_migration_authority_transitions WHERE program_id='program_ready' AND transition_kind='cutover' ORDER BY id DESC LIMIT 1`).Scan(&rollbackTransitionID); err != nil { + t.Fatal(err) + } + binding := billingmigration.RollbackProposalBinding{CheckpointID: checkpointID, CheckpointDigest: checkpointDigest, AuthorityDigest: authoritySet, ScopeDigest: digestByte(0x61), CutoverTransitionID: rollbackTransitionID, CutoverTransitionDigest: "", CutoverEpoch: 1, CutoverTransitionedAt: now, RollbackDeadline: now.Add(7 * 24 * time.Hour), CredentialID: "credential_ready", CredentialStatus: "active", CapabilityAssessmentID: "capability_ready", CapabilityAssessmentDigest: digestByte(0x72), CapabilityAssessedAt: now.Add(-time.Minute), SourceValidationID: "validation_source_execute", SourceValidationDigest: digestByte(0x93), SourceValidatedAt: now, ProviderValidationID: "validation_provider_execute", ProviderValidationDigest: digestByte(0x94), ProviderValidatedAt: now} + var transitionRaw []byte + if err = pool.QueryRow(ctx, `SELECT transition_digest FROM billing_migration_authority_transitions WHERE id=$1`, binding.CutoverTransitionID).Scan(&transitionRaw); err != nil { + t.Fatal(err) + } + binding.CutoverTransitionDigest = billingmigration.FormatDigest(transitionRaw) + prerequisites, err := billingmigration.RollbackPrerequisitesDigest("program_ready", 7, binding) + if err != nil { + t.Fatal(err) + } + proposal, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.ProposeRollbackInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: "propose-rollback-execute", CheckpointID: checkpointID, ExpectedStateVersion: 7, ExpectedCheckpointDigest: checkpointDigest, ExpectedAuthorityDigest: authoritySet, ExpectedRollbackPrerequisitesDigest: prerequisites, Reason: "Verified rollback prerequisites and source health", ExpiresAt: now.Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + approval, _, err := service.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: proposal.ProposalID, IdempotencyKey: "approve-rollback-execute", ExpectedStateVersion: 7}) + if err != nil { + t.Fatal(err) + } + rollback := billingmigration.ExecuteRollbackInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: "execute-rollback", ExpectedStateVersion: 7, ExpectedDigests: billingmigration.RollbackCommandDigests{Checkpoint: checkpointDigest, Authority: authoritySet, RollbackPrerequisites: prerequisites, Approval: approval.ApprovalDigest}, Reason: "Rollback within approved window after source health verification", Scope: scope, CheckpointID: checkpointID, ApprovalID: approval.ApprovalID, ExpectedAuthorityEpoch: 1} + rolledBack, replay, err := service.ExecuteRollback(ctx, billingmigration.Actor{ID: "owner_one"}, rollback) + if err != nil || replay || rolledBack.State != "rolled_back" || rolledBack.AuthorityEpoch != 2 || len(rolledBack.TransitionIDs) != 2 { + t.Fatalf("rollback=%#v replay=%v err=%v", rolledBack, replay, err) + } + if err = pool.QueryRow(ctx, `SELECT current_snapshot_id FROM billing_migration_scope_current_pointers WHERE application_id='app_one' AND platform='ios' AND billing_customer_id='customer_one'`).Scan(&iosPointer); err != nil || iosPointer != "snapshot_baseline" { + t.Fatalf("restored ios baseline=%q err=%v", iosPointer, err) + } + var removed int + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_scope_current_pointers WHERE (application_id='app_two' AND billing_customer_id='customer_one') OR billing_customer_id='customer_two'`).Scan(&removed); err != nil || removed != 0 { + t.Fatalf("absent/mosaic-only pointers remaining=%d err=%v", removed, err) + } + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_transition_outbox WHERE program_id='program_ready' AND event_kind='rollback_changed'`).Scan(&outbox); err != nil || outbox != 2 { + t.Fatalf("rollback outbox=%d err=%v", outbox, err) + } +} + +func TestCutoverAuditFailureRollsBackEveryWrite(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + checkpointID, approvalDigest, _ := seedExecutionCheckpoint(t, ctx, db, now) + if _, err := pool.Exec(ctx, `CREATE FUNCTION reject_execution_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='billing.migration.execute_cutover' THEN RAISE EXCEPTION 'forced audit failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_execution_audit BEFORE INSERT ON audit_events FOR EACH ROW EXECUTE FUNCTION reject_execution_audit()`); err != nil { + t.Fatal(err) + } + service := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return now })) + input := executionCutoverInput(checkpointID, approvalDigest, "atomic-failure") + if _, _, err := service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, input); err == nil { + t.Fatal("forced audit failure accepted") + } + var state, pointer string + var version, epoch, transitions, outbox, idempotency int64 + if err := pool.QueryRow(ctx, `SELECT state,state_version FROM billing_migration_programs WHERE id='program_ready'`).Scan(&state, &version); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT current_authority,current_epoch FROM billing_migration_authority_scopes WHERE id='authority_ready_ios'`).Scan(&pointer, &epoch); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_authority_transitions WHERE program_id='program_ready'`).Scan(&transitions); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_transition_outbox WHERE program_id='program_ready'`).Scan(&outbox); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_command_idempotency WHERE program_id='program_ready' AND command_kind='execute_cutover'`).Scan(&idempotency); err != nil { + t.Fatal(err) + } + if state != "cutover_pending" || version != 6 || pointer != "source" || epoch != 0 || transitions != 0 || outbox != 0 || idempotency != 0 { + t.Fatalf("partial failure state=%s/%d authority=%s/%d transitions=%d outbox=%d idempotency=%d", state, version, pointer, epoch, transitions, outbox, idempotency) + } +} + +func TestCutoverRejectsSameCardinalityCorruptedCheckpointMap(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + checkpointID, approvalDigest, _ := seedExecutionCheckpoint(t, ctx, db, now) + if _, err := pool.Exec(ctx, `ALTER TABLE billing_migration_checkpoint_pointer_maps DISABLE TRIGGER billing_migration_checkpoint_maps_immutable; DELETE FROM billing_migration_checkpoint_pointer_maps WHERE id='map_android_baseline'; INSERT INTO billing_migration_checkpoint_pointer_maps(id,checkpoint_id,program_id,project_id,environment_id,application_id,platform,billing_customer_id,pointer_role,snapshot_id,absent_current,pointer_digest) VALUES('map_noncohort_baseline','checkpoint_execute','program_ready','project_one','environment_one','app_two','android','customer_two','rollback_baseline','snapshot_mosaic_only',false,decode(repeat('77',32),'hex')); ALTER TABLE billing_migration_checkpoint_pointer_maps ENABLE TRIGGER billing_migration_checkpoint_maps_immutable`); err != nil { + t.Fatal(err) + } + service := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return now })) + if _, _, err := service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, executionCutoverInput(checkpointID, approvalDigest, "corrupted-map")); !errors.Is(err, billingmigration.ErrPointerCoverage) { + t.Fatalf("same-cardinality corrupted map error=%v", err) + } + var state string + var transitions int + if err := pool.QueryRow(ctx, `SELECT state FROM billing_migration_programs WHERE id='program_ready'`).Scan(&state); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_authority_transitions WHERE program_id='program_ready'`).Scan(&transitions); err != nil { + t.Fatal(err) + } + if state != "cutover_pending" || transitions != 0 { + t.Fatalf("corrupt map changed state=%s transitions=%d", state, transitions) + } +} + +func TestCutoverRejectsExecuteTimeReadinessDrift(t *testing.T) { + tests := []struct{ name, mutation string }{{"final watermark drift", `INSERT INTO billing_migration_final_deltas(id,program_id,project_id,state_version,manifest_digest,mapping_digest,evidence_digest,final_watermark_digest,source_watermark,provider_watermark,shadow_watermark,delta_digest,completed_at) VALUES('delta_drift','program_ready','project_one',6,decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('67',32),'hex'),decode(repeat('aa',32),'hex'),$1,$1,$1,decode(repeat('ab',32),'hex'),$1+interval '1 second')`}, {"stale watermarks", `INSERT INTO billing_migration_final_deltas(id,program_id,project_id,state_version,manifest_digest,mapping_digest,evidence_digest,final_watermark_digest,source_watermark,provider_watermark,shadow_watermark,delta_digest,completed_at) VALUES('delta_stale','program_ready','project_one',6,decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('67',32),'hex'),decode(repeat('68',32),'hex'),$1-interval '2 hours',$1-interval '2 hours',$1-interval '2 hours',decode(repeat('ac',32),'hex'),$1+interval '1 second')`}, {"unresolved blocking case", `INSERT INTO billing_migration_cases(id,program_id,project_id,state_version,classification,status,reason,case_digest,opened_at) VALUES('case_execute_drift','program_ready','project_one',6,'blocking','open','new blocking divergence',decode(repeat('ad',32),'hex'),$1)`}, {"expired exception", `INSERT INTO billing_migration_cases(id,program_id,project_id,state_version,classification,status,reason,case_digest,opened_at) VALUES('case_expired_execute','program_ready','project_one',6,'blocking','open','exception expired',decode(repeat('ae',32),'hex'),$1-interval '2 hours'); INSERT INTO billing_migration_source_access_exceptions(id,case_id,program_id,project_id,application_id,platform,reason,affected_customer_count,rollback_treatment,identity_ambiguity_count,proposer_actor_id,approver_actor_id,approved_at,expires_at,exception_digest) VALUES('exception_expired_execute','case_expired_execute','program_ready','project_one','app_one','ios','temporarily approved',1,'restore source',0,'owner_one','owner_two',$1-interval '2 hours',$1-interval '1 second',decode(repeat('af',32),'hex'))`}} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + checkpointID, approvalDigest, _ := seedExecutionCheckpoint(t, ctx, db, now) + statement := strings.ReplaceAll(test.mutation, "$1", "TIMESTAMPTZ '"+now.Format(time.RFC3339)+"'") + if _, err := db.ExecContext(ctx, statement); err != nil { + t.Fatal(err) + } + service := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return now })) + if _, _, err := service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, executionCutoverInput(checkpointID, approvalDigest, "drift-"+test.name)); !errors.Is(err, billingmigration.ErrStaleDigest) { + t.Fatalf("execute-time drift error=%v", err) + } + }) + } +} + +func TestRollbackAuditFailureRollsBackEveryWrite(t *testing.T) { + ctx, pool, _, service, input := prepareRollbackExecution(t) + if _, err := pool.Exec(ctx, `CREATE FUNCTION reject_rollback_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='billing.migration.execute_rollback' THEN RAISE EXCEPTION 'forced rollback audit failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_rollback_audit BEFORE INSERT ON audit_events FOR EACH ROW EXECUTE FUNCTION reject_rollback_audit()`); err != nil { + t.Fatal(err) + } + if _, _, err := service.ExecuteRollback(ctx, billingmigration.Actor{ID: "owner_one"}, input); err == nil { + t.Fatal("forced rollback audit failure accepted") + } + var state, ios, android, mosaicOnly string + var version, authorities, transitions, outbox, idempotency int + if err := pool.QueryRow(ctx, `SELECT state,state_version FROM billing_migration_programs WHERE id='program_ready'`).Scan(&state, &version); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT current_snapshot_id FROM billing_migration_scope_current_pointers WHERE application_id='app_one' AND billing_customer_id='customer_one'`).Scan(&ios); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT current_snapshot_id FROM billing_migration_scope_current_pointers WHERE application_id='app_two' AND billing_customer_id='customer_one'`).Scan(&android); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT current_snapshot_id FROM billing_migration_scope_current_pointers WHERE application_id='app_one' AND billing_customer_id='customer_two'`).Scan(&mosaicOnly); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM billing_migration_authority_scopes WHERE active_program_id='program_ready' AND current_authority='mosaic' AND current_epoch=1),(SELECT count(*) FROM billing_migration_authority_transitions WHERE program_id='program_ready' AND transition_kind='rollback'),(SELECT count(*) FROM billing_migration_transition_outbox WHERE program_id='program_ready' AND event_kind='rollback_changed'),(SELECT count(*) FROM billing_migration_command_idempotency WHERE program_id='program_ready' AND command_kind='execute_rollback')`).Scan(&authorities, &transitions, &outbox, &idempotency); err != nil { + t.Fatal(err) + } + if state != "stabilizing" || version != 7 || ios != "snapshot_activation" || android != "snapshot_activation" || mosaicOnly != "snapshot_mosaic_only" || authorities != 2 || transitions != 0 || outbox != 0 || idempotency != 0 { + t.Fatalf("partial rollback state=%s/%d pointers=%s,%s,%s authorities=%d transitions=%d outbox=%d idempotency=%d", state, version, ios, android, mosaicOnly, authorities, transitions, outbox, idempotency) + } +} + +func TestRollbackRejectsExecuteTimePrerequisiteDrift(t *testing.T) { + tests := []struct{ name, mutation string }{{"credential", `UPDATE billing_migration_credentials SET status='revoked',revoked_at=$1 WHERE id='credential_ready'`}, {"capability", `INSERT INTO billing_migration_capability_assessments(id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) VALUES('capability_drift','program_ready','project_one',7,'v2',ARRAY['read_customers'],decode(repeat('b1',32),'hex'),$1+interval '1 second')`}, {"source health", `INSERT INTO billing_migration_validation_attempts(id,program_id,project_id,attempt_kind,status,record_count,result_digest,attempted_at) VALUES('source_drift','program_ready','project_one','source_validation','succeeded',1,decode(repeat('b2',32),'hex'),$1+interval '1 second')`}, {"provider health", `INSERT INTO billing_migration_validation_attempts(id,program_id,project_id,attempt_kind,status,record_count,result_digest,attempted_at) VALUES('provider_drift','program_ready','project_one','provider_validation','succeeded',1,decode(repeat('b3',32),'hex'),$1+interval '1 second')`}} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, pool, db, service, input := prepareRollbackExecution(t) + statement := strings.ReplaceAll(test.mutation, "$1", "TIMESTAMPTZ '2026-07-29T12:00:00Z'") + if _, err := db.ExecContext(ctx, statement); err != nil { + t.Fatal(err) + } + if _, _, err := service.ExecuteRollback(ctx, billingmigration.Actor{ID: "owner_one"}, input); !errors.Is(err, billingmigration.ErrRollbackPrerequisite) { + t.Fatalf("rollback prerequisite drift error=%v", err) + } + var state string + if err := pool.QueryRow(ctx, `SELECT state FROM billing_migration_programs WHERE id='program_ready'`).Scan(&state); err != nil || state != "stabilizing" { + t.Fatalf("drift changed state=%s err=%v", state, err) + } + }) + } +} + +func TestConcurrentSameCutoverCommandCommitsOnceAndReplays(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + checkpointID, approvalDigest, _ := seedExecutionCheckpoint(t, ctx, db, now) + service := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return now })) + input := executionCutoverInput(checkpointID, approvalDigest, "concurrent-key") + type outcome struct { + replay bool + err error + } + results := make(chan outcome, 2) + var start sync.WaitGroup + start.Add(1) + for i := 0; i < 2; i++ { + go func() { + start.Wait() + _, replay, err := service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, input) + results <- outcome{replay, err} + }() + } + start.Done() + first, second := <-results, <-results + if first.err != nil || second.err != nil || first.replay == second.replay { + t.Fatalf("concurrent outcomes first=%#v second=%#v", first, second) + } + var transitions, outbox int + if err := pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM billing_migration_authority_transitions WHERE program_id='program_ready'),(SELECT count(*) FROM billing_migration_transition_outbox WHERE program_id='program_ready')`).Scan(&transitions, &outbox); err != nil || transitions != 2 || outbox != 2 { + t.Fatalf("concurrent cardinality transitions=%d outbox=%d err=%v", transitions, outbox, err) + } +} + +func executionCutoverInput(checkpointID, approvalDigest, key string) billingmigration.ExecuteCutoverInput { + return billingmigration.ExecuteCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: key, ExpectedStateVersion: 6, Reason: "Production cutover after final owner review", Scope: billingmigration.Scope{ProjectID: "project_one", EnvironmentID: "environment_one", Applications: []billingmigration.ScopeItem{{ApplicationID: "app_two", Platform: "android"}, {ApplicationID: "app_one", Platform: "ios"}}}, CheckpointID: checkpointID, ApprovalID: "approval_execute", ExpectedAuthorityEpoch: 0, ExpectedDigests: billingmigration.CutoverCommandDigests{Scope: digestByte(0x61), Manifest: digestByte(0x65), Mapping: digestByte(0x66), Policy: digestByte(0x71), Evidence: digestByte(0x67), Readiness: digestByte(0x88), FinalWatermark: digestByte(0x68), ApplicationVersion: digestByte(0x70), Approval: approvalDigest}} +} + +func prepareRollbackExecution(t *testing.T) (context.Context, *pgxpool.Pool, *sql.DB, *billingmigration.Service, billingmigration.ExecuteRollbackInput) { + t.Helper() + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + checkpointID, approvalDigest, checkpointDigest := seedExecutionCheckpoint(t, ctx, db, now) + service := billingmigration.NewService(billingmigrationpostgres.New(pool), nil, nil, billingmigration.WithClock(func() time.Time { return now })) + cutoverResult, _, err := service.ExecuteCutover(ctx, billingmigration.Actor{ID: "owner_one"}, executionCutoverInput(checkpointID, approvalDigest, "prepare-rollback")) + if err != nil { + t.Fatal(err) + } + if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_validation_attempts(id,program_id,project_id,attempt_kind,status,record_count,result_digest,attempted_at) VALUES('validation_source_execute','program_ready','project_one','source_validation','succeeded',1,decode(repeat('93',32),'hex'),$1),('validation_provider_execute','program_ready','project_one','provider_validation','succeeded',1,decode(repeat('94',32),'hex'),$1)`, now); err != nil { + t.Fatal(err) + } + if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_scope_current_pointers(project_id,environment_id,application_id,platform,billing_customer_id,current_snapshot_id,authority_epoch,updated_at) VALUES('project_one','environment_one','app_one','ios','customer_two','snapshot_mosaic_only',1,$1)`, now); err != nil { + t.Fatal(err) + } + var authorityDigests []string + rows, err := pool.Query(ctx, `SELECT authority_digest FROM billing_migration_authority_scopes WHERE active_program_id='program_ready' ORDER BY application_id,platform`) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var raw []byte + if err = rows.Scan(&raw); err != nil { + t.Fatal(err) + } + authorityDigests = append(authorityDigests, billingmigration.FormatDigest(raw)) + } + rows.Close() + authoritySet, err := billingmigration.AuthoritySetDigest("program_ready", digestByte(0x61), authorityDigests) + if err != nil { + t.Fatal(err) + } + var transitionID string + var transitionRaw []byte + if err = pool.QueryRow(ctx, `SELECT id,transition_digest FROM billing_migration_authority_transitions WHERE program_id='program_ready' AND transition_kind='cutover' ORDER BY id DESC LIMIT 1`).Scan(&transitionID, &transitionRaw); err != nil { + t.Fatal(err) + } + binding := billingmigration.RollbackProposalBinding{CheckpointID: checkpointID, CheckpointDigest: checkpointDigest, AuthorityDigest: authoritySet, ScopeDigest: digestByte(0x61), CutoverTransitionID: transitionID, CutoverTransitionDigest: billingmigration.FormatDigest(transitionRaw), CutoverEpoch: 1, CutoverTransitionedAt: cutoverResult.ExecutedAt, RollbackDeadline: now.Add(7 * 24 * time.Hour), CredentialID: "credential_ready", CredentialStatus: "active", CapabilityAssessmentID: "capability_ready", CapabilityAssessmentDigest: digestByte(0x72), CapabilityAssessedAt: now.Add(-time.Minute), SourceValidationID: "validation_source_execute", SourceValidationDigest: digestByte(0x93), SourceValidatedAt: now, ProviderValidationID: "validation_provider_execute", ProviderValidationDigest: digestByte(0x94), ProviderValidatedAt: now} + prerequisites, err := billingmigration.RollbackPrerequisitesDigest("program_ready", 7, binding) + if err != nil { + t.Fatal(err) + } + proposal, _, err := service.ProposeRollback(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.ProposeRollbackInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: "prepare-rollback-proposal", CheckpointID: checkpointID, ExpectedStateVersion: 7, ExpectedCheckpointDigest: checkpointDigest, ExpectedAuthorityDigest: authoritySet, ExpectedRollbackPrerequisitesDigest: prerequisites, Reason: "Verified rollback prerequisites and source health", ExpiresAt: now.Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + approval, _, err := service.ApproveCutover(ctx, billingmigration.Actor{ID: "owner_two"}, billingmigration.ApproveCutoverInput{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: proposal.ProposalID, IdempotencyKey: "prepare-rollback-approval", ExpectedStateVersion: 7}) + if err != nil { + t.Fatal(err) + } + input := billingmigration.ExecuteRollbackInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: "execute-rollback-under-test", ExpectedStateVersion: 7, ExpectedDigests: billingmigration.RollbackCommandDigests{Checkpoint: checkpointDigest, Authority: authoritySet, RollbackPrerequisites: prerequisites, Approval: approval.ApprovalDigest}, Reason: "Rollback within approved window after source health verification", Scope: billingmigration.Scope{ProjectID: "project_one", EnvironmentID: "environment_one", Applications: []billingmigration.ScopeItem{{ApplicationID: "app_two", Platform: "android"}, {ApplicationID: "app_one", Platform: "ios"}}}, CheckpointID: checkpointID, ApprovalID: approval.ApprovalID, ExpectedAuthorityEpoch: 1} + return ctx, pool, db, service, input +} + +func executionDatabase(t *testing.T) (context.Context, *pgxpool.Pool, *sql.DB) { + t.Helper() + url := os.Getenv("DATABASE_TEST_URL") + if url == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(url) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err = db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + seedMigrationTenant(t, ctx, db) + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + return ctx, pool, db +} + +func seedExecutionCheckpoint(t *testing.T, ctx context.Context, db *sql.DB, now time.Time) (string, string, string) { + t.Helper() + seedReadyProgram(t, ctx, db, now) + statement := `INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) VALUES('org_one','owner_two','owner',$1,$1); UPDATE billing_migration_programs SET state='cutover_pending',state_version=6 WHERE id='program_ready'; INSERT INTO customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id,snapshot_version,rule_version,computed_at,as_of,checksum,change_reason,created_at) VALUES('snapshot_mosaic_only','project_one','environment_one','customer_two',1,1,$1,$1,decode(repeat('97',32),'hex'),'migration_postcutover',$1); INSERT INTO billing_migration_readiness_assessments(id,program_id,project_id,state_version,ready,current_access_mapping_percent,current_access_evidence_percent,critical_count,blocking_count,warning_count,informational_count,final_delta_completed,watermarks_fresh,supported_versions_authority_aware,readiness_digest,assessed_at,authoritative,source_capabilities_fresh,warning_threshold,application_version_digest) VALUES('readiness_execute','program_ready','project_one',5,true,100,100,0,0,0,0,true,true,true,decode(repeat('88',32),'hex'),$1,true,true,0,decode(repeat('70',32),'hex')); INSERT INTO billing_migration_final_delta_cohort_sets(id,final_delta_id,program_id,project_id,customer_count,cohort_digest,frozen_at) VALUES('cohort_execute','delta_ready','program_ready','project_one',1,decode(repeat('98',32),'hex'),$1); INSERT INTO billing_migration_final_delta_cohort_customers(cohort_set_id,program_id,project_id,billing_customer_id,customer_digest) VALUES('cohort_execute','program_ready','project_one','customer_one',decode(repeat('99',32),'hex')); INSERT INTO billing_migration_cutover_proposals(id,program_id,project_id,state_version,command,proposer_actor_id,reason,scope_digest,manifest_digest,mapping_digest,policy_digest,evidence_digest,readiness_digest,final_watermark_digest,application_version_digest,proposal_digest,status,proposed_at,expires_at) VALUES('proposal_execute','program_ready','project_one',5,'cutover','owner_one','reviewed',decode(repeat('61',32),'hex'),decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('71',32),'hex'),decode(repeat('67',32),'hex'),decode(repeat('88',32),'hex'),decode(repeat('68',32),'hex'),decode(repeat('70',32),'hex'),decode(repeat('86',32),'hex'),'approved',$1,$1+interval '1 hour'); INSERT INTO billing_migration_approvals(id,program_id,project_id,proposal_id,state_version,command,proposer_actor_id,approver_actor_id,approval_digest,approved_at,expires_at) VALUES('approval_execute','program_ready','project_one','proposal_execute',5,'cutover','owner_one','owner_two',decode(repeat('89',32),'hex'),$1,$1+interval '1 hour'); INSERT INTO billing_migration_checkpoints(id,program_id,project_id,state_version,authority_epoch,source_watermark,provider_watermark,shadow_watermark,scope_digest,manifest_digest,mapping_digest,policy_digest,evidence_digest,readiness_digest,final_watermark_digest,application_version_digest,approval_digest,checkpoint_digest,created_at,cohort_digest) VALUES('checkpoint_execute','program_ready','project_one',6,0,$1-interval '1 minute',$1-interval '1 minute',$1-interval '1 minute',decode(repeat('61',32),'hex'),decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('71',32),'hex'),decode(repeat('67',32),'hex'),decode(repeat('88',32),'hex'),decode(repeat('68',32),'hex'),decode(repeat('70',32),'hex'),decode(repeat('89',32),'hex'),decode(repeat('8a',32),'hex'),$1,decode(repeat('98',32),'hex')); INSERT INTO billing_migration_checkpoint_pointer_maps(id,checkpoint_id,program_id,project_id,environment_id,application_id,platform,billing_customer_id,pointer_role,snapshot_id,absent_current,pointer_digest) VALUES('map_ios_activate','checkpoint_execute','program_ready','project_one','environment_one','app_one','ios','customer_one','prepared_activation','snapshot_activation',false,decode(repeat('79',32),'hex')),('map_ios_baseline','checkpoint_execute','program_ready','project_one','environment_one','app_one','ios','customer_one','rollback_baseline','snapshot_baseline',false,decode(repeat('78',32),'hex')),('map_android_activate','checkpoint_execute','program_ready','project_one','environment_one','app_two','android','customer_one','prepared_activation','snapshot_activation',false,decode(repeat('79',32),'hex')),('map_android_baseline','checkpoint_execute','program_ready','project_one','environment_one','app_two','android','customer_one','rollback_baseline',NULL,true,decode(repeat('77',32),'hex'));` + statement = strings.ReplaceAll(statement, "$1", "TIMESTAMPTZ '"+now.Format(time.RFC3339)+"'") + _, err := db.ExecContext(ctx, statement) + if err != nil { + t.Fatal(err) + } + return "checkpoint_execute", digestByte(0x89), digestByte(0x8a) +} +func digestByte(value byte) string { return billingmigration.FormatDigest(bytesOf(value)) } diff --git a/apps/api/internal/platform/billingmigrationpostgres/key_inventory.go b/apps/api/internal/platform/billingmigrationpostgres/key_inventory.go new file mode 100644 index 00000000..b9976390 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/key_inventory.go @@ -0,0 +1,35 @@ +package billingmigrationpostgres + +import ( + "context" + "fmt" +) + +// SourceObjectEnvelopeCountsByKeyID reports the database inventory of retained, +// immutable migration source objects. Deleted objects keep their ledger +// metadata but no longer require ciphertext keys, so they are intentionally +// excluded. This operation never reads object keys, ciphertext, or customer +// data. +func (r *Repository) SourceObjectEnvelopeCountsByKeyID(ctx context.Context) (map[string]int64, error) { + rows, err := r.pool.Query(ctx, `SELECT key_id,count(*) + FROM billing_migration_source_objects + WHERE state='verified' AND key_id IS NOT NULL + GROUP BY key_id`) + if err != nil { + return nil, fmt.Errorf("count migration source-object envelopes: %w", err) + } + defer rows.Close() + counts := make(map[string]int64) + for rows.Next() { + var keyID string + var count int64 + if err := rows.Scan(&keyID, &count); err != nil { + return nil, fmt.Errorf("scan migration source-object envelope count: %w", err) + } + counts[keyID] = count + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read migration source-object envelope counts: %w", err) + } + return counts, nil +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/key_inventory_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/key_inventory_integration_test.go new file mode 100644 index 00000000..9cb080ea --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/key_inventory_integration_test.go @@ -0,0 +1,99 @@ +package billingmigrationpostgres_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +func TestSourceObjectEnvelopeCountsIncludeOnlyRetainedObjects(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err = db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + seedMigrationTenant(t, ctx, db) + now := time.Now().UTC() + statements := []struct { + query string + args []any + }{ + {`INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) + VALUES('credential_inventory','project_one','revenuecat','rc_project','active',1,'AES-256-GCM','credential_key',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),'owner_one',$1)`, []any{now}}, + {`INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) + VALUES('program_inventory','project_one','environment_one','revenuecat',$1,'credential_inventory','mapping',1,0,7,7,decode(repeat('11',32),'hex'),decode(repeat('12',32),'hex'),'program-inventory',decode(repeat('13',32),'hex'),'owner_one',$2,$2)`, []any{billingmigration.AdapterVersion, now}}, + {`INSERT INTO billing_migration_program_scopes(program_id,project_id,environment_id,application_id,platform,created_at) + VALUES('program_inventory','project_one','environment_one','app_one','ios',$1)`, []any{now}}, + } + for _, statement := range statements { + if _, err = db.ExecContext(ctx, statement.query, statement.args...); err != nil { + t.Fatal(err) + } + } + if _, err = db.ExecContext(ctx, `INSERT INTO billing_migration_source_objects( + id,program_id,project_id,reservation_key,reservation_digest,reservation_generation, + write_token_digest,object_key,source_channel,adapter_version,schema_version,state, + envelope_version,algorithm,key_id,nonce,chunk_size,chunk_count,aad_digest,plaintext_digest, + plaintext_size_bytes,ciphertext_digest,ciphertext_size_bytes,reserved_at,verified_at) + VALUES('inventory-retained','program_inventory','project_one','inventory-retained',decode(repeat('11',32),'hex'),1, + decode(repeat('12',32),'hex'),'billing-migrations/project_one/program_source/inventory-retained.bin', + 'revenuecat_api_v2','1','1','verified',1,'AES-256-GCM-CHUNKED','retained-key',decode(repeat('13',12),'hex'), + 16384,1,decode(repeat('14',32),'hex'),decode(repeat('15',32),'hex'),1,decode(repeat('16',32),'hex'),64,$1,$1)`, now); err != nil { + t.Fatal(err) + } + if _, err = db.ExecContext(ctx, `INSERT INTO billing_migration_source_objects( + id,program_id,project_id,reservation_key,reservation_digest,reservation_generation, + write_token_digest,object_key,source_channel,adapter_version,schema_version,state, + envelope_version,algorithm,key_id,nonce,chunk_size,chunk_count,aad_digest,plaintext_digest, + plaintext_size_bytes,ciphertext_digest,ciphertext_size_bytes,reserved_at,verified_at, + deleted_at,deletion_actor_id,deletion_digest) + VALUES('inventory-deleted','program_inventory','project_one','inventory-deleted',decode(repeat('21',32),'hex'),1, + decode(repeat('22',32),'hex'),'billing-migrations/project_one/program_source/inventory-deleted.bin', + 'revenuecat_api_v2','1','1','deleted',1,'AES-256-GCM-CHUNKED','deleted-key',decode(repeat('23',12),'hex'), + 16384,1,decode(repeat('24',32),'hex'),decode(repeat('25',32),'hex'),1,decode(repeat('26',32),'hex'),64,$1,$1, + $1,'retention:test',decode(repeat('27',32),'hex'))`, now); err != nil { + t.Fatal(err) + } + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + counts, err := billingmigrationpostgres.New(pool).SourceObjectEnvelopeCountsByKeyID(ctx) + if err != nil { + t.Fatal(err) + } + if counts["retained-key"] != 1 { + t.Fatalf("retained-key count = %d, want 1", counts["retained-key"]) + } + if _, included := counts["deleted-key"]; included { + t.Fatal("deleted source-object metadata still requires an encryption key") + } +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/operational_reads.go b/apps/api/internal/platform/billingmigrationpostgres/operational_reads.go new file mode 100644 index 00000000..0c45d621 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/operational_reads.go @@ -0,0 +1,34 @@ +package billingmigrationpostgres + +import ( + "encoding/base64" + "encoding/json" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +type operationalCursor struct { + At time.Time `json:"at"` + ID string `json:"id"` +} + +func decodeOperationalCursor(value string) (operationalCursor, error) { + if value == "" { + return operationalCursor{}, nil + } + raw, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return operationalCursor{}, billingmigration.ErrInvalid + } + var cursor operationalCursor + if json.Unmarshal(raw, &cursor) != nil || cursor.At.IsZero() || cursor.ID == "" { + return operationalCursor{}, billingmigration.ErrInvalid + } + return cursor, nil +} + +func encodeOperationalCursor(at time.Time, id string) string { + raw, _ := json.Marshal(operationalCursor{At: at, ID: id}) + return base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/operational_reads_ab.go b/apps/api/internal/platform/billingmigrationpostgres/operational_reads_ab.go new file mode 100644 index 00000000..98294a6a --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/operational_reads_ab.go @@ -0,0 +1,256 @@ +package billingmigrationpostgres + +import ( + "context" + "errors" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/jackc/pgx/v5" +) + +func cursorClause(cursorValue string, atColumn, idColumn string, args *[]any) (string, error) { + cursor, err := decodeOperationalCursor(cursorValue) + if err != nil { + return "", err + } + if cursor.At.IsZero() { + return "", nil + } + start := len(*args) + 1 + *args = append(*args, cursor.At, cursor.ID) + return " AND (" + atColumn + "," + idColumn + ")<($" + itoa(start) + ",$" + itoa(start+1) + ")", nil +} +func itoa(value int) string { + const digits = "0123456789" + if value < 10 { + return string(digits[value]) + } + return string(digits[value/10]) + string(digits[value%10]) +} +func pageCursor(at time.Time, id string) string { + return encodeOperationalCursor(at, id) +} + +func (r *Repository) ListSourcePullJobs(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.SourcePullJobPage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "j.created_at", "j.id", &args) + if err != nil { + return billingmigration.SourcePullJobPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, `SELECT j.id,j.project_id,j.program_id,j.intent,j.status,j.starting_cursor,j.starting_watermark,coalesce(j.predecessor_pull_job_id,''),coalesce(j.result_source_object_id,''),coalesce(j.result_manifest_id,''),coalesce(j.result_import_batch_id,''),coalesce(j.result_final_delta_job_id,''),coalesce(i.status,''),coalesce(j.last_error_code,''),j.expected_program_state_version,j.attempt_count,j.max_attempts,j.created_at,j.updated_at,j.started_at,j.completed_at,j.failed_at FROM billing_migration_source_pull_jobs j LEFT JOIN billing_migration_import_batches i ON i.id=j.result_import_batch_id WHERE j.project_id=$1 AND j.program_id=$2`+clause+` ORDER BY j.created_at DESC,j.id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.SourcePullJobPage{}, err + } + defer rows.Close() + page := billingmigration.SourcePullJobPage{Items: make([]billingmigration.SourcePullJob, 0, limit)} + for rows.Next() { + var j billingmigration.SourcePullJob + var started, completed, failed *time.Time + if err = rows.Scan(&j.ID, &j.ProjectID, &j.ProgramID, &j.Intent, &j.Status, &j.StartingCursor, &j.StartingWatermark, &j.PredecessorPullJobID, &j.ResultSourceObjectID, &j.ResultManifestID, &j.ResultImportBatchID, &j.ResultFinalDeltaJobID, &j.ResultImportStatus, &j.FailureCode, &j.ExpectedStateVersion, &j.AttemptCount, &j.MaxAttempts, &j.CreatedAt, &j.UpdatedAt, &started, &completed, &failed); err != nil { + return page, err + } + if started != nil { + j.StartedAt = *started + } + if completed != nil { + j.CompletedAt = *completed + } + if failed != nil { + j.FailedAt = *failed + } + page.Items = append(page.Items, j) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + last := page.Items[limit-1] + page.NextCursor = pageCursor(last.CreatedAt, last.ID) + } + return page, nil +} +func (r *Repository) SourcePullJob(ctx context.Context, projectID, programID, id string) (billingmigration.SourcePullJob, error) { + page, err := r.ListSourcePullJobs(ctx, projectID, programID, "", 100) + if err != nil { + return billingmigration.SourcePullJob{}, err + } + for _, j := range page.Items { + if j.ID == id { + return j, nil + } + } + return billingmigration.SourcePullJob{}, billingmigration.ErrNotFound +} + +func (r *Repository) ListProposals(ctx context.Context, p, program, cursor string, limit int) (billingmigration.ProposalPage, error) { + args := []any{p, program} + clause, e := cursorClause(cursor, "proposed_at", "id", &args) + if e != nil { + return billingmigration.ProposalPage{}, e + } + args = append(args, limit+1) + rows, e := r.pool.Query(ctx, `SELECT id,proposed_at FROM billing_migration_cutover_proposals WHERE project_id=$1 AND program_id=$2`+clause+` ORDER BY proposed_at DESC,id DESC LIMIT $`+itoa(len(args)), args...) + if e != nil { + return billingmigration.ProposalPage{}, e + } + defer rows.Close() + type key struct { + id string + at time.Time + } + var keys []key + for rows.Next() { + var k key + if e = rows.Scan(&k.id, &k.at); e != nil { + return billingmigration.ProposalPage{}, e + } + keys = append(keys, k) + } + page := billingmigration.ProposalPage{} + for _, k := range keys { + item, x := r.Proposal(ctx, p, program, k.id) + if x != nil { + return page, x + } + page.Items = append(page.Items, item) + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + k := keys[limit-1] + page.NextCursor = pageCursor(k.at, k.id) + } + return page, rows.Err() +} +func (r *Repository) ReadProposal(ctx context.Context, p, program, id string) (billingmigration.CutoverProposal, error) { + return r.Proposal(ctx, p, program, id) +} + +const approvalReadSelect = `SELECT id,program_id,state_version,command,proposer_actor_id,approver_actor_id,approval_digest,approved_at,expires_at FROM billing_migration_approvals` + +func scanApproval(row pgx.Row) (billingmigration.MigrationApproval, error) { + var a billingmigration.MigrationApproval + var d []byte + e := row.Scan(&a.ApprovalID, &a.ProgramID, &a.StateVersion, &a.Command, &a.ProposerActorID, &a.ApproverActorID, &d, &a.ApprovedAt, &a.ExpiresAt) + if errors.Is(e, pgx.ErrNoRows) { + e = billingmigration.ErrNotFound + } + a.ApprovalDigest = billingmigration.FormatDigest(d) + return a, e +} +func (r *Repository) ListApprovals(ctx context.Context, p, program, cursor string, limit int) (billingmigration.ApprovalPage, error) { + args := []any{p, program} + clause, e := cursorClause(cursor, "approved_at", "id", &args) + if e != nil { + return billingmigration.ApprovalPage{}, e + } + args = append(args, limit+1) + rows, e := r.pool.Query(ctx, approvalReadSelect+` WHERE project_id=$1 AND program_id=$2`+clause+` ORDER BY approved_at DESC,id DESC LIMIT $`+itoa(len(args)), args...) + if e != nil { + return billingmigration.ApprovalPage{}, e + } + defer rows.Close() + page := billingmigration.ApprovalPage{} + for rows.Next() { + a, x := scanApproval(rows) + if x != nil { + return page, x + } + page.Items = append(page.Items, a) + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + a := page.Items[limit-1] + page.NextCursor = pageCursor(a.ApprovedAt, a.ApprovalID) + } + return page, rows.Err() +} +func (r *Repository) Approval(ctx context.Context, p, program, id string) (billingmigration.MigrationApproval, error) { + return scanApproval(r.pool.QueryRow(ctx, approvalReadSelect+` WHERE project_id=$1 AND program_id=$2 AND id=$3`, p, program, id)) +} + +func (r *Repository) ListCheckpoints(ctx context.Context, p, program, cursor string, limit int) (billingmigration.CheckpointPage, error) { + args := []any{p, program} + clause, e := cursorClause(cursor, "c.created_at", "c.id", &args) + if e != nil { + return billingmigration.CheckpointPage{}, e + } + args = append(args, limit+1) + rows, e := r.pool.Query(ctx, checkpointSelect+` WHERE c.project_id=$1 AND c.program_id=$2`+clause+` ORDER BY c.created_at DESC,c.id DESC LIMIT $`+itoa(len(args)), args...) + if e != nil { + return billingmigration.CheckpointPage{}, e + } + defer rows.Close() + page := billingmigration.CheckpointPage{} + for rows.Next() { + var c billingmigration.MigrationCheckpoint + if e = scanCheckpoint(rows, &c); e != nil { + return page, e + } + page.Items = append(page.Items, c) + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + c := page.Items[limit-1] + page.NextCursor = pageCursor(c.CreatedAt, c.CheckpointID) + } + return page, rows.Err() +} +func (r *Repository) Checkpoint(ctx context.Context, p, program, id string) (billingmigration.MigrationCheckpoint, error) { + var c billingmigration.MigrationCheckpoint + e := scanCheckpoint(r.pool.QueryRow(ctx, checkpointSelect+` WHERE c.project_id=$1 AND c.program_id=$2 AND c.id=$3`, p, program, id), &c) + if errors.Is(e, pgx.ErrNoRows) { + e = billingmigration.ErrNotFound + } + return c, e +} +func (r *Repository) LatestCheckpoint(ctx context.Context, p, program string) (billingmigration.MigrationCheckpoint, error) { + var c billingmigration.MigrationCheckpoint + e := scanCheckpoint(r.pool.QueryRow(ctx, checkpointSelect+` WHERE c.project_id=$1 AND c.program_id=$2 ORDER BY c.created_at DESC,c.id DESC LIMIT 1`, p, program), &c) + if errors.Is(e, pgx.ErrNoRows) { + e = billingmigration.ErrNotFound + } + return c, e +} + +func (r *Repository) ListAuthorityExecutions(ctx context.Context, p, program, cursor string, limit int) (billingmigration.AuthorityExecutionPage, error) { + args := []any{p, program} + clause, e := cursorClause(cursor, "created_at", "resource_id", &args) + if e != nil { + return billingmigration.AuthorityExecutionPage{}, e + } + args = append(args, limit+1) + rows, e := r.pool.Query(ctx, `SELECT resource_id,replace(command_kind,'execute_',''),created_at FROM billing_migration_command_idempotency WHERE project_id=$1 AND program_id=$2 AND command_kind IN('execute_cutover','execute_rollback')`+clause+` ORDER BY created_at DESC,resource_id DESC LIMIT $`+itoa(len(args)), args...) + if e != nil { + return billingmigration.AuthorityExecutionPage{}, e + } + defer rows.Close() + page := billingmigration.AuthorityExecutionPage{} + for rows.Next() { + var a billingmigration.AuthorityExecution + if e = rows.Scan(&a.ExecutionID, &a.Command, &a.ExecutedAt); e != nil { + return page, e + } + a.ProgramID = program + a.State = "executed" + page.Items = append(page.Items, a) + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + a := page.Items[limit-1] + page.NextCursor = pageCursor(a.ExecutedAt, a.ExecutionID) + } + return page, rows.Err() +} +func (r *Repository) AuthorityExecution(ctx context.Context, p, program, id string) (billingmigration.AuthorityExecution, error) { + var a billingmigration.AuthorityExecution + e := r.pool.QueryRow(ctx, `SELECT resource_id,replace(command_kind,'execute_',''),created_at FROM billing_migration_command_idempotency WHERE project_id=$1 AND program_id=$2 AND resource_id=$3 AND command_kind IN('execute_cutover','execute_rollback')`, p, program, id).Scan(&a.ExecutionID, &a.Command, &a.ExecutedAt) + if errors.Is(e, pgx.ErrNoRows) { + e = billingmigration.ErrNotFound + } + a.ProgramID = program + a.State = "executed" + return a, e +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/operational_reads_cde.go b/apps/api/internal/platform/billingmigrationpostgres/operational_reads_cde.go new file mode 100644 index 00000000..26ed15b2 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/operational_reads_cde.go @@ -0,0 +1,575 @@ +package billingmigrationpostgres + +import ( + "context" + "errors" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/jackc/pgx/v5" +) + +func (r *Repository) ListCases(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.CasePage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "opened_at", "id", &args) + if err != nil { + return billingmigration.CasePage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, `SELECT id,opened_at FROM billing_migration_cases WHERE project_id=$1 AND program_id=$2`+clause+` ORDER BY opened_at DESC,id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.CasePage{}, err + } + defer rows.Close() + type caseKey struct { + id string + at time.Time + } + var keys []caseKey + for rows.Next() { + var key caseKey + if err = rows.Scan(&key.id, &key.at); err != nil { + return billingmigration.CasePage{}, err + } + keys = append(keys, key) + } + if err = rows.Err(); err != nil { + return billingmigration.CasePage{}, err + } + page := billingmigration.CasePage{Items: make([]billingmigration.MigrationCase, 0, limit)} + for _, key := range keys { + item, readErr := caseByID(ctx, r.pool, projectID, programID, key.id) + if readErr != nil { + return page, readErr + } + page.Items = append(page.Items, item) + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + key := keys[limit-1] + page.NextCursor = pageCursor(key.at, key.id) + } + return page, nil +} +func (r *Repository) ReadCase(ctx context.Context, projectID, programID, id string) (billingmigration.MigrationCase, error) { + return caseByID(ctx, r.pool, projectID, programID, id) +} + +const caseActionReadSelect = `SELECT id,case_id,program_id,actor_id,action,before_digest,after_digest,created_at FROM billing_migration_case_actions` + +func scanCaseAction(row pgx.Row) (billingmigration.CaseAction, error) { + var item billingmigration.CaseAction + var before, after []byte + err := row.Scan(&item.ID, &item.CaseID, &item.ProgramID, &item.ActorID, &item.Action, &before, &after, &item.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return item, billingmigration.ErrNotFound + } + item.BeforeDigest, item.AfterDigest = billingmigration.FormatDigest(before), billingmigration.FormatDigest(after) + return item, err +} +func (r *Repository) ListCaseActions(ctx context.Context, projectID, programID, caseID, cursor string, limit int) (billingmigration.CaseActionPage, error) { + args := []any{projectID, programID, caseID} + clause, err := cursorClause(cursor, "created_at", "id", &args) + if err != nil { + return billingmigration.CaseActionPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, caseActionReadSelect+` WHERE project_id=$1 AND program_id=$2 AND case_id=$3`+clause+` ORDER BY created_at DESC,id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.CaseActionPage{}, err + } + defer rows.Close() + page := billingmigration.CaseActionPage{Items: make([]billingmigration.CaseAction, 0, limit)} + for rows.Next() { + item, scanErr := scanCaseAction(rows) + if scanErr != nil { + return page, scanErr + } + page.Items = append(page.Items, item) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + item := page.Items[limit-1] + page.NextCursor = pageCursor(item.CreatedAt, item.ID) + } + return page, nil +} + +func scanRepairPreviewRecord(row pgx.Row) (billingmigration.RepairPreviewRecord, error) { + var out billingmigration.RepairPreviewRecord + var before, after, preview, caseDigest, policy, scope []byte + err := row.Scan(&out.PreviewID, &out.CaseID, &out.ProgramID, &out.ProjectID, &out.RepairKind, &out.ScopeKind, &out.Reason, &out.ScopeReferences, &out.AffectedCount, &out.ExpectedStateVersion, &before, &after, &preview, &caseDigest, &policy, &scope, &out.CreatedByActorID, &out.CreatedAt, &out.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + out.BeforeDigest, out.AfterDigest, out.PreviewDigest = billingmigration.FormatDigest(before), billingmigration.FormatDigest(after), billingmigration.FormatDigest(preview) + out.CaseDigest, out.PolicyDigest, out.ScopeDigest = billingmigration.FormatDigest(caseDigest), billingmigration.FormatDigest(policy), billingmigration.FormatDigest(scope) + return out, err +} + +const repairPreviewReadSelect = `SELECT id,case_id,program_id,project_id,repair_kind,scope_kind,reason,scope_references,affected_count,expected_program_state_version,before_digest,after_digest,preview_digest,expected_case_digest,expected_policy_digest,expected_scope_digest,created_by_actor_id,created_at,expires_at FROM billing_migration_repair_previews` + +func (r *Repository) ListRepairPreviews(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.RepairPreviewPage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "created_at", "id", &args) + if err != nil { + return billingmigration.RepairPreviewPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, repairPreviewReadSelect+` WHERE project_id=$1 AND program_id=$2`+clause+` ORDER BY created_at DESC,id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.RepairPreviewPage{}, err + } + defer rows.Close() + page := billingmigration.RepairPreviewPage{Items: make([]billingmigration.RepairPreviewRecord, 0, limit)} + for rows.Next() { + item, e := scanRepairPreviewRecord(rows) + if e != nil { + return page, e + } + page.Items = append(page.Items, item) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + item := page.Items[limit-1] + page.NextCursor = pageCursor(item.CreatedAt, item.PreviewID) + } + return page, nil +} +func (r *Repository) ReadRepairPreview(ctx context.Context, projectID, programID, id string) (billingmigration.RepairPreviewRecord, error) { + return scanRepairPreviewRecord(r.pool.QueryRow(ctx, repairPreviewReadSelect+` WHERE project_id=$1 AND program_id=$2 AND id=$3`, projectID, programID, id)) +} + +const repairExecutionReadSelect = `SELECT r.id,r.preview_id,r.program_id,CASE WHEN e.id IS NULL THEN 'pending' ELSE 'completed' END,COALESCE(e.result,''),COALESCE(e.error_code,''),p.before_digest,e.actual_after_digest,e.result_digest,r.attempt_number,r.actor_id,r.reserved_at,e.executed_at FROM billing_migration_repair_reservations r JOIN billing_migration_repair_previews p ON p.id=r.preview_id AND p.project_id=r.project_id AND p.program_id=r.program_id LEFT JOIN billing_migration_repair_executions e ON e.id=r.id AND e.project_id=r.project_id AND e.program_id=r.program_id` + +func scanRepairExecutionRecord(row pgx.Row) (billingmigration.RepairExecutionRecord, error) { + var out billingmigration.RepairExecutionRecord + var before, after, result []byte + err := row.Scan(&out.ExecutionID, &out.PreviewID, &out.ProgramID, &out.ExecutionStatus, &out.Result, &out.ErrorCode, &before, &after, &result, &out.AttemptNumber, &out.ExecutedByActorID, &out.ReservedAt, &out.ExecutedAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + out.BeforeDigest = billingmigration.FormatDigest(before) + if len(after) == 32 { + out.AfterDigest = billingmigration.FormatDigest(after) + } + if len(result) == 32 { + out.ResultDigest = billingmigration.FormatDigest(result) + } + return out, err +} +func (r *Repository) ListRepairExecutions(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.RepairExecutionPage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "r.reserved_at", "r.id", &args) + if err != nil { + return billingmigration.RepairExecutionPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, repairExecutionReadSelect+` WHERE r.project_id=$1 AND r.program_id=$2`+clause+` ORDER BY r.reserved_at DESC,r.id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.RepairExecutionPage{}, err + } + defer rows.Close() + page := billingmigration.RepairExecutionPage{Items: make([]billingmigration.RepairExecutionRecord, 0, limit)} + for rows.Next() { + item, e := scanRepairExecutionRecord(rows) + if e != nil { + return page, e + } + page.Items = append(page.Items, item) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + item := page.Items[limit-1] + page.NextCursor = pageCursor(item.ReservedAt, item.ExecutionID) + } + return page, nil +} +func (r *Repository) ReadRepairExecution(ctx context.Context, projectID, programID, id string) (billingmigration.RepairExecutionRecord, error) { + return scanRepairExecutionRecord(r.pool.QueryRow(ctx, repairExecutionReadSelect+` WHERE r.project_id=$1 AND r.program_id=$2 AND r.id=$3`, projectID, programID, id)) +} + +const redeliveryReadSelect = `SELECT id,program_id,webhook_event_id,webhook_destination_id,webhook_delivery_id,reason,actor_id,expected_state_version,expected_event_digest,created_at FROM billing_migration_webhook_redeliveries` + +func scanRedeliveryRecord(row pgx.Row) (billingmigration.RedeliveryRecord, error) { + var out billingmigration.RedeliveryRecord + var digest []byte + err := row.Scan(&out.RedeliveryID, &out.ProgramID, &out.EventID, &out.DestinationID, &out.DeliveryID, &out.Reason, &out.ActorID, &out.ExpectedStateVersion, &digest, &out.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + out.ExpectedEventDigest = billingmigration.FormatDigest(digest) + return out, err +} +func (r *Repository) ListRedeliveries(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.RedeliveryPage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "created_at", "id", &args) + if err != nil { + return billingmigration.RedeliveryPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, redeliveryReadSelect+` WHERE project_id=$1 AND program_id=$2`+clause+` ORDER BY created_at DESC,id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.RedeliveryPage{}, err + } + defer rows.Close() + page := billingmigration.RedeliveryPage{Items: make([]billingmigration.RedeliveryRecord, 0, limit)} + for rows.Next() { + item, e := scanRedeliveryRecord(rows) + if e != nil { + return page, e + } + page.Items = append(page.Items, item) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + item := page.Items[limit-1] + page.NextCursor = pageCursor(item.CreatedAt, item.RedeliveryID) + } + return page, nil +} +func (r *Repository) ReadRedelivery(ctx context.Context, projectID, programID, id string) (billingmigration.RedeliveryRecord, error) { + return scanRedeliveryRecord(r.pool.QueryRow(ctx, redeliveryReadSelect+` WHERE project_id=$1 AND program_id=$2 AND id=$3`, projectID, programID, id)) +} + +const credentialRemovalReadSelect = `SELECT id,program_id,credential_id,reason,actor_id,removal_digest,expected_state_version,early_removal,removed_at FROM billing_migration_credential_removals` + +func scanCredentialRemovalRecord(row pgx.Row) (billingmigration.CredentialRemovalRecord, error) { + var out billingmigration.CredentialRemovalRecord + var digest []byte + err := row.Scan(&out.RemovalID, &out.ProgramID, &out.CredentialID, &out.Reason, &out.ActorID, &digest, &out.StateVersion, &out.Early, &out.RemovedAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + out.RemovalDigest = billingmigration.FormatDigest(digest) + return out, err +} +func (r *Repository) ListCredentialRemovals(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.CredentialRemovalPage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "removed_at", "id", &args) + if err != nil { + return billingmigration.CredentialRemovalPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, credentialRemovalReadSelect+` WHERE project_id=$1 AND program_id=$2`+clause+` ORDER BY removed_at DESC,id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.CredentialRemovalPage{}, err + } + defer rows.Close() + page := billingmigration.CredentialRemovalPage{Items: make([]billingmigration.CredentialRemovalRecord, 0, limit)} + for rows.Next() { + item, e := scanCredentialRemovalRecord(rows) + if e != nil { + return page, e + } + page.Items = append(page.Items, item) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + item := page.Items[limit-1] + page.NextCursor = pageCursor(item.RemovedAt, item.RemovalID) + } + return page, nil +} +func (r *Repository) ReadCredentialRemoval(ctx context.Context, projectID, programID, id string) (billingmigration.CredentialRemovalRecord, error) { + return scanCredentialRemovalRecord(r.pool.QueryRow(ctx, credentialRemovalReadSelect+` WHERE project_id=$1 AND program_id=$2 AND id=$3`, projectID, programID, id)) +} +func (r *Repository) CurrentCredentialRemoval(ctx context.Context, projectID, programID string) (billingmigration.CredentialRemovalRecord, error) { + return scanCredentialRemovalRecord(r.pool.QueryRow(ctx, credentialRemovalReadSelect+` WHERE project_id=$1 AND program_id=$2 ORDER BY removed_at DESC,id DESC LIMIT 1`, projectID, programID)) +} + +const legalHoldProposalReadSelect = `SELECT id,program_id,command,reason,external_compliance_reference,proposer_actor_id,expected_previous_command_digest,proposal_digest,status,proposed_at,expires_at FROM billing_migration_legal_hold_proposals` + +func scanLegalHoldProposalRecord(row pgx.Row) (billingmigration.LegalHoldProposalRecord, error) { + var out billingmigration.LegalHoldProposalRecord + var previous, digest []byte + err := row.Scan(&out.ProposalID, &out.ProgramID, &out.Command, &out.Reason, &out.ExternalComplianceReference, &out.ProposerActorID, &previous, &digest, &out.Status, &out.ProposedAt, &out.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + if len(previous) > 0 { + out.ExpectedPreviousCommandDigest = billingmigration.FormatDigest(previous) + } + out.ProposalDigest = billingmigration.FormatDigest(digest) + return out, err +} +func (r *Repository) ListLegalHoldProposals(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.LegalHoldProposalPage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "proposed_at", "id", &args) + if err != nil { + return billingmigration.LegalHoldProposalPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, legalHoldProposalReadSelect+` WHERE project_id=$1 AND program_id=$2`+clause+` ORDER BY proposed_at DESC,id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.LegalHoldProposalPage{}, err + } + defer rows.Close() + page := billingmigration.LegalHoldProposalPage{Items: make([]billingmigration.LegalHoldProposalRecord, 0, limit)} + for rows.Next() { + item, e := scanLegalHoldProposalRecord(rows) + if e != nil { + return page, e + } + page.Items = append(page.Items, item) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + item := page.Items[limit-1] + page.NextCursor = pageCursor(item.ProposedAt, item.ProposalID) + } + return page, nil +} +func (r *Repository) ReadLegalHoldProposal(ctx context.Context, projectID, programID, id string) (billingmigration.LegalHoldProposalRecord, error) { + return scanLegalHoldProposalRecord(r.pool.QueryRow(ctx, legalHoldProposalReadSelect+` WHERE project_id=$1 AND program_id=$2 AND id=$3`, projectID, programID, id)) +} + +const legalHoldReadSelect = `SELECT id,proposal_id,program_id,command,reason,external_compliance_reference,proposer_actor_id,approver_actor_id,COALESCE(previous_command_id,''),command_digest,production,commanded_at FROM billing_migration_legal_hold_commands` + +func scanLegalHoldRecord(row pgx.Row) (billingmigration.LegalHoldRecord, error) { + var out billingmigration.LegalHoldRecord + var digest []byte + err := row.Scan(&out.HoldID, &out.ProposalID, &out.ProgramID, &out.Command, &out.Reason, &out.ExternalComplianceReference, &out.ProposerActorID, &out.ApproverActorID, &out.PreviousCommandID, &digest, &out.Production, &out.CommandedAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + out.CommandDigest = billingmigration.FormatDigest(digest) + return out, err +} +func (r *Repository) ListLegalHolds(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.LegalHoldPage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "commanded_at", "id", &args) + if err != nil { + return billingmigration.LegalHoldPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, legalHoldReadSelect+` WHERE project_id=$1 AND program_id=$2`+clause+` ORDER BY commanded_at DESC,id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.LegalHoldPage{}, err + } + defer rows.Close() + page := billingmigration.LegalHoldPage{Items: make([]billingmigration.LegalHoldRecord, 0, limit)} + for rows.Next() { + item, e := scanLegalHoldRecord(rows) + if e != nil { + return page, e + } + page.Items = append(page.Items, item) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + item := page.Items[limit-1] + page.NextCursor = pageCursor(item.CommandedAt, item.HoldID) + } + return page, nil +} +func (r *Repository) ReadLegalHold(ctx context.Context, projectID, programID, id string) (billingmigration.LegalHoldRecord, error) { + return scanLegalHoldRecord(r.pool.QueryRow(ctx, legalHoldReadSelect+` WHERE project_id=$1 AND program_id=$2 AND id=$3`, projectID, programID, id)) +} +func (r *Repository) CurrentLegalHold(ctx context.Context, projectID, programID string) (billingmigration.LegalHoldRecord, error) { + return scanLegalHoldRecord(r.pool.QueryRow(ctx, legalHoldReadSelect+` WHERE project_id=$1 AND program_id=$2 ORDER BY commanded_at DESC,id DESC LIMIT 1`, projectID, programID)) +} + +const completionReportReadSelect = `SELECT r.id,r.program_id,r.project_id,r.state_version,r.completed_at,r.stabilization_ended_at,r.rollback_window_ended_at,r.credential_removed_at,r.legal_hold,r.source_objects_delete_at,r.completion_digest,r.authority_digest,r.stability_evidence_digest,r.completion_policy_digest,COALESCE(a.actor_id,'') FROM billing_migration_completion_reports r LEFT JOIN audit_events a ON a.id='aud_'||r.id` + +func scanCompletionReportRecord(row pgx.Row) (billingmigration.CompletionReportRecord, error) { + var out billingmigration.CompletionReportRecord + var completion, authority, stability, policy []byte + err := row.Scan(&out.ReportID, &out.ProgramID, &out.ProjectID, &out.StateVersion, &out.CompletedAt, &out.StabilizationEndedAt, &out.RollbackWindowEndedAt, &out.CredentialRemovedAt, &out.LegalHold, &out.SourceObjectsDeleteAt, &completion, &authority, &stability, &policy, &out.CompletedByActorID) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + out.CompletionDigest, out.AuthorityDigest, out.StabilityEvidenceDigest, out.PolicyDigest = billingmigration.FormatDigest(completion), billingmigration.FormatDigest(authority), billingmigration.FormatDigest(stability), billingmigration.FormatDigest(policy) + return out, err +} +func (r *Repository) ListCompletionReports(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.CompletionReportPage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "r.completed_at", "r.id", &args) + if err != nil { + return billingmigration.CompletionReportPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, completionReportReadSelect+` WHERE r.project_id=$1 AND r.program_id=$2`+clause+` ORDER BY r.completed_at DESC,r.id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.CompletionReportPage{}, err + } + defer rows.Close() + page := billingmigration.CompletionReportPage{Items: make([]billingmigration.CompletionReportRecord, 0, limit)} + for rows.Next() { + item, e := scanCompletionReportRecord(rows) + if e != nil { + return page, e + } + page.Items = append(page.Items, item) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + item := page.Items[limit-1] + page.NextCursor = pageCursor(item.CompletedAt, item.ReportID) + } + return page, nil +} +func (r *Repository) ReadCompletionReport(ctx context.Context, projectID, programID, id string) (billingmigration.CompletionReportRecord, error) { + return scanCompletionReportRecord(r.pool.QueryRow(ctx, completionReportReadSelect+` WHERE r.project_id=$1 AND r.program_id=$2 AND r.id=$3`, projectID, programID, id)) +} + +const stabilizationPolicyReadSelect = `SELECT id,program_id,project_id,state_version,authority_mismatch_max,access_api_error_max,sdk_sync_failure_max,divergence_max,validation_backlog_max,source_delta_lag_max_seconds,webhook_failure_max,webhook_freshness_max_seconds,quarantine_max,support_case_max,old_app_version_max,worker_unhealthy_max,policy_digest,frozen_by_actor_id,frozen_at FROM billing_migration_stabilization_policies` + +func scanStabilizationPolicyRecord(row pgx.Row) (billingmigration.StabilizationPolicyRecord, error) { + var out billingmigration.StabilizationPolicyRecord + var digest []byte + t := &out.Thresholds + err := row.Scan(&out.ID, &out.ProgramID, &out.ProjectID, &out.StateVersion, &t.AuthorityMismatchMax, &t.AccessAPIErrorMax, &t.SDKSyncFailureMax, &t.DivergenceMax, &t.ValidationBacklogMax, &t.SourceDeltaLagMaxSeconds, &t.WebhookFailureMax, &t.WebhookFreshnessMaxSeconds, &t.QuarantineMax, &t.SupportCaseMax, &t.OldAppVersionMax, &t.WorkerUnhealthyMax, &digest, &out.FrozenByActorID, &out.FrozenAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + out.PolicyDigest = billingmigration.FormatDigest(digest) + return out, err +} +func (r *Repository) CurrentStabilizationPolicy(ctx context.Context, projectID, programID string) (billingmigration.StabilizationPolicyRecord, error) { + return scanStabilizationPolicyRecord(r.pool.QueryRow(ctx, stabilizationPolicyReadSelect+` WHERE project_id=$1 AND program_id=$2 ORDER BY frozen_at DESC,id DESC LIMIT 1`, projectID, programID)) +} + +const stabilizationObservationReadSelect = `SELECT o.id,o.program_id,o.project_id,o.policy_id,p.policy_digest,o.state_version,o.authority_epoch,o.authority_mismatches,o.access_api_errors,o.sdk_sync_failures,o.divergences,o.validation_backlog,o.source_delta_lag_seconds,o.webhook_failures,o.webhook_age_seconds,o.quarantined_records,o.support_cases,o.old_app_versions,o.unhealthy_workers,o.source_watermark,o.webhook_last_success_at,o.breach_codes,o.healthy,o.evidence_digest,o.observed_at FROM billing_migration_stabilization_observations o JOIN billing_migration_stabilization_policies p ON p.id=o.policy_id AND p.project_id=o.project_id AND p.program_id=o.program_id` + +func scanStabilizationObservationRecord(row pgx.Row) (billingmigration.StabilizationObservationRecord, error) { + var out billingmigration.StabilizationObservationRecord + var policy, evidence []byte + m := &out.Metrics + err := row.Scan(&out.ID, &out.ProgramID, &out.ProjectID, &out.PolicyID, &policy, &out.StateVersion, &out.AuthorityEpoch, &m.AuthorityMismatches, &m.AccessAPIErrors, &m.SDKSyncFailures, &m.Divergences, &m.ValidationBacklog, &m.SourceDeltaLagSeconds, &m.WebhookFailures, &m.WebhookAgeSeconds, &m.QuarantinedRecords, &m.SupportCases, &m.OldAppVersions, &m.UnhealthyWorkers, &out.SourceWatermark, &out.WebhookLastSuccessAt, &out.BreachCodes, &out.Healthy, &evidence, &out.ObservedAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + out.PolicyDigest, out.EvidenceDigest = billingmigration.FormatDigest(policy), billingmigration.FormatDigest(evidence) + return out, err +} +func (r *Repository) ListStabilizationObservations(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.StabilizationObservationPage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "o.observed_at", "o.id", &args) + if err != nil { + return billingmigration.StabilizationObservationPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, stabilizationObservationReadSelect+` WHERE o.project_id=$1 AND o.program_id=$2`+clause+` ORDER BY o.observed_at DESC,o.id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.StabilizationObservationPage{}, err + } + defer rows.Close() + page := billingmigration.StabilizationObservationPage{Items: make([]billingmigration.StabilizationObservationRecord, 0, limit)} + for rows.Next() { + item, e := scanStabilizationObservationRecord(rows) + if e != nil { + return page, e + } + page.Items = append(page.Items, item) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + item := page.Items[limit-1] + page.NextCursor = pageCursor(item.ObservedAt, item.ID) + } + return page, nil +} +func (r *Repository) LatestStabilizationObservation(ctx context.Context, projectID, programID string) (billingmigration.StabilizationObservationRecord, error) { + return scanStabilizationObservationRecord(r.pool.QueryRow(ctx, stabilizationObservationReadSelect+` WHERE o.project_id=$1 AND o.program_id=$2 ORDER BY o.observed_at DESC,o.id DESC LIMIT 1`, projectID, programID)) +} + +const rollbackAssessmentReadSelect = `SELECT id,program_id,project_id,observation_id,latest_delta_id,readiness_digest,state_version,source_support_available,source_healthy,application_compatible,limitations_blocking,ready,customer_impact_count,assessed_at,source_health_digest,source_current_access_digest,latest_delta_digest,customer_impact_digest,application_compatibility_digest,limitation_report_digest,audit_digest,stabilization_healthy,assessed_by_actor_id,source_current_access_at FROM billing_migration_rollback_readiness_assessments` + +func scanRollbackAssessmentRecord(row pgx.Row) (billingmigration.RollbackReadinessAssessmentRecord, error) { + var out billingmigration.RollbackReadinessAssessmentRecord + var readiness, sourceHealth, sourceAccess, delta, impact, compatibility, limitation, audit []byte + err := row.Scan(&out.ID, &out.ProgramID, &out.ProjectID, &out.ObservationID, &out.LatestDeltaID, &readiness, &out.StateVersion, &out.SourceSupportAvailable, &out.SourceHealthy, &out.ApplicationCompatible, &out.LimitationsBlocking, &out.Ready, &out.CustomerImpactCount, &out.AssessedAt, &sourceHealth, &sourceAccess, &delta, &impact, &compatibility, &limitation, &audit, &out.StabilizationHealthy, &out.AssessedByActorID, &out.SourceCurrentAccessAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + out.ReadinessDigest = billingmigration.FormatDigest(readiness) + out.SourceHealthDigest = billingmigration.FormatDigest(sourceHealth) + out.SourceCurrentAccessDigest = billingmigration.FormatDigest(sourceAccess) + out.LatestDeltaDigest = billingmigration.FormatDigest(delta) + out.CustomerImpactDigest = billingmigration.FormatDigest(impact) + out.ApplicationCompatibilityDigest = billingmigration.FormatDigest(compatibility) + out.LimitationReportDigest = billingmigration.FormatDigest(limitation) + out.AuditDigest = billingmigration.FormatDigest(audit) + return out, err +} +func (r *Repository) ListRollbackReadinessAssessments(ctx context.Context, projectID, programID, cursor string, limit int) (billingmigration.RollbackReadinessAssessmentPage, error) { + args := []any{projectID, programID} + clause, err := cursorClause(cursor, "assessed_at", "id", &args) + if err != nil { + return billingmigration.RollbackReadinessAssessmentPage{}, err + } + args = append(args, limit+1) + rows, err := r.pool.Query(ctx, rollbackAssessmentReadSelect+` WHERE project_id=$1 AND program_id=$2`+clause+` ORDER BY assessed_at DESC,id DESC LIMIT $`+itoa(len(args)), args...) + if err != nil { + return billingmigration.RollbackReadinessAssessmentPage{}, err + } + defer rows.Close() + page := billingmigration.RollbackReadinessAssessmentPage{Items: make([]billingmigration.RollbackReadinessAssessmentRecord, 0, limit)} + for rows.Next() { + item, e := scanRollbackAssessmentRecord(rows) + if e != nil { + return page, e + } + page.Items = append(page.Items, item) + } + if err = rows.Err(); err != nil { + return page, err + } + if len(page.Items) > limit { + page.Items = page.Items[:limit] + item := page.Items[limit-1] + page.NextCursor = pageCursor(item.AssessedAt, item.ID) + } + return page, nil +} +func (r *Repository) LatestRollbackReadinessAssessment(ctx context.Context, projectID, programID string) (billingmigration.RollbackReadinessAssessmentRecord, error) { + return scanRollbackAssessmentRecord(r.pool.QueryRow(ctx, rollbackAssessmentReadSelect+` WHERE project_id=$1 AND program_id=$2 ORDER BY assessed_at DESC,id DESC LIMIT 1`, projectID, programID)) +} + +const rollbackCheckpointReadSelect = `SELECT id,program_id,project_id,assessment_id,authority_digest,policy_digest,evidence_digest,readiness_digest,checkpoint_digest,state_version,authority_epoch,created_by_actor_id,created_at FROM billing_migration_rollback_readiness_checkpoints` + +func scanRollbackCheckpointRecord(row pgx.Row) (billingmigration.RollbackReadinessCheckpointRecord, error) { + var out billingmigration.RollbackReadinessCheckpointRecord + var authority, policy, evidence, readiness, checkpoint []byte + err := row.Scan(&out.ID, &out.ProgramID, &out.ProjectID, &out.AssessmentID, &authority, &policy, &evidence, &readiness, &checkpoint, &out.StateVersion, &out.AuthorityEpoch, &out.CreatedByActorID, &out.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, billingmigration.ErrNotFound + } + out.AuthorityDigest = billingmigration.FormatDigest(authority) + out.PolicyDigest = billingmigration.FormatDigest(policy) + out.EvidenceDigest = billingmigration.FormatDigest(evidence) + out.ReadinessDigest = billingmigration.FormatDigest(readiness) + out.CheckpointDigest = billingmigration.FormatDigest(checkpoint) + return out, err +} +func (r *Repository) LatestRollbackReadinessCheckpoint(ctx context.Context, projectID, programID string) (billingmigration.RollbackReadinessCheckpointRecord, error) { + return scanRollbackCheckpointRecord(r.pool.QueryRow(ctx, rollbackCheckpointReadSelect+` WHERE project_id=$1 AND program_id=$2 ORDER BY created_at DESC,id DESC LIMIT 1`, projectID, programID)) +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/operational_reads_test.go b/apps/api/internal/platform/billingmigrationpostgres/operational_reads_test.go new file mode 100644 index 00000000..923780ef --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/operational_reads_test.go @@ -0,0 +1,18 @@ +package billingmigrationpostgres + +import ( + "testing" + "time" +) + +func TestOperationalCursorRoundTripAndRejectsMalformedInput(t *testing.T) { + at := time.Date(2026, 7, 29, 10, 30, 0, 0, time.UTC) + encoded := encodeOperationalCursor(at, "record_one") + decoded, err := decodeOperationalCursor(encoded) + if err != nil || decoded.ID != "record_one" || !decoded.At.Equal(at) { + t.Fatalf("cursor round trip decoded=%+v err=%v", decoded, err) + } + if _, err = decodeOperationalCursor("not-a-cursor"); err == nil { + t.Fatal("malformed cursor was accepted") + } +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/operations_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/operations_integration_test.go new file mode 100644 index 00000000..ea273647 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/operations_integration_test.go @@ -0,0 +1,314 @@ +package billingmigrationpostgres_test + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" +) + +func TestPackageCEnforcesRepairAllowlistAndIrreversibleCredentialRemoval(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + seedReadyProgram(t, ctx, db, now) + if _, err := db.ExecContext(ctx, `INSERT INTO billing_migration_cases(id,program_id,project_id,state_version,classification,status,reason,case_digest,opened_at) VALUES('case_ops','program_ready','project_one',4,'blocking','open','quarantined provider reference',decode(repeat('31',32),'hex'),$1)`, now); err != nil { + t.Fatalf("case insert with backward-compatible updated_at default: %v", err) + } + previewSQL := `INSERT INTO billing_migration_repair_previews(id,case_id,program_id,project_id,repair_kind,scope_kind,scope_references,affected_count,before_digest,after_digest,preview_digest,created_by_actor_id,created_at,expected_program_state_version,expected_case_digest,expected_policy_digest,expected_scope_digest,reason,expires_at) VALUES($1,'case_ops','program_ready','project_one',$2,'provider_reference',ARRAY['reference_one'],1,decode(repeat('32',32),'hex'),decode(repeat('33',32),'hex'),decode(repeat('34',32),'hex'),'owner_one',$3::timestamptz,4,decode(repeat('31',32),'hex'),decode(repeat('71',32),'hex'),decode(repeat('61',32),'hex'),'Revalidate quarantined provider reference',$3::timestamptz+interval '1 hour')` + if _, err := db.ExecContext(ctx, previewSQL, "preview_allowed", billingmigration.RepairRevalidateProviderReference, now); err != nil { + t.Fatalf("allowlisted repair preview: %v", err) + } + if _, err := db.ExecContext(ctx, previewSQL, "preview_unsafe", "arbitrary_sql", now); err == nil { + t.Fatal("database accepted non-allowlisted repair kind") + } + if _, err := db.ExecContext(ctx, `UPDATE billing_migration_programs SET state='ready',state_version=5,updated_at=$1 WHERE id='program_ready'`, now); err != nil { + t.Fatal(err) + } + + repository := billingmigrationpostgres.New(pool) + removal := billingmigration.CredentialRemoval{RemovalID: "removal_ops", ProgramID: "program_ready", ProjectID: "project_one", Reason: "Final import complete; acknowledge irreversible source access removal", ActorID: "owner_one", RemovalDigest: digestByte(0x35), RemovedAt: now} + write := billingmigration.CredentialRemovalWrite{Removal: removal, ExpectedState: 5, IdempotencyKey: "remove-ops", RequestDigest: bytesOf(0x36), IrreversibleAck: true} + removed, replay, err := repository.RemoveCredential(ctx, write) + if err != nil || replay || removed.CredentialID != "credential_ready" { + t.Fatalf("remove credential result=%#v replay=%v err=%v", removed, replay, err) + } + replayed, replay, err := repository.RemoveCredential(ctx, write) + if err != nil || !replay || replayed.RemovalID != removed.RemovalID { + t.Fatalf("credential removal replay=%#v replay=%v err=%v", replayed, replay, err) + } + write.RequestDigest = bytesOf(0x37) + if _, _, err = repository.RemoveCredential(ctx, write); !errors.Is(err, billingmigration.ErrIdempotencyConflict) { + t.Fatalf("changed credential-removal request error=%v", err) + } + var envelopePresent bool + if err = pool.QueryRow(ctx, `SELECT nonce IS NOT NULL OR ciphertext IS NOT NULL FROM billing_migration_credentials WHERE id='credential_ready'`).Scan(&envelopePresent); err != nil || envelopePresent { + t.Fatalf("credential envelope present=%v err=%v", envelopePresent, err) + } +} + +func TestPackageCMappingRepairInvalidationRollbackIsAtomic(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + seedReadyProgram(t, ctx, db, now) + statement := ` + INSERT INTO billing_migration_mapping_sets(id,program_id,project_id,version,status,mapping_digest,expected_program_state_version,created_by_actor_id,created_at,frozen_at) + VALUES('mapping_repair','program_ready','project_one',2,'frozen',decode(repeat('67',32),'hex'),4,'owner_one',$1,$1); + INSERT INTO billing_migration_cases(id,program_id,project_id,state_version,classification,status,reason,case_digest,opened_at) + VALUES('case_mapping_repair','program_ready','project_one',4,'blocking','open','Mapping repair requires invalidating stale operation outputs',decode(repeat('31',32),'hex'),$1); + INSERT INTO billing_migration_repair_previews(id,case_id,program_id,project_id,repair_kind,scope_kind,scope_references,affected_count,before_digest,after_digest,preview_digest,created_by_actor_id,created_at,expected_program_state_version,expected_case_digest,expected_policy_digest,expected_scope_digest,reason,expires_at) + VALUES('preview_mapping_repair','case_mapping_repair','program_ready','project_one','replace_mapping_set','mapping_set',ARRAY['mapping_repair'],1,decode(repeat('66',32),'hex'),decode(repeat('67',32),'hex'),decode(repeat('68',32),'hex'),'owner_one',$1,4,decode(repeat('31',32),'hex'),decode(repeat('62',32),'hex'),decode(repeat('61',32),'hex'),'Replace frozen mapping and invalidate derived artifacts',$1+interval '1 hour'); + INSERT INTO billing_migration_run_jobs(id,program_id,project_id,run_kind,idempotency_key,request_digest,expected_program_state_version,manifest_digest,mapping_digest,policy_digest,status,created_at,updated_at,due_at,max_attempts) + VALUES('run_job_mapping_repair','program_ready','project_one','shadow','shadow-mapping-repair',decode(repeat('81',32),'hex'),4,decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('62',32),'hex'),'pending',$1,$1,$1,3); + INSERT INTO billing_migration_cutover_proposals(id,program_id,project_id,state_version,command,proposer_actor_id,reason,scope_digest,manifest_digest,mapping_digest,policy_digest,evidence_digest,readiness_digest,final_watermark_digest,application_version_digest,proposal_digest,status,proposed_at,expires_at) + VALUES('proposal_mapping_repair','program_ready','project_one',4,'cutover','owner_one','pending proposal uses old mapping',decode(repeat('61',32),'hex'),decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('62',32),'hex'),decode(repeat('82',32),'hex'),decode(repeat('83',32),'hex'),decode(repeat('68',32),'hex'),decode(repeat('70',32),'hex'),decode(repeat('84',32),'hex'),'pending',$1,$1+interval '1 hour');` + statement = strings.ReplaceAll(statement, "$1", "TIMESTAMPTZ '"+now.Format(time.RFC3339)+"'") + if _, err := db.ExecContext(ctx, statement); err != nil { + t.Fatal(err) + } + repository := billingmigrationpostgres.New(pool) + prepared, err := repository.PrepareRepair(ctx, billingmigration.RepairExecutionWrite{ + ProjectID: "project_one", ProgramID: "program_ready", PreviewID: "preview_mapping_repair", ActorID: "owner_one", + IdempotencyKey: "execute-mapping-repair", ExpectedStateVersion: 4, ExpectedPreviewDigest: bytesOf(0x68), + ExpectedCaseDigest: bytesOf(0x31), ExpectedPolicyDigest: bytesOf(0x62), ExpectedScopeDigest: bytesOf(0x61), + RequestDigest: bytesOf(0x85), At: now.Add(time.Minute), + }) + if err != nil { + t.Fatal(err) + } + if _, err = pool.Exec(ctx, `CREATE FUNCTION reject_mapping_repair_invalidation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced repair invalidation failure'; END $$; CREATE TRIGGER reject_mapping_repair_invalidation BEFORE INSERT ON billing_migration_repair_invalidations FOR EACH ROW EXECUTE FUNCTION reject_mapping_repair_invalidation()`); err != nil { + t.Fatal(err) + } + _, err = repository.SettleRepair(ctx, billingmigration.RepairSettlement{ + ExecutionID: prepared.ExecutionID, ProgramID: "program_ready", ProjectID: "project_one", Result: "succeeded", + AttemptNumber: prepared.AttemptNumber, ActualBeforeDigest: bytesOf(0x66), ActualAfterDigest: bytesOf(0x67), + ResultDigest: bytesOf(0x86), At: now.Add(2 * time.Minute), + }) + if err == nil { + t.Fatal("forced invalidation failure accepted") + } + var state, proposalStatus string + var version, executions int + var settledAt *time.Time + if err = pool.QueryRow(ctx, `SELECT state,state_version FROM billing_migration_programs WHERE id='program_ready'`).Scan(&state, &version); err != nil { + t.Fatal(err) + } + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_repair_executions WHERE id=$1`, prepared.ExecutionID).Scan(&executions); err != nil { + t.Fatal(err) + } + if err = pool.QueryRow(ctx, `SELECT settled_at FROM billing_migration_repair_reservations WHERE id=$1`, prepared.ExecutionID).Scan(&settledAt); err != nil { + t.Fatal(err) + } + if err = pool.QueryRow(ctx, `SELECT status FROM billing_migration_cutover_proposals WHERE id='proposal_mapping_repair'`).Scan(&proposalStatus); err != nil { + t.Fatal(err) + } + if state != "shadowing" || version != 4 || executions != 0 || settledAt != nil || proposalStatus != "pending" { + t.Fatalf("partial mapping repair writes state=%s version=%d executions=%d settledAt=%v proposal=%s", state, version, executions, settledAt, proposalStatus) + } +} + +func TestPackageCLegalHoldRequiresTwoProductionOwnersAndExactChain(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + seedReadyProgram(t, ctx, db, now) + if _, err := db.ExecContext(ctx, `INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) VALUES('org_one','owner_two','owner',$1,$1)`, now); err != nil { + t.Fatal(err) + } + repository := billingmigrationpostgres.New(pool) + proposal := billingmigration.LegalHoldProposal{ProposalID: "proposal_set", ProgramID: "program_ready", ProjectID: "project_one", Command: "set", Reason: "Regulatory preservation request", ExternalComplianceReference: "LEGAL-2026-1", ProposerActorID: "owner_one", ProposalDigest: digestByte(0x41), Status: "pending", ProposedAt: now, ExpiresAt: now.Add(time.Hour)} + proposed, replay, err := repository.ProposeLegalHold(ctx, billingmigration.LegalHoldProposalWrite{Proposal: proposal, IdempotencyKey: "proposal-set", RequestDigest: bytesOf(0x42)}) + if err != nil || replay || proposed.ProposerActorID != "owner_one" { + t.Fatalf("proposal=%#v replay=%v err=%v", proposed, replay, err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO billing_migration_legal_hold_commands(id,program_id,project_id,proposal_id,command,reason,external_compliance_reference,proposer_actor_id,approver_actor_id,production,command_digest,commanded_at) VALUES('hold_forged_environment','program_ready','project_one','proposal_set','set','Regulatory preservation request','LEGAL-2026-1','owner_one','owner_one',false,decode(repeat('40',32),'hex'),$1)`, now.Add(30*time.Second)); err == nil { + t.Fatal("database accepted forged nonproduction self-approval for production environment") + } + self := billingmigration.LegalHoldApprovalWrite{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: proposal.ProposalID, ApproverActorID: "owner_one", IdempotencyKey: "approve-self", ExpectedProposalDigest: bytesOf(0x41), RequestDigest: bytesOf(0x43), At: now.Add(time.Minute)} + if _, _, err := repository.ApproveLegalHold(ctx, self); !errors.Is(err, billingmigration.ErrForbidden) { + t.Fatalf("production self-approval error=%v", err) + } + staleProposal := self + staleProposal.ApproverActorID = "owner_two" + staleProposal.IdempotencyKey = "approve-stale-proposal" + staleProposal.ExpectedProposalDigest = bytesOf(0xfe) + staleProposal.RequestDigest = bytesOf(0xfd) + if _, _, err := repository.ApproveLegalHold(ctx, staleProposal); !errors.Is(err, billingmigration.ErrStaleDigest) { + t.Fatalf("stale proposal digest error=%v", err) + } + expired := proposal + expired.ProposalID = "proposal_expired" + expired.ProposalDigest = digestByte(0x49) + expired.ProposedAt = now + expired.ExpiresAt = now.Add(time.Minute) + if _, _, err := repository.ProposeLegalHold(ctx, billingmigration.LegalHoldProposalWrite{Proposal: expired, IdempotencyKey: "proposal-expired", RequestDigest: bytesOf(0x4a)}); err != nil { + t.Fatalf("persist expiring proposal: %v", err) + } + if _, _, err := repository.ApproveLegalHold(ctx, billingmigration.LegalHoldApprovalWrite{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: expired.ProposalID, ApproverActorID: "owner_two", IdempotencyKey: "approve-expired", ExpectedProposalDigest: bytesOf(0x49), RequestDigest: bytesOf(0x4b), At: now.Add(2 * time.Minute)}); !errors.Is(err, billingmigration.ErrExpiredApproval) { + t.Fatalf("expired proposal error=%v", err) + } + approve := self + approve.ApproverActorID = "owner_two" + approve.IdempotencyKey = "approve-set" + approve.RequestDigest = bytesOf(0x44) + hold, replay, err := repository.ApproveLegalHold(ctx, approve) + if err != nil || replay || !hold.Production { + t.Fatalf("set legal hold=%#v replay=%v err=%v", hold, replay, err) + } + releaseProposal := billingmigration.LegalHoldProposal{ProposalID: "proposal_release", ProgramID: "program_ready", ProjectID: "project_one", Command: "release", Reason: "Compliance released preservation request", ExternalComplianceReference: "LEGAL-2026-1-RELEASE", ProposerActorID: "owner_one", ExpectedPreviousCommandDigest: digestByte(0xff), ProposalDigest: digestByte(0x45), Status: "pending", ProposedAt: now.Add(2 * time.Minute), ExpiresAt: now.Add(time.Hour)} + if _, _, err := repository.ProposeLegalHold(ctx, billingmigration.LegalHoldProposalWrite{Proposal: releaseProposal, IdempotencyKey: "release-stale", RequestDigest: bytesOf(0x46), ExpectedPreviousDigest: bytesOf(0xff)}); !errors.Is(err, billingmigration.ErrStaleDigest) { + t.Fatalf("stale legal-hold chain error=%v", err) + } + previousRaw, _ := billingmigration.ParseDigest(hold.CommandDigest) + releaseProposal.ExpectedPreviousCommandDigest = hold.CommandDigest + _, _, err = repository.ProposeLegalHold(ctx, billingmigration.LegalHoldProposalWrite{Proposal: releaseProposal, IdempotencyKey: "release-proposal", RequestDigest: bytesOf(0x47), ExpectedPreviousDigest: previousRaw}) + if err != nil { + t.Fatalf("release proposal: %v", err) + } + release := billingmigration.LegalHoldApprovalWrite{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: releaseProposal.ProposalID, ApproverActorID: "owner_two", IdempotencyKey: "release-approve", ExpectedProposalDigest: bytesOf(0x45), RequestDigest: bytesOf(0x48), At: now.Add(3 * time.Minute)} + if released, replay, err := repository.ApproveLegalHold(ctx, release); err != nil || replay || released.PreviousCommandID != hold.HoldID { + t.Fatalf("release legal hold=%#v replay=%v err=%v", released, replay, err) + } +} + +func TestPackageCNonproductionLegalHoldAllowsAuthenticatedSelfApproval(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + seedReadyProgram(t, ctx, db, now) + if _, err := db.ExecContext(ctx, `UPDATE environments SET mode='development' WHERE id='environment_one'`); err != nil { + t.Fatal(err) + } + repository := billingmigrationpostgres.New(pool) + proposal := billingmigration.LegalHoldProposal{ProposalID: "proposal_dev", ProgramID: "program_ready", ProjectID: "project_one", Command: "set", Reason: "Development retention drill", ExternalComplianceReference: "DEV-HOLD-1", ProposerActorID: "owner_one", ProposalDigest: digestByte(0x51), Status: "pending", ProposedAt: now, ExpiresAt: now.Add(time.Hour)} + if _, _, err := repository.ProposeLegalHold(ctx, billingmigration.LegalHoldProposalWrite{Proposal: proposal, IdempotencyKey: "proposal-dev", RequestDigest: bytesOf(0x52)}); err != nil { + t.Fatal(err) + } + hold, replay, err := repository.ApproveLegalHold(ctx, billingmigration.LegalHoldApprovalWrite{ProjectID: "project_one", ProgramID: "program_ready", ProposalID: proposal.ProposalID, ApproverActorID: "owner_one", IdempotencyKey: "approve-dev", ExpectedProposalDigest: bytesOf(0x51), RequestDigest: bytesOf(0x53), At: now.Add(time.Minute)}) + if err != nil || replay || hold.Production || hold.ProposerActorID != "owner_one" || hold.ApproverActorID != "owner_one" { + t.Fatalf("hold=%#v replay=%v err=%v", hold, replay, err) + } +} + +func TestPackageCCompletionRequiresFreshCurrentEpochEvidenceForEveryExactScope(t *testing.T) { + ctx, pool, db := executionDatabase(t) + cutoverAt := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + completedAt := cutoverAt.Add(8 * 24 * time.Hour) + seedReadyProgram(t, ctx, db, cutoverAt) + if _, err := db.ExecContext(ctx, `UPDATE billing_migration_programs SET state='stabilizing',state_version=7,policy_digest=decode(repeat('71',32),'hex'),updated_at=$1 WHERE id='program_ready'`, cutoverAt); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `UPDATE billing_migration_credentials SET nonce=NULL,ciphertext=NULL,removed_at=$1,removed_by_actor_id='owner_one',removal_digest=decode(repeat('91',32),'hex') WHERE id='credential_ready'`, cutoverAt); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `UPDATE billing_migration_authority_scopes SET current_authority='mosaic',current_epoch=1,authority_digest=decode(repeat('92',32),'hex'),updated_at=$1 WHERE active_program_id='program_ready'`, cutoverAt); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO billing_migration_authority_transitions(id,program_id,project_id,authority_scope_id,from_authority,to_authority,from_epoch,to_epoch,transition_kind,transition_digest,transitioned_at) + VALUES('completion_transition_ios','program_ready','project_one','authority_ready_ios','source','mosaic',0,1,'cutover',decode(repeat('93',32),'hex'),$1), + ('completion_transition_android','program_ready','project_one','authority_ready_android','source','mosaic',0,1,'cutover',decode(repeat('94',32),'hex'),$1)`, cutoverAt); err != nil { + t.Fatal(err) + } + repository := billingmigrationpostgres.New(pool) + assertStable := func(want bool, label string) { + t.Helper() + prerequisites, err := repository.CompletionPrerequisites(ctx, "project_one", "program_ready", completedAt) + if err != nil || prerequisites.AuthorityStable != want { + t.Fatalf("%s prerequisites=%#v err=%v", label, prerequisites, err) + } + } + assertStable(false, "stale and wrong-epoch observations") + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) + VALUES('completion_sync_ios','program_ready','project_one','app_one','ios','2.10.0','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('95',32),'hex'),$1)`, completedAt.Add(-30*time.Minute)); err != nil { + t.Fatal(err) + } + assertStable(false, "missing exact android scope") + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) + VALUES('completion_sync_android_wrong_epoch','program_ready','project_one','app_two','android','2.10.0','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,0,'accepted',decode(repeat('96',32),'hex'),$1)`, completedAt.Add(-20*time.Minute)); err != nil { + t.Fatal(err) + } + assertStable(false, "wrong android authority epoch") + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) + VALUES('completion_sync_android','program_ready','project_one','app_two','android','2.10.0','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('97',32),'hex'),$1)`, completedAt.Add(-10*time.Minute)); err != nil { + t.Fatal(err) + } + assertStable(true, "fresh current-epoch evidence for every scope") + if _, err := pool.Exec(ctx, `INSERT INTO webhook_destinations(id,project_id,environment_id,url,status,event_types,description,contract_version,last_successful_test_at,created_at,updated_at) VALUES + ('completion_webhook_fresh','project_one','environment_one','https://fresh.example.test','active',ARRAY['authority.rollback.completed'],'fresh',2,$1,$2,$2), + ('completion_webhook_stale','project_one','environment_one','https://stale.example.test','active',ARRAY['authority.rollback.completed'],'stale',2,$3,$2,$2)`, completedAt.Add(-time.Minute), cutoverAt, completedAt.Add(-2*time.Hour)); err != nil { + t.Fatal(err) + } + prerequisites, err := repository.CompletionPrerequisites(ctx, "project_one", "program_ready", completedAt) + if err != nil || prerequisites.WebhookReady { + t.Fatalf("one fresh destination masked stale active destination: prerequisites=%#v err=%v", prerequisites, err) + } + policyDigest, _ := billingmigration.ParseDigest(prerequisites.PolicyDigest) + authorityDigest, _ := billingmigration.ParseDigest(prerequisites.AuthorityDigest) + stabilityDigest, _ := billingmigration.ParseDigest(prerequisites.StabilityEvidenceDigest) + if _, _, err := repository.CompleteMigration(ctx, billingmigration.CompletionWrite{ + Report: billingmigration.CompletionReport{ReportID: "completion_stale_webhook", ProgramID: "program_ready", ProjectID: "project_one", StateVersion: 8, CompletedAt: completedAt}, + ExpectedStateVersion: 7, ExpectedPolicyDigest: policyDigest, AuthorityDigest: authorityDigest, StabilityDigest: stabilityDigest, + CompletionDigest: bytesOf(0xb1), RequestDigest: bytesOf(0xb2), DeletionIdentity: bytesOf(0xb3), RetentionJobID: "retention_stale_webhook", ActorID: "owner_one", IdempotencyKey: "completion-stale-webhook", + }); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("transactional completion accepted stale active destination: %v", err) + } + if _, err := pool.Exec(ctx, `UPDATE webhook_destinations SET last_successful_test_at=$1 WHERE id='completion_webhook_stale'`, completedAt.Add(-time.Minute)); err != nil { + t.Fatal(err) + } + prerequisites, err = repository.CompletionPrerequisites(ctx, "project_one", "program_ready", completedAt) + if err != nil || !prerequisites.WebhookReady { + t.Fatalf("all active v2 destinations fresh: prerequisites=%#v err=%v", prerequisites, err) + } + if _, err := pool.Exec(ctx, `INSERT INTO webhook_destinations(id,project_id,environment_id,url,status,event_types,description,contract_version,last_successful_test_at,created_at,updated_at) + VALUES('completion_webhook_v1','project_one','environment_one','https://v1.example.test','active',ARRAY['customer.entitlements.changed'],'v1',1,$1,$2,$2)`, completedAt.Add(-time.Minute), cutoverAt); err != nil { + t.Fatal(err) + } + prerequisites, err = repository.CompletionPrerequisites(ctx, "project_one", "program_ready", completedAt) + if err != nil || prerequisites.WebhookReady { + t.Fatalf("active v1 destination incorrectly accepted: prerequisites=%#v err=%v", prerequisites, err) + } +} + +func TestPackageCRetentionSkipsHeldJobsAndExhaustsRetryBudget(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + seedReadyProgram(t, ctx, db, now) + if _, err := db.ExecContext(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest) + VALUES('completion_retention','program_ready','project_one',5,$1::timestamptz,$1::timestamptz-interval '1 day',$1::timestamptz-interval '1 day',true,$1::timestamptz-interval '2 days',false,$1::timestamptz+interval '30 days',decode(repeat('a1',32),'hex'),decode(repeat('a2',32),'hex'),decode(repeat('a3',32),'hex'),decode(repeat('a4',32),'hex'))`, now); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO billing_migration_retention_jobs(id,program_id,project_id,status,legal_hold,due_at,lease_generation,created_at,updated_at,completion_report_id,attempt_count,max_attempts,deletion_identity,original_due_at) + VALUES('retention_ops','program_ready','project_one','pending',true,$1,0,$1,$1,'completion_retention',0,2,decode(repeat('a5',32),'hex'),$1)`, now); err != nil { + t.Fatal(err) + } + repository := billingmigrationpostgres.New(pool) + if _, err := repository.ClaimRetention(ctx, billingmigration.RetentionClaim{WorkerID: "worker", Now: now, LeaseFor: time.Minute}); !errors.Is(err, billingmigration.ErrNotFound) { + t.Fatalf("held retention claim error=%v", err) + } + var attempts int + if err := pool.QueryRow(ctx, `SELECT attempt_count FROM billing_migration_retention_jobs WHERE id='retention_ops'`).Scan(&attempts); err != nil || attempts != 0 { + t.Fatalf("held job attempts=%d err=%v", attempts, err) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_retention_jobs SET legal_hold=false WHERE id='retention_ops'`); err != nil { + t.Fatal(err) + } + lease, err := repository.ClaimRetention(ctx, billingmigration.RetentionClaim{WorkerID: "worker", Now: now, LeaseFor: time.Minute}) + if err != nil || lease.AttemptNumber != 1 { + t.Fatalf("first lease=%#v err=%v", lease, err) + } + retryAt := now.Add(time.Hour) + if err := repository.FinishRetention(ctx, billingmigration.RetentionFinish{JobID: lease.JobID, Generation: lease.Generation, ErrorCode: "object_delete_retryable", RetryAt: retryAt, At: now.Add(time.Minute)}); err != nil { + t.Fatal(err) + } + second, err := repository.ClaimRetention(ctx, billingmigration.RetentionClaim{WorkerID: "worker", Now: retryAt, LeaseFor: time.Minute}) + if err != nil || second.AttemptNumber != 2 { + t.Fatalf("second lease=%#v err=%v", second, err) + } + if err := repository.FinishRetention(ctx, billingmigration.RetentionFinish{JobID: second.JobID, Generation: second.Generation, ErrorCode: "object_delete_retryable", RetryAt: retryAt.Add(time.Hour), At: retryAt.Add(time.Minute)}); err != nil { + t.Fatal(err) + } + var status, errorCode string + if err := pool.QueryRow(ctx, `SELECT status,last_error_code FROM billing_migration_retention_jobs WHERE id='retention_ops'`).Scan(&status, &errorCode); err != nil || status != "failed" || errorCode != "object_delete_retryable" { + t.Fatalf("terminal retention status=%q error=%q queryErr=%v", status, errorCode, err) + } +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/repairs.go b/apps/api/internal/platform/billingmigrationpostgres/repairs.go new file mode 100644 index 00000000..79fc9f9c --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/repairs.go @@ -0,0 +1,250 @@ +package billingmigrationpostgres + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/jackc/pgx/v5" +) + +func (r *Repository) CreateRepairPreview(ctx context.Context, w billingmigration.RepairPreviewWrite) (billingmigration.RepairPreview, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.RepairPreview{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + resource, replay, err := operationCommandReplay(ctx, tx, w.Preview.ProgramID, "preview_repair", w.IdempotencyKey, w.RequestDigest) + if err != nil { + return billingmigration.RepairPreview{}, false, err + } + if replay { + p, err := repairPreviewByID(ctx, tx, w.Preview.ProjectID, w.Preview.ProgramID, resource) + return p, true, err + } + var state int64 + var policy, scope, caseD []byte + var caseStatus string + err = tx.QueryRow(ctx, `SELECT p.state_version,p.policy_digest,p.scope_digest,c.case_digest,c.status FROM billing_migration_programs p JOIN billing_migration_cases c ON c.program_id=p.id AND c.project_id=p.project_id WHERE p.id=$1 AND p.project_id=$2 AND c.id=$3 FOR UPDATE OF p,c`, w.Preview.ProgramID, w.Preview.ProjectID, w.Preview.CaseID).Scan(&state, &policy, &scope, &caseD, &caseStatus) + if err != nil { + return billingmigration.RepairPreview{}, false, translate(err, "lock repair preview bindings") + } + if state != w.Preview.ExpectedStateVersion || caseStatus == "resolved" || caseStatus == "dismissed" || !bytes.Equal(policy, w.PolicyDigest) || !bytes.Equal(scope, w.ScopeDigest) || !bytes.Equal(caseD, w.CaseDigest) { + return billingmigration.RepairPreview{}, false, billingmigration.ErrStaleDigest + } + before, _ := billingmigration.ParseDigest(w.Preview.BeforeDigest) + after, _ := billingmigration.ParseDigest(w.Preview.AfterDigest) + preview, _ := billingmigration.ParseDigest(w.Preview.PreviewDigest) + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_repair_previews(id,case_id,program_id,project_id,repair_kind,scope_kind,scope_references,affected_count,before_digest,after_digest,preview_digest,created_by_actor_id,created_at,expected_program_state_version,expected_case_digest,expected_policy_digest,expected_scope_digest,reason,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`, w.Preview.PreviewID, w.Preview.CaseID, w.Preview.ProgramID, w.Preview.ProjectID, w.Preview.RepairKind, w.Preview.ScopeKind, w.Preview.ScopeReferences, w.Preview.AffectedCount, before, after, preview, w.ActorID, w.Preview.CreatedAt, w.Preview.ExpectedStateVersion, w.CaseDigest, w.PolicyDigest, w.ScopeDigest, w.Preview.Reason, w.Preview.ExpiresAt) + if err != nil { + return billingmigration.RepairPreview{}, false, translate(err, "insert repair preview") + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_command_idempotency(id,program_id,project_id,command_kind,idempotency_key,request_digest,resource_id,created_at) VALUES('mci_'||$1,$2,$3,'preview_repair',$4,$5,$1,$6)`, w.Preview.PreviewID, w.Preview.ProgramID, w.Preview.ProjectID, w.IdempotencyKey, w.RequestDigest, w.Preview.CreatedAt) + if err != nil { + return billingmigration.RepairPreview{}, false, translate(err, "insert repair preview idempotency") + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.RepairPreview{}, false, err + } + return w.Preview, false, nil +} + +func (r *Repository) PrepareRepair(ctx context.Context, w billingmigration.RepairExecutionWrite) (billingmigration.PreparedRepair, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.PreparedRepair{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + var existingID, previewID string + var stored []byte + var attempt int + var settledAt *time.Time + err = tx.QueryRow(ctx, `SELECT id,preview_id,request_digest,attempt_number,settled_at FROM billing_migration_repair_reservations WHERE program_id=$1 AND idempotency_key=$2`, w.ProgramID, w.IdempotencyKey).Scan(&existingID, &previewID, &stored, &attempt, &settledAt) + if err == nil { + if !bytes.Equal(stored, w.RequestDigest) || previewID != w.PreviewID { + return billingmigration.PreparedRepair{}, billingmigration.ErrIdempotencyConflict + } + p, err := preparedRepair(ctx, tx, w.ProjectID, w.ProgramID, w.PreviewID, existingID, attempt) + if err != nil { + return p, err + } + if settledAt != nil { + execution, e := repairExecutionByID(ctx, tx, existingID) + if e != nil { + return p, e + } + p.State = billingmigration.RepairPreparationSettled + p.SettledExecution = &execution + } else { + p.State = billingmigration.RepairPreparationUnsettled + } + return p, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return billingmigration.PreparedRepair{}, err + } + var kind, caseID, state, caseStatus string + var refs []string + var version int64 + var previewD, caseD, policy, scope []byte + var expires time.Time + err = tx.QueryRow(ctx, `SELECT p.repair_kind,p.case_id,p.scope_references,p.preview_digest,p.expected_case_digest,p.expected_policy_digest,p.expected_scope_digest,p.expires_at,mp.state,mp.state_version,c.status FROM billing_migration_repair_previews p JOIN billing_migration_programs mp ON mp.id=p.program_id AND mp.project_id=p.project_id JOIN billing_migration_cases c ON c.id=p.case_id WHERE p.id=$1 AND p.program_id=$2 AND p.project_id=$3 FOR UPDATE OF mp,c`, w.PreviewID, w.ProgramID, w.ProjectID).Scan(&kind, &caseID, &refs, &previewD, &caseD, &policy, &scope, &expires, &state, &version, &caseStatus) + if err != nil { + return billingmigration.PreparedRepair{}, translate(err, "lock repair bindings") + } + if version != w.ExpectedStateVersion || caseStatus == "resolved" || caseStatus == "dismissed" || !bytes.Equal(previewD, w.ExpectedPreviewDigest) || !bytes.Equal(caseD, w.ExpectedCaseDigest) || !bytes.Equal(policy, w.ExpectedPolicyDigest) || !bytes.Equal(scope, w.ExpectedScopeDigest) || !expires.After(w.At) { + return billingmigration.PreparedRepair{}, billingmigration.ErrStaleDigest + } + if kind == billingmigration.RepairReplaceMappingSet && (state == "cutover_pending" || state == "stabilizing" || state == "completed" || state == "rolled_back") { + return billingmigration.PreparedRepair{}, billingmigration.ErrConflict + } + err = tx.QueryRow(ctx, `SELECT COALESCE(max(attempt_number),0)+1 FROM billing_migration_repair_reservations WHERE preview_id=$1`, w.PreviewID).Scan(&attempt) + if err != nil || attempt > 16 { + return billingmigration.PreparedRepair{}, billingmigration.ErrConflict + } + sum := sha256.Sum256(append([]byte(w.PreviewID+"\x1f"), w.RequestDigest...)) + id := "mre_" + hex.EncodeToString(sum[:12]) + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_repair_reservations(id,preview_id,program_id,project_id,idempotency_key,request_digest,attempt_number,expected_program_state_version,actor_id,reserved_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, id, w.PreviewID, w.ProgramID, w.ProjectID, w.IdempotencyKey, w.RequestDigest, attempt, w.ExpectedStateVersion, w.ActorID, w.At) + if err != nil { + return billingmigration.PreparedRepair{}, translate(err, "reserve repair execution") + } + prepared, err := preparedRepair(ctx, tx, w.ProjectID, w.ProgramID, w.PreviewID, id, attempt) + if err != nil { + return billingmigration.PreparedRepair{}, err + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.PreparedRepair{}, err + } + prepared.State = billingmigration.RepairPreparationNew + return prepared, nil +} + +func preparedRepair(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, projectID, programID, previewID, executionID string, attempt int) (billingmigration.PreparedRepair, error) { + var p billingmigration.PreparedRepair + p.ExecutionID = executionID + p.AttemptNumber = attempt + err := q.QueryRow(ctx, `SELECT repair_kind,case_id,scope_references,before_digest FROM billing_migration_repair_previews WHERE id=$1 AND program_id=$2 AND project_id=$3`, previewID, programID, projectID).Scan(&p.RepairKind, &p.CaseID, &p.ScopeReferences, &p.PreviewBeforeDigest) + return p, err +} + +func (r *Repository) SettleRepair(ctx context.Context, s billingmigration.RepairSettlement) (billingmigration.RepairExecution, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.RepairExecution{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + var previewID, actorID, repairKind string + var attempt int + var expectedStateVersion int64 + var settledAt *time.Time + err = tx.QueryRow(ctx, `SELECT r.preview_id,r.actor_id,r.attempt_number,r.expected_program_state_version,r.settled_at,p.repair_kind FROM billing_migration_repair_reservations r JOIN billing_migration_repair_previews p ON p.id=r.preview_id WHERE r.id=$1 AND r.program_id=$2 AND r.project_id=$3 FOR UPDATE OF r`, s.ExecutionID, s.ProgramID, s.ProjectID).Scan(&previewID, &actorID, &attempt, &expectedStateVersion, &settledAt, &repairKind) + if err != nil { + return billingmigration.RepairExecution{}, translate(err, "lock repair reservation") + } + if settledAt != nil { + e, err := repairExecutionByID(ctx, tx, s.ExecutionID) + return e, err + } + if s.Result == "" { + return billingmigration.RepairExecution{}, billingmigration.ErrConflict + } + if attempt != s.AttemptNumber || len(s.ActualBeforeDigest) != 32 || len(s.ActualAfterDigest) != 32 { + return billingmigration.RepairExecution{}, billingmigration.ErrInvalid + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_repair_executions(id,preview_id,program_id,project_id,idempotency_key,result,result_digest,executed_by_actor_id,executed_at,attempt_number,request_digest,actual_before_digest,actual_after_digest,error_code) SELECT r.id,r.preview_id,r.program_id,r.project_id,r.idempotency_key,$2,$3,r.actor_id,$4,r.attempt_number,r.request_digest,$5,$6,NULLIF($7,'') FROM billing_migration_repair_reservations r WHERE r.id=$1`, s.ExecutionID, s.Result, s.ResultDigest, s.At, s.ActualBeforeDigest, s.ActualAfterDigest, s.ErrorCode) + if err != nil { + return billingmigration.RepairExecution{}, translate(err, "append repair execution") + } + if repairKind == billingmigration.RepairReplaceMappingSet && s.Result != "failed" { + var state string + var version int64 + err = tx.QueryRow(ctx, `SELECT state,state_version FROM billing_migration_programs WHERE id=$1 AND project_id=$2 FOR UPDATE`, s.ProgramID, s.ProjectID).Scan(&state, &version) + if err != nil { + return billingmigration.RepairExecution{}, translate(err, "lock mapping repair rewind") + } + if version != expectedStateVersion || state == "cutover_pending" || state == "stabilizing" || state == "completed" || state == "rolled_back" { + return billingmigration.RepairExecution{}, billingmigration.ErrStaleState + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_repair_invalidations(id,execution_id,program_id,project_id,invalidation_kind,invalidated_reference_id,invalidation_digest,invalidated_at) + SELECT 'mri_'||substr(md5($1||':'||x.kind||':'||x.reference_id),1,20),$1,$2,$3,x.kind,x.reference_id, + sha256(convert_to($1||chr(31)||x.kind||chr(31)||x.reference_id,'UTF8')),$4 + FROM ( + SELECT CASE run_kind WHEN 'dry_run' THEN 'dry_run' ELSE 'shadow' END kind,COALESCE(result_run_id,id) reference_id FROM billing_migration_run_jobs WHERE program_id=$2 + UNION ALL SELECT 'readiness',id FROM billing_migration_readiness_assessments WHERE program_id=$2 + UNION ALL SELECT 'checkpoint',id FROM billing_migration_checkpoints WHERE program_id=$2 + UNION ALL SELECT 'approval',id FROM billing_migration_approvals WHERE program_id=$2 + )x ON CONFLICT(execution_id,invalidation_kind,invalidated_reference_id) DO NOTHING`, s.ExecutionID, s.ProgramID, s.ProjectID, s.At) + if err != nil { + return billingmigration.RepairExecution{}, translate(err, "append mapping repair invalidations") + } + _, err = tx.Exec(ctx, `UPDATE billing_migration_cutover_proposals SET status='invalidated',invalidated_at=$2 WHERE program_id=$1 AND status IN('pending','approved')`, s.ProgramID, s.At) + if err != nil { + return billingmigration.RepairExecution{}, translate(err, "invalidate mapping repair proposals") + } + tag, err := tx.Exec(ctx, `UPDATE billing_migration_programs SET state='mapping',state_version=state_version+1,updated_at=$3 WHERE id=$1 AND project_id=$2 AND state_version=$4`, s.ProgramID, s.ProjectID, s.At, expectedStateVersion) + if err != nil || tag.RowsAffected() != 1 { + return billingmigration.RepairExecution{}, billingmigration.ErrStaleState + } + } + for i, invalidation := range s.Invalidations { + if len(invalidation.Digest) != 32 { + return billingmigration.RepairExecution{}, billingmigration.ErrInvalid + } + id := fmt.Sprintf("mri_%s_%02d", s.ExecutionID[4:], i) + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_repair_invalidations(id,execution_id,program_id,project_id,invalidation_kind,invalidated_reference_id,invalidation_digest,invalidated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(execution_id,invalidation_kind,invalidated_reference_id) DO NOTHING`, id, s.ExecutionID, s.ProgramID, s.ProjectID, invalidation.Kind, invalidation.ReferenceID, invalidation.Digest, s.At) + if err != nil { + return billingmigration.RepairExecution{}, translate(err, "append repair invalidation") + } + } + _, err = tx.Exec(ctx, `UPDATE billing_migration_repair_reservations SET settled_at=$2 WHERE id=$1 AND settled_at IS NULL`, s.ExecutionID, s.At) + if err != nil { + return billingmigration.RepairExecution{}, err + } + e, err := repairExecutionByID(ctx, tx, s.ExecutionID) + if err != nil { + return e, err + } + if err = tx.Commit(ctx); err != nil { + return e, err + } + return e, nil +} + +func repairPreviewByID(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, projectID, programID, id string) (billingmigration.RepairPreview, error) { + var p billingmigration.RepairPreview + var before, after, preview, caseD, policy, scope []byte + err := q.QueryRow(ctx, `SELECT id,case_id,program_id,project_id,repair_kind,scope_kind,reason,scope_references,affected_count,expected_program_state_version,before_digest,after_digest,preview_digest,expected_case_digest,expected_policy_digest,expected_scope_digest,created_at,expires_at FROM billing_migration_repair_previews WHERE id=$1 AND program_id=$2 AND project_id=$3`, id, programID, projectID).Scan(&p.PreviewID, &p.CaseID, &p.ProgramID, &p.ProjectID, &p.RepairKind, &p.ScopeKind, &p.Reason, &p.ScopeReferences, &p.AffectedCount, &p.ExpectedStateVersion, &before, &after, &preview, &caseD, &policy, &scope, &p.CreatedAt, &p.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return p, billingmigration.ErrNotFound + } + p.BeforeDigest = billingmigration.FormatDigest(before) + p.AfterDigest = billingmigration.FormatDigest(after) + p.PreviewDigest = billingmigration.FormatDigest(preview) + p.CaseDigest = billingmigration.FormatDigest(caseD) + p.PolicyDigest = billingmigration.FormatDigest(policy) + p.ScopeDigest = billingmigration.FormatDigest(scope) + return p, err +} +func repairExecutionByID(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, id string) (billingmigration.RepairExecution, error) { + var e billingmigration.RepairExecution + var before, after, result []byte + err := q.QueryRow(ctx, `SELECT id,preview_id,program_id,result,COALESCE(error_code,''),actual_before_digest,actual_after_digest,result_digest,attempt_number,executed_at FROM billing_migration_repair_executions WHERE id=$1`, id).Scan(&e.ExecutionID, &e.PreviewID, &e.ProgramID, &e.Result, &e.ErrorCode, &before, &after, &result, &e.AttemptNumber, &e.ExecutedAt) + if errors.Is(err, pgx.ErrNoRows) { + return e, billingmigration.ErrConflict + } + e.BeforeDigest = billingmigration.FormatDigest(before) + e.AfterDigest = billingmigration.FormatDigest(after) + e.ResultDigest = billingmigration.FormatDigest(result) + return e, err +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/repository.go b/apps/api/internal/platform/billingmigrationpostgres/repository.go new file mode 100644 index 00000000..f14153df --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/repository.go @@ -0,0 +1,327 @@ +// Package billingmigrationpostgres persists Phase 9C migration evidence. +package billingmigrationpostgres + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +type Repository struct{ pool *pgxpool.Pool } + +func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +var _ billingmigration.Repository = (*Repository)(nil) + +var capabilityRoles = map[string]map[string]bool{ + billingmigration.CapabilityView: {"member": true, "admin": true, "owner": true}, + billingmigration.CapabilityManageSource: {"owner": true}, + billingmigration.CapabilityManageMappings: {"admin": true, "owner": true}, + billingmigration.CapabilityRunImport: {"admin": true, "owner": true}, + billingmigration.CapabilityAssessReadiness: {"owner": true}, + billingmigration.CapabilityProposeCutover: {"admin": true, "owner": true}, + billingmigration.CapabilityApproveCutover: {"owner": true}, + billingmigration.CapabilityExecuteCutover: {"owner": true}, + billingmigration.CapabilityExecuteRollback: {"owner": true}, + billingmigration.CapabilityResolveCases: {"admin": true, "owner": true}, + billingmigration.CapabilityExecuteRepair: {"owner": true}, + billingmigration.CapabilityDeleteSource: {"owner": true}, + billingmigration.CapabilityRemoveCredential: {"owner": true}, + billingmigration.CapabilityManageLegalHold: {"owner": true}, + billingmigration.CapabilityCompleteMigration: {"owner": true}, +} + +var orderedOperatorCapabilities = []string{ + billingmigration.CapabilityView, billingmigration.CapabilityManageSource, billingmigration.CapabilityManageMappings, + billingmigration.CapabilityRunImport, billingmigration.CapabilityAssessReadiness, billingmigration.CapabilityProposeCutover, + billingmigration.CapabilityApproveCutover, billingmigration.CapabilityExecuteCutover, billingmigration.CapabilityExecuteRollback, + billingmigration.CapabilityResolveCases, billingmigration.CapabilityExecuteRepair, billingmigration.CapabilityDeleteSource, + billingmigration.CapabilityRemoveCredential, billingmigration.CapabilityManageLegalHold, billingmigration.CapabilityCompleteMigration, +} + +func (r *Repository) AllowedCapabilities(ctx context.Context, actor billingmigration.Actor, projectID string) ([]string, error) { + if strings.TrimSpace(actor.ID) == "" { + return nil, billingmigration.ErrUnauthenticated + } + var role string + err := r.pool.QueryRow(ctx, `SELECT m.role FROM projects p JOIN organization_members m ON m.organization_id=p.organization_id WHERE p.id=$1 AND m.actor_id=$2`, projectID, actor.ID).Scan(&role) + if errors.Is(err, pgx.ErrNoRows) { + return nil, billingmigration.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("read billing migration operator capabilities: %w", err) + } + allowed := make([]string, 0, len(orderedOperatorCapabilities)) + for _, capability := range orderedOperatorCapabilities { + if capabilityRoles[capability][role] { + allowed = append(allowed, capability) + } + } + return allowed, nil +} + +func (r *Repository) Authorize(ctx context.Context, actor billingmigration.Actor, projectID, capability string) (billingmigration.Authorization, error) { + if strings.TrimSpace(actor.ID) == "" { + return billingmigration.Authorization{}, billingmigration.ErrUnauthenticated + } + var authorization billingmigration.Authorization + err := r.pool.QueryRow(ctx, `SELECT p.organization_id, m.role FROM projects p + JOIN organization_members m ON m.organization_id=p.organization_id + WHERE p.id=$1 AND m.actor_id=$2`, projectID, actor.ID). + Scan(&authorization.OrganizationID, &authorization.Role) + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.Authorization{}, billingmigration.ErrNotFound + } + if err != nil { + return billingmigration.Authorization{}, fmt.Errorf("authorize billing migration: %w", err) + } + roles, knownCapability := capabilityRoles[capability] + if !knownCapability || !roles[authorization.Role] { + return billingmigration.Authorization{}, billingmigration.ErrForbidden + } + return authorization, nil +} + +func (r *Repository) Idempotency(ctx context.Context, projectID, key string) (billingmigration.StoredIdempotency, error) { + var stored billingmigration.StoredIdempotency + err := r.pool.QueryRow(ctx, `SELECT id, request_digest FROM billing_migration_programs + WHERE project_id=$1 AND idempotency_key=$2`, projectID, key). + Scan(&stored.ProgramID, &stored.RequestDigest) + if errors.Is(err, pgx.ErrNoRows) { + return stored, billingmigration.ErrNotFound + } + if err != nil { + return stored, fmt.Errorf("read migration idempotency: %w", err) + } + return stored, nil +} + +func (r *Repository) CreateProgram(ctx context.Context, command billingmigration.CreateProgramCommand) (billingmigration.ProgramDetail, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.ProgramDetail{}, fmt.Errorf("begin migration program: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var organizationID, role string + if err := tx.QueryRow(ctx, `SELECT p.organization_id,m.role FROM projects p + JOIN environments e ON e.project_id=p.id + JOIN organization_members m ON m.organization_id=p.organization_id AND m.actor_id=$3 + WHERE p.id=$1 AND e.id=$2`, command.Program.Scope.ProjectID, + command.Program.Scope.EnvironmentID, command.ActorID).Scan(&organizationID, &role); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.ProgramDetail{}, billingmigration.ErrNotFound + } + return billingmigration.ProgramDetail{}, fmt.Errorf("validate migration environment: %w", err) + } + if organizationID != command.OrganizationID { + return billingmigration.ProgramDetail{}, billingmigration.ErrConflict + } + if !capabilityRoles[billingmigration.CapabilityManageSource][role] { + return billingmigration.ProgramDetail{}, billingmigration.ErrForbidden + } + for _, scope := range command.Program.Scope.Applications { + var exists bool + err := tx.QueryRow(ctx, `SELECT true FROM applications WHERE id=$1 AND project_id=$2 AND platform=$3`, + scope.ApplicationID, command.Program.Scope.ProjectID, scope.Platform).Scan(&exists) + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.ProgramDetail{}, billingmigration.ErrInvalid + } + if err != nil { + return billingmigration.ProgramDetail{}, fmt.Errorf("validate migration application: %w", err) + } + } + program := command.Program + var commonEpoch *int64 + for _, scope := range program.Scope.Applications { + authoritySum := sha256.Sum256([]byte(program.Scope.ProjectID + "\x00" + program.Scope.EnvironmentID + "\x00" + scope.ApplicationID + "\x00" + scope.Platform + "\x00source\x000")) + authorityID := "mas_" + hex.EncodeToString(authoritySum[:8]) + if _, err := tx.Exec(ctx, `INSERT INTO billing_migration_authority_scopes(id,project_id,environment_id,application_id,platform,current_authority,current_epoch,active_program_id,authority_digest,updated_at) VALUES($1,$2,$3,$4,$5,'source',0,NULL,$6,$7) ON CONFLICT(project_id,environment_id,application_id,platform) DO NOTHING`, authorityID, program.Scope.ProjectID, program.Scope.EnvironmentID, scope.ApplicationID, scope.Platform, authoritySum[:], command.Now); err != nil { + return billingmigration.ProgramDetail{}, translate(err, "initialize migration authority scope") + } + var activeProgramID *string + var currentAuthority string + var currentEpoch int64 + if err := tx.QueryRow(ctx, `SELECT current_authority,current_epoch,active_program_id FROM billing_migration_authority_scopes WHERE project_id=$1 AND environment_id=$2 AND application_id=$3 AND platform=$4 FOR UPDATE`, program.Scope.ProjectID, program.Scope.EnvironmentID, scope.ApplicationID, scope.Platform).Scan(¤tAuthority, ¤tEpoch, &activeProgramID); err != nil { + return billingmigration.ProgramDetail{}, translate(err, "lock migration authority scope") + } + if currentAuthority != "source" || (activeProgramID != nil && *activeProgramID != program.ProgramID) || (commonEpoch != nil && *commonEpoch != currentEpoch) { + return billingmigration.ProgramDetail{}, billingmigration.ErrConflict + } + if commonEpoch == nil { + epoch := currentEpoch + commonEpoch = &epoch + } + } + if commonEpoch == nil { + return billingmigration.ProgramDetail{}, billingmigration.ErrInvalid + } + program.AuthorityEpochBefore = *commonEpoch + credential := command.Credential + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_credentials( + id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext, + fingerprint,created_by_actor_id,created_at) VALUES($1,$2,'revenuecat',$3,'active',$4,$5,$6,$7,$8,$9,$10,$11)`, + credential.ID, credential.ProjectID, credential.ExternalProjectID, credential.EnvelopeVersion, + credential.Algorithm, credential.KeyID, credential.Nonce, credential.Ciphertext, + credential.Fingerprint, credential.CreatedByActorID, credential.CreatedAt) + if err != nil { + return billingmigration.ProgramDetail{}, translate(err, "insert migration credential") + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_programs( + id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version, + authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest, + idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) + VALUES($1,$2,$3,'revenuecat',$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$16)`, + program.ProgramID, program.Scope.ProjectID, program.Scope.EnvironmentID, program.Source.AdapterVersion, + credential.ID, program.State, program.StateVersion, program.AuthorityEpochBefore, + program.StabilizationDays, program.RollbackWindowDays, command.ScopeDigest, command.PolicyDigest, + command.IdempotencyKey, command.RequestDigest, command.ActorID, command.Now) + if err != nil { + return billingmigration.ProgramDetail{}, translate(err, "insert migration program") + } + for _, scope := range program.Scope.Applications { + if _, err := tx.Exec(ctx, `INSERT INTO billing_migration_program_scopes( + program_id,project_id,environment_id,application_id,platform,created_at) VALUES($1,$2,$3,$4,$5,$6)`, + program.ProgramID, program.Scope.ProjectID, program.Scope.EnvironmentID, + scope.ApplicationID, scope.Platform, command.Now); err != nil { + return billingmigration.ProgramDetail{}, translate(err, "insert migration scope") + } + tag, err := tx.Exec(ctx, `UPDATE billing_migration_authority_scopes SET active_program_id=$5,updated_at=$6 WHERE project_id=$1 AND environment_id=$2 AND application_id=$3 AND platform=$4 AND (active_program_id IS NULL OR active_program_id=$5)`, program.Scope.ProjectID, program.Scope.EnvironmentID, scope.ApplicationID, scope.Platform, program.ProgramID, command.Now) + if err != nil { + return billingmigration.ProgramDetail{}, translate(err, "claim migration authority scope") + } + if tag.RowsAffected() != 1 { + return billingmigration.ProgramDetail{}, billingmigration.ErrConflict + } + } + assessment := command.Assessment + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_capability_assessments( + id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, command.AssessmentID, program.ProgramID, + program.Scope.ProjectID, assessment.StateVersion, assessment.ProviderAPIVersion, + assessment.Capabilities, command.AssessmentDigest, assessment.AssessedAt) + if err != nil { + return billingmigration.ProgramDetail{}, translate(err, "insert migration assessment") + } + metadata, _ := json.Marshal(map[string]any{"scopeDigest": billingmigration.FormatDigest(command.ScopeDigest), "sourceAdapter": "revenuecat"}) + _, err = tx.Exec(ctx, `INSERT INTO audit_events(id,actor_id,organization_id,project_id,environment_id, + action,resource_type,resource_id,metadata,created_at) + VALUES($1,$2,$3,$4,$5,'billing.migration.program.created','billing_migration_program',$6,$7,$8)`, + "aud_"+program.ProgramID, command.ActorID, organizationID, program.Scope.ProjectID, + program.Scope.EnvironmentID, program.ProgramID, metadata, command.Now) + if err != nil { + return billingmigration.ProgramDetail{}, fmt.Errorf("insert migration audit: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return billingmigration.ProgramDetail{}, translate(err, "commit migration program") + } + return billingmigration.ProgramDetail{Program: program, SourceCapabilityAssessment: &assessment}, nil +} + +func (r *Repository) ListPrograms(ctx context.Context, projectID string, limit int) ([]billingmigration.ProgramDetail, error) { + rows, err := r.pool.Query(ctx, programSelect+` WHERE p.project_id=$1 ORDER BY p.created_at DESC,p.id LIMIT $2`, projectID, limit) + if err != nil { + return nil, fmt.Errorf("list migration programs: %w", err) + } + defer rows.Close() + programs := make([]billingmigration.ProgramDetail, 0, limit) + for rows.Next() { + program, err := scanProgram(rows) + if err != nil { + return nil, fmt.Errorf("scan migration program: %w", err) + } + detail, err := r.hydrate(ctx, program) + if err != nil { + return nil, err + } + programs = append(programs, detail) + } + return programs, rows.Err() +} + +func (r *Repository) Program(ctx context.Context, projectID, programID string) (billingmigration.ProgramDetail, error) { + program, err := scanProgram(r.pool.QueryRow(ctx, programSelect+` WHERE p.project_id=$1 AND p.id=$2`, projectID, programID)) + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.ProgramDetail{}, billingmigration.ErrNotFound + } + if err != nil { + return billingmigration.ProgramDetail{}, fmt.Errorf("read migration program: %w", err) + } + return r.hydrate(ctx, program) +} + +const programSelect = `SELECT p.id,p.state_version,p.state,p.project_id,p.environment_id, + p.source_adapter,p.source_adapter_version,p.credential_id,p.authority_epoch_before, + p.stabilization_days,p.rollback_window_days,p.scope_digest,p.policy_digest,p.created_at,p.updated_at + FROM billing_migration_programs p` + +type rowScanner interface{ Scan(...any) error } + +func scanProgram(row rowScanner) (billingmigration.Program, error) { + var program billingmigration.Program + var scopeDigest, policyDigest []byte + err := row.Scan(&program.ProgramID, &program.StateVersion, &program.State, + &program.Scope.ProjectID, &program.Scope.EnvironmentID, &program.Source.Adapter, + &program.Source.AdapterVersion, &program.Source.CredentialReference, + &program.AuthorityEpochBefore, &program.StabilizationDays, &program.RollbackWindowDays, + &scopeDigest, &policyDigest, &program.CreatedAt, &program.UpdatedAt) + program.ScopeDigest, program.PolicyDigest = billingmigration.FormatDigest(scopeDigest), billingmigration.FormatDigest(policyDigest) + return program, err +} + +func (r *Repository) hydrate(ctx context.Context, program billingmigration.Program) (billingmigration.ProgramDetail, error) { + rows, err := r.pool.Query(ctx, `SELECT application_id,platform FROM billing_migration_program_scopes + WHERE program_id=$1 AND project_id=$2 ORDER BY application_id,platform`, program.ProgramID, program.Scope.ProjectID) + if err != nil { + return billingmigration.ProgramDetail{}, fmt.Errorf("read migration scopes: %w", err) + } + for rows.Next() { + var scope billingmigration.ScopeItem + if err := rows.Scan(&scope.ApplicationID, &scope.Platform); err != nil { + rows.Close() + return billingmigration.ProgramDetail{}, err + } + program.Scope.Applications = append(program.Scope.Applications, scope) + } + if err := rows.Err(); err != nil { + rows.Close() + return billingmigration.ProgramDetail{}, err + } + rows.Close() + detail := billingmigration.ProgramDetail{Program: program} + var assessment billingmigration.CapabilityAssessment + err = r.pool.QueryRow(ctx, `SELECT state_version,provider_api_version,capabilities,assessed_at + FROM billing_migration_capability_assessments WHERE program_id=$1 AND project_id=$2 + ORDER BY assessed_at DESC,id DESC LIMIT 1`, program.ProgramID, program.Scope.ProjectID). + Scan(&assessment.StateVersion, &assessment.ProviderAPIVersion, &assessment.Capabilities, &assessment.AssessedAt) + if err == nil { + assessment.ProgramID, assessment.Adapter = program.ProgramID, billingmigration.AdapterRevenueCat + detail.SourceCapabilityAssessment = &assessment + } else if !errors.Is(err, pgx.ErrNoRows) { + return detail, fmt.Errorf("read migration assessment: %w", err) + } + return detail, nil +} + +func translate(err error, operation string) error { + var postgresError *pgconn.PgError + if errors.As(err, &postgresError) { + switch postgresError.Code { + case "23505", "40001": + return billingmigration.ErrConflict + case "23503", "23514", "22001": + return billingmigration.ErrInvalid + } + } + return fmt.Errorf("%s: %w", operation, err) +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go new file mode 100644 index 00000000..a7cf93ea --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go @@ -0,0 +1,411 @@ +package billingmigrationpostgres_test + +import ( + "context" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +type assessor struct{} + +func (assessor) AssessMigration(context.Context, string, []byte) (billingmigration.CapabilityResult, error) { + return billingmigration.CapabilityResult{ProviderAPIVersion: "v2", Capabilities: []string{"read_customers"}, AssessedAt: time.Now().UTC()}, nil +} + +func TestMigration53DownRefusesCryptographicallyRemovedCredential(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err := goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err := goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + seedMigrationTenant(t, ctx, db) + if _, err := db.ExecContext(ctx, `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at,removed_at,removed_by_actor_id,removal_digest) VALUES('credential_removed_guard','project_one','revenuecat','removed','active',1,'AES-256-GCM','retired',NULL,NULL,decode(repeat('a1',32),'hex'),'owner_one',now(),now(),'owner_one',decode(repeat('a2',32),'hex'))`); err != nil { + t.Fatal(err) + } + err = goose.DownToContext(ctx, db, ".", 52) + if err == nil || !strings.Contains(err.Error(), "cannot rollback migration 00053: cryptographically removed billing migration credentials cannot restore ciphertext") { + t.Fatalf("rollback guard error = %v", err) + } + version, versionErr := goose.GetDBVersionContext(ctx, db) + if versionErr != nil || version != 53 { + t.Fatalf("guard left migration version=%d err=%v", version, versionErr) + } +} + +func TestProgramTransactionEnforcesTenantScopeAndEncryptedCredential(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err := goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err := goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("apply migrations: %v", err) + } + seedMigrationTenant(t, ctx, db) + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + key := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + cipher, err := providercredential.NewAESGCMCipher( + fmt.Sprintf(`{"version":1,"activeKeyId":"key_one","keys":{"key_one":"%s"}}`, key), zeroReader{}) + if err != nil { + t.Fatal(err) + } + repository := billingmigrationpostgres.New(pool) + service := billingmigration.NewService(repository, cipher, assessor{}) + created, replayed, err := service.CreateProgram(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.CreateProgramInput{ + ProjectID: "project_one", EnvironmentID: "environment_one", ExternalProjectID: "rc_project", + Credential: []byte("migration-secret"), IdempotencyKey: "program-create-1", + Applications: []billingmigration.ScopeItem{{ApplicationID: "app_one", Platform: "ios"}}, + }) + if err != nil || replayed { + t.Fatalf("create program: replayed=%v err=%v", replayed, err) + } + if created.Program.StateVersion != 1 || len(created.Program.Scope.Applications) != 1 { + t.Fatalf("program = %#v", created.Program) + } + if created.Program.State != billingmigration.StateMapping { + t.Fatalf("new program state = %q", created.Program.State) + } + if _, _, err := service.CreateProgram(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.CreateProgramInput{ProjectID: "project_one", EnvironmentID: "environment_one", ExternalProjectID: "rc_overlap", Credential: []byte("overlap-secret"), IdempotencyKey: "program-overlap", Applications: []billingmigration.ScopeItem{{ApplicationID: "app_one", Platform: "ios"}}}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("overlapping active migration scope error = %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) VALUES('app_unowned','project_one','Unowned Android','android','com.example.unowned',now(),now()); INSERT INTO billing_migration_authority_scopes(id,project_id,environment_id,application_id,platform,current_authority,current_epoch,active_program_id,authority_digest,updated_at) VALUES('authority_unowned','project_one','environment_one','app_unowned','android','source',0,NULL,decode(repeat('44',32),'hex'),now())`); err != nil { + t.Fatalf("seed unowned authority scope: %v", err) + } + claimed, _, err := service.CreateProgram(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.CreateProgramInput{ProjectID: "project_one", EnvironmentID: "environment_one", ExternalProjectID: "rc_unowned", Credential: []byte("unowned-secret"), IdempotencyKey: "program-unowned", Applications: []billingmigration.ScopeItem{{ApplicationID: "app_unowned", Platform: "android"}}}) + if err != nil { + t.Fatalf("claim unowned authority scope: %v", err) + } + var claimedProgramID *string + if err := pool.QueryRow(ctx, `SELECT active_program_id FROM billing_migration_authority_scopes WHERE id='authority_unowned'`).Scan(&claimedProgramID); err != nil || claimedProgramID == nil || *claimedProgramID != claimed.Program.ProgramID { + t.Fatalf("unowned authority claimant=%v err=%v", claimedProgramID, err) + } + if _, err := pool.Exec(ctx, `INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) VALUES('app_epoch_ios','project_one','Epoch iOS','ios','com.example.epoch.ios',now(),now()),('app_epoch_android','project_one','Epoch Android','android','com.example.epoch.android',now(),now()),('app_mixed_ios','project_one','Mixed iOS','ios','com.example.mixed.ios',now(),now()),('app_mixed_android','project_one','Mixed Android','android','com.example.mixed.android',now(),now()),('app_nonsource','project_one','Non-source','ios','com.example.nonsource',now(),now()); INSERT INTO billing_migration_authority_scopes(id,project_id,environment_id,application_id,platform,current_authority,current_epoch,active_program_id,authority_digest,updated_at) VALUES('authority_epoch_ios','project_one','environment_one','app_epoch_ios','ios','source',5,NULL,decode(repeat('51',32),'hex'),now()),('authority_epoch_android','project_one','environment_one','app_epoch_android','android','source',5,NULL,decode(repeat('52',32),'hex'),now()),('authority_mixed_ios','project_one','environment_one','app_mixed_ios','ios','source',2,NULL,decode(repeat('53',32),'hex'),now()),('authority_mixed_android','project_one','environment_one','app_mixed_android','android','source',3,NULL,decode(repeat('54',32),'hex'),now()),('authority_nonsource','project_one','environment_one','app_nonsource','ios','mosaic',4,NULL,decode(repeat('55',32),'hex'),now())`); err != nil { + t.Fatalf("seed reusable authority scopes: %v", err) + } + epochProgram, _, err := service.CreateProgram(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.CreateProgramInput{ProjectID: "project_one", EnvironmentID: "environment_one", ExternalProjectID: "rc_epoch", Credential: []byte("epoch-secret"), IdempotencyKey: "program-epoch", Applications: []billingmigration.ScopeItem{{ApplicationID: "app_epoch_ios", Platform: "ios"}, {ApplicationID: "app_epoch_android", Platform: "android"}}}) + if err != nil || epochProgram.Program.AuthorityEpochBefore != 5 { + t.Fatalf("reuse source epoch program=%#v err=%v", epochProgram.Program, err) + } + if _, _, err := service.CreateProgram(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.CreateProgramInput{ProjectID: "project_one", EnvironmentID: "environment_one", ExternalProjectID: "rc_mixed", Credential: []byte("mixed-secret"), IdempotencyKey: "program-mixed", Applications: []billingmigration.ScopeItem{{ApplicationID: "app_mixed_ios", Platform: "ios"}, {ApplicationID: "app_mixed_android", Platform: "android"}}}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("mixed authority epochs error=%v", err) + } + if _, _, err := service.CreateProgram(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.CreateProgramInput{ProjectID: "project_one", EnvironmentID: "environment_one", ExternalProjectID: "rc_nonsource", Credential: []byte("nonsource-secret"), IdempotencyKey: "program-nonsource", Applications: []billingmigration.ScopeItem{{ApplicationID: "app_nonsource", Platform: "ios"}}}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("non-source authority error=%v", err) + } + var authorityKind string + var authorityEpoch int64 + if err := pool.QueryRow(ctx, `SELECT current_authority,current_epoch FROM billing_migration_authority_scopes WHERE project_id='project_one' AND environment_id='environment_one' AND application_id='app_one' AND platform='ios'`).Scan(&authorityKind, &authorityEpoch); err != nil { + t.Fatalf("read initialized authority scope: %v", err) + } + if authorityKind != "source" || authorityEpoch != created.Program.AuthorityEpochBefore { + t.Fatalf("initialized authority = %s/%d", authorityKind, authorityEpoch) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) + VALUES('sync_valid',$1,'project_one','app_one','ios','2.0.0','2.0.0',ARRAY['2','3'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,$2,'accepted',decode(repeat('41',32),'hex'),now())`, created.Program.ProgramID, authorityEpoch); err != nil { + t.Fatalf("insert valid v2 sync observation: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) + VALUES('sync_duplicate',$1,'project_one','app_one','ios','2.0.0','2.0.0',ARRAY['2','2'],ARRAY['authority_epoch'],1,$2,'accepted',decode(repeat('42',32),'hex'),now())`, created.Program.ProgramID, authorityEpoch); err == nil { + t.Fatal("v2 sync observation accepted duplicate contract versions") + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) + VALUES('sync_unknown_capability',$1,'project_one','app_one','ios','2.0.0','2.0.0',ARRAY['2'],ARRAY['urgent_sync'],1,$2,'accepted',decode(repeat('43',32),'hex'),now())`, created.Program.ProgramID, authorityEpoch); err == nil { + t.Fatal("v2 sync observation accepted a non-contract authority capability") + } + + programs, err := service.ListPrograms(ctx, billingmigration.Actor{ID: "member_one"}, "project_one", 10) + if err != nil || len(programs) != 3 { + t.Fatalf("member list: programs=%d err=%v", len(programs), err) + } + if _, _, err := service.CreateProgram(ctx, billingmigration.Actor{ID: "member_one"}, billingmigration.CreateProgramInput{ + ProjectID: "project_one", EnvironmentID: "environment_one", ExternalProjectID: "rc_project", + Credential: []byte("other-secret"), IdempotencyKey: "member-create", + Applications: []billingmigration.ScopeItem{{ApplicationID: "app_one", Platform: "ios"}}, + }); err != billingmigration.ErrForbidden { + t.Fatalf("member create error = %v", err) + } + if _, err := service.AssessCurrentReadiness(ctx, billingmigration.Actor{ID: "member_one"}, "project_one", created.Program.ProgramID, 1); err != billingmigration.ErrForbidden { + t.Fatalf("member readiness assessment error = %v", err) + } + if _, err := repository.Authorize(ctx, billingmigration.Actor{ID: "admin_one"}, "project_one", billingmigration.CapabilityManageSource); err != billingmigration.ErrForbidden { + t.Fatalf("admin manage-source error = %v", err) + } + if _, err := repository.Authorize(ctx, billingmigration.Actor{ID: "admin_one"}, "project_one", billingmigration.CapabilityManageMappings); err != nil { + t.Fatalf("admin manage-mappings error = %v", err) + } + if _, err := repository.Authorize(ctx, billingmigration.Actor{ID: "admin_one"}, "project_one", billingmigration.CapabilityRunImport); err != nil { + t.Fatalf("admin run-import error = %v", err) + } + if _, err := repository.Authorize(ctx, billingmigration.Actor{ID: "admin_one"}, "project_one", billingmigration.CapabilityAssessReadiness); err != billingmigration.ErrForbidden { + t.Fatalf("admin assess-readiness error = %v", err) + } + if _, err := repository.Authorize(ctx, billingmigration.Actor{ID: "admin_one"}, "project_one", billingmigration.CapabilityResolveCases); err != nil { + t.Fatalf("admin resolve-cases error = %v", err) + } + for _, capability := range []string{billingmigration.CapabilityExecuteRepair, billingmigration.CapabilityDeleteSource, billingmigration.CapabilityRemoveCredential, billingmigration.CapabilityManageLegalHold, billingmigration.CapabilityCompleteMigration} { + if _, err := repository.Authorize(ctx, billingmigration.Actor{ID: "admin_one"}, "project_one", capability); err != billingmigration.ErrForbidden { + t.Fatalf("admin capability %s error = %v", capability, err) + } + if _, err := repository.Authorize(ctx, billingmigration.Actor{ID: "owner_one"}, "project_one", capability); err != nil { + t.Fatalf("owner capability %s error = %v", capability, err) + } + } + + var plaintextColumns int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns + WHERE table_name='billing_migration_credentials' AND column_name IN ('secret','api_key','credential')`).Scan(&plaintextColumns); err != nil { + t.Fatal(err) + } + if plaintextColumns != 0 { + t.Fatal("migration credential schema contains a plaintext credential column") + } + var ciphertext []byte + if err := pool.QueryRow(ctx, `SELECT ciphertext FROM billing_migration_credentials WHERE id=$1`, created.Program.Source.CredentialReference).Scan(&ciphertext); err != nil { + t.Fatal(err) + } + if string(ciphertext) == "migration-secret" { + t.Fatal("migration credential was stored in plaintext") + } + var unsafeSourceColumns int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns + WHERE table_name='billing_migration_source_records' + AND column_name IN ('normalized_record','raw_payload','purchase_token','credential')`).Scan(&unsafeSourceColumns); err != nil { + t.Fatal(err) + } + if unsafeSourceColumns != 0 { + t.Fatal("normalized source rows can persist provider payload or credential-shaped material") + } + if _, err := pool.Exec(ctx, ` + INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) + VALUES('environment_two','project_one','staging','Staging','staging',now(),now()); + INSERT INTO billing_migration_program_scopes(program_id,project_id,environment_id,application_id,platform,created_at) + VALUES($1,'project_one','environment_two','app_one','ios',now())`, created.Program.ProgramID); err == nil { + t.Fatal("program scope accepted an Environment different from the program Environment") + } + + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_source_manifests( + id,program_id,project_id,state_version,adapter_version,provider_api_version,schema_version, + record_count,current_access_record_count,object_key,object_checksum,object_size_bytes, + object_encryption,manifest_digest,captured_at) VALUES( + 'manifest_one',$1,'project_one',1,'adapter-v1','v2','schema-v1',5,5,'private/object', + decode(repeat('00',32),'hex'),0,'AES-256-GCM',decode(repeat('11',32),'hex'),now())`, created.Program.ProgramID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE billing_migration_source_manifests SET record_count=1 WHERE id='manifest_one'`); err == nil { + t.Fatal("immutable migration manifest accepted an update") + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_source_records( + id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,record_digest, + current_access,normalization_schema_version,evidence_kind,observed_at,created_at) VALUES + ('record_export',$1,'project_one','manifest_one','customer','customer-export','1',decode(repeat('30',32),'hex'),true,'v1','trusted_source_export',now(),now()), + ('record_wrong_kind',$1,'project_one','manifest_one','alias','customer-export','1',decode(repeat('35',32),'hex'),true,'v1','provider_validated',now(),now()), + ('record_api',$1,'project_one','manifest_one','customer','customer-api','1',decode(repeat('31',32),'hex'),true,'v1','trusted_provider_api',now(),now()), + ('record_signed',$1,'project_one','manifest_one','customer','customer-signed','1',decode(repeat('32',32),'hex'),true,'v1','provider_signed',now(),now()), + ('record_validated',$1,'project_one','manifest_one','customer','customer-validated','1',decode(repeat('33',32),'hex'),true,'v1','provider_validated',now(),now())`, created.Program.ProgramID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_source_records( + id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,record_digest, + current_access,normalization_schema_version,evidence_kind,observed_at,created_at) + VALUES('record_control',$1,'project_one','manifest_one','customer',E'bad\nidentifier','1',decode(repeat('34',32),'hex'),false,'v1','provider_validated',now(),now())`, created.Program.ProgramID); err == nil { + t.Fatal("source record accepted a control character in source_identifier") + } + now := time.Now().UTC() + if err := repository.CreateMappingSet(ctx, 1, billingmigration.MappingSetWrite{ + ProjectID: "project_one", MappingDigest: bytesOf(0x22), ActorID: "owner_one", CreatedAt: now, + MappingSet: billingmigration.MappingSet{ProgramID: created.Program.ProgramID, StateVersion: 1, + MappingSetID: "mapping_one", Version: 1, Status: "draft", Entries: []billingmigration.MappingEntry{{SourceKind: "customer_id", SourceIdentifier: "customer-export", TargetID: "customer_one", MatchKind: "exact"}}}, + }); err != nil { + t.Fatalf("create mapping set: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_mapping_entries( + id,mapping_set_id,program_id,project_id,source_kind,source_identifier,target_id,match_kind,created_at) + VALUES('mapping_control','mapping_one',$1,'project_one','customer_id',E'bad\tidentifier','customer_one','exact',now())`, created.Program.ProgramID); err == nil { + t.Fatal("mapping entry accepted a control character in source_identifier") + } + if _, err := repository.CreateImportBatch(ctx, 1, billingmigration.ImportBatchWrite{ProjectID: "project_one", ManifestID: "manifest_one", MappingSetID: "mapping_one", RequestDigest: bytesOf(0x30), CreatedAt: now, Batch: billingmigration.ImportBatch{ProgramID: created.Program.ProgramID, StateVersion: 1, BatchID: "batch_too_early", IdempotencyKey: "batch-too-early", RecordCount: 1}}); err != billingmigration.ErrConflict { + t.Fatalf("import before mapping freeze error = %v", err) + } + if err := repository.FreezeMappingSet(ctx, "project_one", created.Program.ProgramID, "mapping_one", 1, now); err != nil { + t.Fatalf("freeze mapping set: %v", err) + } + if err := repository.CreateMappingSet(ctx, 2, billingmigration.MappingSetWrite{ProjectID: "project_one", MappingDigest: bytesOf(0x24), ActorID: "owner_one", CreatedAt: now, MappingSet: billingmigration.MappingSet{ProgramID: created.Program.ProgramID, StateVersion: 2, MappingSetID: "mapping_too_late", Version: 2, Status: "draft"}}); err != billingmigration.ErrConflict { + t.Fatalf("mapping after freeze error = %v", err) + } + replayedBatch, err := repository.CreateImportBatch(ctx, 2, billingmigration.ImportBatchWrite{ + ProjectID: "project_one", ManifestID: "manifest_one", MappingSetID: "mapping_one", + RequestDigest: bytesOf(0x33), CreatedAt: now, + Batch: billingmigration.ImportBatch{ProgramID: created.Program.ProgramID, StateVersion: 2, + BatchID: "batch_one", IdempotencyKey: "batch-key", RecordCount: 10}, + }) + if err != nil || replayedBatch { + t.Fatalf("create import batch: replay=%v err=%v", replayedBatch, err) + } + batch, leased, err := repository.LeaseImportBatch(ctx, "worker_one", now, now.Add(time.Minute)) + if err != nil || !leased { + t.Fatalf("lease import batch: leased=%v err=%v", leased, err) + } + if err := repository.CompleteImportBatch(ctx, "project_one", created.Program.ProgramID, batch.BatchID, + "worker_one", batch.LeaseGeneration-1, "cursor-10", 9, 1, now.Add(time.Second)); err != billingmigration.ErrConflict { + t.Fatalf("stale lease completion error = %v", err) + } + if err := repository.CompleteImportBatch(ctx, "project_one", created.Program.ProgramID, batch.BatchID, + "worker_one", batch.LeaseGeneration, "cursor-10", 9, 1, now.Add(time.Second)); err != nil { + t.Fatalf("complete import batch: %v", err) + } + if _, _, err := service.QueueRun(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.QueueRunInput{ProjectID: "project_one", ProgramID: created.Program.ProgramID, RunKind: "shadow", IdempotencyKey: "shadow-too-early", ExpectedStateVersion: 2, ManifestDigest: billingmigration.FormatDigest(bytesOf(0x11)), MappingDigest: billingmigration.FormatDigest(bytesOf(0x22))}); err != billingmigration.ErrConflict { + t.Fatalf("shadow before dry run error = %v", err) + } + runInput := billingmigration.QueueRunInput{ProjectID: "project_one", ProgramID: created.Program.ProgramID, + RunKind: "dry_run", IdempotencyKey: "dry-run-one", ExpectedStateVersion: 2, + ManifestDigest: billingmigration.FormatDigest(bytesOf(0x11)), MappingDigest: billingmigration.FormatDigest(bytesOf(0x22))} + runJob, replayedRun, err := service.QueueRun(ctx, billingmigration.Actor{ID: "owner_one"}, runInput) + if err != nil || replayedRun { + t.Fatalf("queue dry run: replay=%v err=%v", replayedRun, err) + } + replayedJob, replayedRun, err := service.QueueRun(ctx, billingmigration.Actor{ID: "owner_one"}, runInput) + if err != nil || !replayedRun || replayedJob.RunJobID != runJob.RunJobID { + t.Fatalf("replay dry run: first=%s replay=%#v replayed=%v err=%v", runJob.RunJobID, replayedJob, replayedRun, err) + } + if _, err := service.AssessCurrentReadiness(ctx, billingmigration.Actor{ID: "owner_one"}, "project_one", created.Program.ProgramID, 3); err != billingmigration.ErrConflict { + t.Fatalf("readiness before shadow error = %v", err) + } + if _, _, err := service.QueueRun(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.QueueRunInput{ProjectID: "project_one", ProgramID: created.Program.ProgramID, RunKind: "shadow", IdempotencyKey: "shadow-one", ExpectedStateVersion: 3, ManifestDigest: billingmigration.FormatDigest(bytesOf(0x11)), MappingDigest: billingmigration.FormatDigest(bytesOf(0x22))}); err != nil { + t.Fatalf("queue shadow run: %v", err) + } + readiness, err := service.AssessCurrentReadiness(ctx, billingmigration.Actor{ID: "owner_one"}, "project_one", created.Program.ProgramID, 4) + if err != nil || readiness.Ready { + t.Fatalf("stage 2B readiness = %#v err=%v", readiness, err) + } + if readiness.CurrentAccessEvidencePercent != 60 { + t.Fatalf("provider evidence percentage = %v, want signed+validated only", readiness.CurrentAccessEvidencePercent) + } + if readiness.CurrentAccessMappingPercent != 20 { + t.Fatalf("mapping percentage = %v, wrong-kind identifier collision counted as coverage", readiness.CurrentAccessMappingPercent) + } + if _, err := billingmigration.ParseDigest(readiness.ReadinessDigest); err != nil { + t.Fatalf("readiness digest is not frozen sha256 form: %q", readiness.ReadinessDigest) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_cutover_proposals(id,program_id,project_id,state_version,command,proposer_actor_id,reason,scope_digest,manifest_digest,mapping_digest,policy_digest,evidence_digest,readiness_digest,final_watermark_digest,application_version_digest,proposal_digest,status,proposed_at,expires_at) + VALUES('proposal_two_person',$1,'project_one',4,'cutover','owner_one','reviewed',decode(repeat('50',32),'hex'),decode(repeat('51',32),'hex'),decode(repeat('52',32),'hex'),decode(repeat('53',32),'hex'),decode(repeat('54',32),'hex'),decode(repeat('55',32),'hex'),decode(repeat('56',32),'hex'),decode(repeat('57',32),'hex'),decode(repeat('58',32),'hex'),'pending',now(),now()+interval '1 hour')`, created.Program.ProgramID); err != nil { + t.Fatalf("insert approval fixture: %v", err) + } + approvalSQL := `INSERT INTO billing_migration_approvals(id,program_id,project_id,proposal_id,state_version,command,proposer_actor_id,approver_actor_id,approval_digest,approved_at,expires_at) + VALUES($1,$2,'project_one','proposal_two_person',4,'cutover','owner_one','owner_one',decode(repeat('59',32),'hex'),now(),now()+interval '1 hour')` + if _, err := pool.Exec(ctx, approvalSQL, "approval_same_actor_production", created.Program.ProgramID); err == nil { + t.Fatal("production approval accepted the proposer as approver") + } + if _, err := pool.Exec(ctx, `UPDATE environments SET mode='staging' WHERE id='environment_one' AND project_id='project_one'`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, approvalSQL, "approval_same_actor_staging", created.Program.ProgramID); err != nil { + t.Fatalf("nonproduction approval rejected self-approval: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_cases(id,program_id,project_id,state_version,classification,status,reason,case_digest,opened_at) VALUES('case_distinct_exception',$1,'project_one',4,'blocking','in_progress','reviewed',decode(repeat('92',32),'hex'),now())`, created.Program.ProgramID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_source_access_exceptions(id,case_id,program_id,project_id,application_id,platform,reason,affected_customer_count,rollback_treatment,identity_ambiguity_count,proposer_actor_id,approver_actor_id,approved_at,expires_at,exception_digest) VALUES('exception_same_actor','case_distinct_exception',$1,'project_one','app_one','ios','reviewed',1,'restore source',0,'owner_one','owner_one',now(),now()+interval '1 hour',decode(repeat('93',32),'hex'))`, created.Program.ProgramID); err == nil { + t.Fatal("nonproduction source-access exception accepted identical proposer and approver") + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_bad',$1,'project_one',4,now(),now(),now()+interval '1 day',true,now(),false,now()+interval '30 days',decode(repeat('94',32),'hex'))`, created.Program.ProgramID); err == nil { + t.Fatal("completion accepted completion/credential removal before rollback window ended") + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_bad_stabilization',$1,'project_one',4,now(),now()+interval '1 second',now(),true,now(),false,now()+interval '30 days',decode(repeat('99',32),'hex'))`, created.Program.ProgramID); err == nil { + t.Fatal("completion accepted completed_at before stabilization ended") + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_bad_delete',$1,'project_one',4,now(),now(),now(),true,now(),false,now()+interval '31 days',decode(repeat('95',32),'hex'))`, created.Program.ProgramID); err == nil { + t.Fatal("completion accepted a non-deterministic source-object deletion time") + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_valid',$1,'project_one',4,now(),now()-interval '1 day',now(),true,now(),false,now()+interval '30 days',decode(repeat('96',32),'hex'))`, created.Program.ProgramID); err != nil { + t.Fatalf("valid completion timing rejected: %v", err) + } +} + +func seedMigrationTenant(t *testing.T, ctx context.Context, db *sql.DB) { + t.Helper() + _, err := db.ExecContext(ctx, ` + INSERT INTO organizations(id,name,created_at,updated_at) VALUES('org_one','One',now(),now()); + INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) VALUES + ('org_one','owner_one','owner',now(),now()),('org_one','admin_one','admin',now(),now()), + ('org_one','member_one','member',now(),now()); + INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES('project_one','org_one','one','One','active',now(),now()); + INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) + VALUES('environment_one','project_one','production','Production','production',now(),now()); + INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES('app_one','project_one','App','ios','com.example.app',now(),now()); + INSERT INTO billing_customers(id,project_id,status,created_at,updated_at) + VALUES('customer_one','project_one','active',now(),now()); + `) + if err != nil { + t.Fatal(err) + } +} + +type zeroReader struct{} + +func (zeroReader) Read(buffer []byte) (int, error) { + for index := range buffer { + buffer[index] = 0 + } + return len(buffer), nil +} + +func bytesOf(value byte) []byte { + result := make([]byte, 32) + for index := range result { + result[index] = value + } + return result +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/rollback_readiness.go b/apps/api/internal/platform/billingmigrationpostgres/rollback_readiness.go new file mode 100644 index 00000000..5fe4079d --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/rollback_readiness.go @@ -0,0 +1,228 @@ +package billingmigrationpostgres + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +func (r *Repository) AssessRollbackReadiness(ctx context.Context, c billingmigration.AssessRollbackReadinessCommand) (billingmigration.RollbackReadinessAssessment, billingmigration.RollbackReadinessCheckpoint, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + resource, replay, err := operationCommandReplay(ctx, tx, c.Input.ProgramID, "rollback_readiness", c.Input.IdempotencyKey, c.RequestDigest) + if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + if replay { + a, err := scanRollbackAssessment(tx.QueryRow(ctx, rollbackAssessmentSelect+` WHERE a.id=$1 AND a.project_id=$2`, resource, c.Input.ProjectID)) + if err != nil { + return a, billingmigration.RollbackReadinessCheckpoint{}, true, err + } + checkpoint, cerr := scanRollbackCheckpoint(tx.QueryRow(ctx, rollbackCheckpointSelect+` WHERE c.assessment_id=$1 AND c.project_id=$2`, resource, c.Input.ProjectID)) + if errors.Is(cerr, billingmigration.ErrNotFound) { + cerr = nil + } + return a, checkpoint, true, cerr + } + var state, environment, organization string + var version int64 + var scopeRaw []byte + var now time.Time + if err = tx.QueryRow(ctx, `SELECT p.state,p.state_version,p.environment_id,pr.organization_id,p.scope_digest,clock_timestamp() FROM billing_migration_programs p JOIN projects pr ON pr.id=p.project_id WHERE p.id=$1 AND p.project_id=$2 FOR UPDATE OF p`, c.Input.ProgramID, c.Input.ProjectID).Scan(&state, &version, &environment, &organization, &scopeRaw, &now); errors.Is(err, pgx.ErrNoRows) { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, billingmigration.ErrNotFound + } else if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + if state != billingmigration.StateStabilizing || version != c.Input.ExpectedStateVersion { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, billingmigration.ErrStaleState + } + var observationID string + var observationDigest, policyDigest []byte + var observationHealthy bool + var oldVersions int64 + if err = tx.QueryRow(ctx, `SELECT o.id,o.evidence_digest,p.policy_digest,o.healthy,o.old_app_versions FROM billing_migration_stabilization_observations o JOIN billing_migration_stabilization_policies p ON p.id=o.policy_id WHERE o.program_id=$1 AND o.project_id=$2 ORDER BY o.observed_at DESC,o.id DESC LIMIT 1`, c.Input.ProgramID, c.Input.ProjectID).Scan(&observationID, &observationDigest, &policyDigest, &observationHealthy, &oldVersions); errors.Is(err, pgx.ErrNoRows) { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, billingmigration.ErrRollbackPrerequisite + } else if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + if observationID != c.Input.ObservationID || !bytes.Equal(observationDigest, c.ExpectedObservationDigest) { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, billingmigration.ErrStaleDigest + } + var deltaID string + var deltaDigest []byte + var deltaAt, sourceAt time.Time + if err = tx.QueryRow(ctx, `SELECT id,delta_digest,completed_at,source_watermark FROM billing_migration_final_deltas WHERE program_id=$1 AND project_id=$2 ORDER BY completed_at DESC,id DESC LIMIT 1`, c.Input.ProgramID, c.Input.ProjectID).Scan(&deltaID, &deltaDigest, &deltaAt, &sourceAt); errors.Is(err, pgx.ErrNoRows) { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, billingmigration.ErrRollbackPrerequisite + } else if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + var credentialID string + var capabilityOK, pullOK bool + var capabilityDigest, pullDigest, manifestDigest []byte + var impact int64 + var pullCompletedAt time.Time + if err = tx.QueryRow(ctx, `SELECT + COALESCE((SELECT p.credential_id FROM billing_migration_programs p JOIN billing_migration_credentials x ON x.id=p.credential_id AND x.project_id=p.project_id WHERE p.id=$1 AND p.project_id=$2 AND x.status='active' AND x.removed_at IS NULL AND x.nonce IS NOT NULL AND x.ciphertext IS NOT NULL),''), + COALESCE((SELECT provider_api_version='v2' AND capabilities @> ARRAY['read_customers','read_subscriptions','read_aliases','incremental_delta']::text[] FROM billing_migration_capability_assessments WHERE program_id=$1 AND project_id=$2 ORDER BY assessed_at DESC,id DESC LIMIT 1),false), + COALESCE((SELECT assessment_digest FROM billing_migration_capability_assessments WHERE program_id=$1 AND project_id=$2 ORDER BY assessed_at DESC,id DESC LIMIT 1),decode(repeat('00',32),'hex')), + EXISTS(SELECT 1 FROM billing_migration_source_pull_jobs p JOIN billing_migration_source_pull_jobs predecessor ON predecessor.id=p.predecessor_pull_job_id AND predecessor.program_id=p.program_id AND predecessor.project_id=p.project_id JOIN billing_migration_final_delta_jobs j ON j.id=p.result_final_delta_job_id AND j.program_id=p.program_id AND j.project_id=p.project_id WHERE p.id=(SELECT id FROM billing_migration_source_pull_jobs WHERE program_id=$1 AND project_id=$2 AND intent='final_delta' AND status='completed' ORDER BY completed_at DESC,id DESC LIMIT 1) AND predecessor.status='completed' AND p.starting_cursor=predecessor.resume_cursor AND p.starting_watermark=predecessor.final_watermark AND p.starting_watermark_digest=predecessor.evidence_digest AND j.status='completed' AND j.result_final_delta_id=$3), + COALESCE((SELECT evidence_digest FROM billing_migration_source_pull_jobs WHERE program_id=$1 AND project_id=$2 AND intent='final_delta' AND status='completed' ORDER BY completed_at DESC,id DESC LIMIT 1),decode(repeat('00',32),'hex')), + COALESCE((SELECT m.manifest_digest FROM billing_migration_source_pull_jobs p JOIN billing_migration_source_manifests m ON m.id=p.result_manifest_id AND m.program_id=p.program_id AND m.project_id=p.project_id WHERE p.program_id=$1 AND p.project_id=$2 AND p.intent='final_delta' AND p.status='completed' ORDER BY p.completed_at DESC,p.id DESC LIMIT 1),decode(repeat('00',32),'hex')), + COALESCE((SELECT current_access_count FROM billing_migration_source_pull_jobs WHERE program_id=$1 AND project_id=$2 AND intent='final_delta' AND status='completed' ORDER BY completed_at DESC,id DESC LIMIT 1),0), + COALESCE((SELECT completed_at FROM billing_migration_source_pull_jobs WHERE program_id=$1 AND project_id=$2 AND intent='final_delta' AND status='completed' ORDER BY completed_at DESC,id DESC LIMIT 1),to_timestamp(0))`, c.Input.ProgramID, c.Input.ProjectID, deltaID).Scan(&credentialID, &capabilityOK, &capabilityDigest, &pullOK, &pullDigest, &manifestDigest, &impact, &pullCompletedAt); err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + // Require fresh accepted current-epoch SDK evidence for every exact scope. + var scopeCount, compatibleScopeCount int64 + if err = tx.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE EXISTS(SELECT 1 FROM billing_migration_v2_sync_observations s WHERE s.program_id=ps.program_id AND s.project_id=ps.project_id AND s.application_id=ps.application_id AND s.platform=ps.platform AND s.authority_epoch=$3 AND s.sync_result='accepted' AND s.observed_at>=$4)) FROM billing_migration_program_scopes ps WHERE ps.program_id=$1 AND ps.project_id=$2`, c.Input.ProgramID, c.Input.ProjectID, c.Input.ExpectedAuthorityEpoch, sourceAt).Scan(&scopeCount, &compatibleScopeCount); err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + cutoverCompatible, err := versionReadinessTx(ctx, tx, c.Input.ProjectID, c.Input.ProgramID) + if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + applicationCompatible := cutoverCompatible && scopeCount > 0 && scopeCount == compatibleScopeCount && oldVersions == 0 + authorityRows, err := tx.Query(ctx, `SELECT a.authority_digest,a.current_epoch,a.current_authority,a.active_program_id FROM billing_migration_program_scopes ps JOIN billing_migration_authority_scopes a ON a.project_id=ps.project_id AND a.environment_id=ps.environment_id AND a.application_id=ps.application_id AND a.platform=ps.platform WHERE ps.program_id=$1 AND ps.project_id=$2 ORDER BY ps.application_id,ps.platform`, c.Input.ProgramID, c.Input.ProjectID) + if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + var authorityParts []string + authorityOK := true + for authorityRows.Next() { + var raw []byte + var epoch int64 + var authority string + var active *string + if err = authorityRows.Scan(&raw, &epoch, &authority, &active); err != nil { + authorityRows.Close() + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + authorityParts = append(authorityParts, billingmigration.FormatDigest(raw)) + authorityOK = authorityOK && epoch == c.Input.ExpectedAuthorityEpoch && authority == "mosaic" && active != nil && *active == c.Input.ProgramID + } + authorityRows.Close() + if len(authorityParts) == 0 || !authorityOK { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, billingmigration.ErrStaleAuthority + } + authorityDigest, err := billingmigration.AuthoritySetDigest(c.Input.ProgramID, billingmigration.FormatDigest(scopeRaw), authorityParts) + if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + authorityRaw, _ := billingmigration.ParseDigest(authorityDigest) + // Current-access evidence is the sorted immutable source-record digest set. + recordRows, err := tx.Query(ctx, `SELECT s.record_digest FROM billing_migration_source_records s WHERE s.program_id=$1 AND s.project_id=$2 AND s.current_access AND s.manifest_id=(SELECT result_manifest_id FROM billing_migration_source_pull_jobs WHERE program_id=$1 AND project_id=$2 AND intent='final_delta' AND status='completed' ORDER BY completed_at DESC,id DESC LIMIT 1) ORDER BY s.record_digest`, c.Input.ProgramID, c.Input.ProjectID) + if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + parts := [][]byte{manifestDigest} + for recordRows.Next() { + var raw []byte + if err = recordRows.Scan(&raw); err != nil { + recordRows.Close() + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + parts = append(parts, raw) + } + recordRows.Close() + sourceAccessDigest := stabilizationDigest("mosaic-migration-rollback-source-access-v1", parts) + credentialOK := credentialID != "" + // Capability assessments append only for strict supersets. The exact latest + // completed final pull therefore supplies freshness for an unchanged active + // program credential while the latest immutable assessment supplies the + // complete capability set. + sourceSupport := credentialOK && capabilityOK && pullOK + sourceHealthy := sourceSupport && !sourceAt.After(now) && !deltaAt.After(now) && !pullCompletedAt.After(now) && !pullCompletedAt.Before(deltaAt) + limitationsBlocking := !observationHealthy || !sourceSupport || !sourceHealthy || !applicationCompatible + sourceHealthDigest := stabilizationDigest("mosaic-migration-rollback-source-health-v1", struct { + CredentialID string + Credential, Capability, Pull bool + CapabilityDigest, PullDigest []byte + SourceAt, DeltaAt, PullAt time.Time + }{credentialID, credentialOK, capabilityOK, pullOK, capabilityDigest, pullDigest, sourceAt, deltaAt, pullCompletedAt}) + impactDigest := stabilizationDigest("mosaic-migration-rollback-impact-v1", struct { + Program string + Count int64 + Source []byte + }{c.Input.ProgramID, impact, sourceAccessDigest}) + compatibilityDigest := stabilizationDigest("mosaic-migration-rollback-app-compatibility-v1", struct{ Scopes, Covered, Old, Epoch int64 }{scopeCount, compatibleScopeCount, oldVersions, c.Input.ExpectedAuthorityEpoch}) + limitationDigest := stabilizationDigest("mosaic-migration-rollback-limitations-v1", struct{ Stable, Support, Health, Compatible bool }{observationHealthy, sourceSupport, sourceHealthy, applicationCompatible}) + auditDigest := stabilizationDigest("mosaic-migration-rollback-readiness-audit-v1", struct { + Program, Actor string + At time.Time + }{c.Input.ProgramID, c.ActorID, now.UTC()}) + ready := !limitationsBlocking + readinessRaw := stabilizationDigest("mosaic-migration-rollback-readiness-v1", struct { + Program, Observation, Delta string + Version, Epoch, Impact int64 + ObservationDigest, SourceHealth, SourceAccess, DeltaDigest, ImpactDigest, Compatibility, Limitations, Audit []byte + Ready bool + }{c.Input.ProgramID, observationID, deltaID, version, c.Input.ExpectedAuthorityEpoch, impact, observationDigest, sourceHealthDigest, sourceAccessDigest, deltaDigest, impactDigest, compatibilityDigest, limitationDigest, auditDigest, ready}) + a := billingmigration.RollbackReadinessAssessment{ID: stabilizationID("mra", readinessRaw), ProgramID: c.Input.ProgramID, ProjectID: c.Input.ProjectID, ObservationID: observationID, LatestDeltaID: deltaID, ReadinessDigest: billingmigration.FormatDigest(readinessRaw), StateVersion: version, SourceSupportAvailable: sourceSupport, SourceHealthy: sourceHealthy, ApplicationCompatible: applicationCompatible, LimitationsBlocking: limitationsBlocking, Ready: ready, CustomerImpactCount: impact, AssessedAt: now.UTC()} + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_rollback_readiness_assessments(id,program_id,project_id,observation_id,state_version,source_support_available,source_healthy,source_health_digest,source_current_access_digest,source_current_access_at,latest_delta_id,latest_delta_digest,customer_impact_count,customer_impact_digest,application_compatible,application_compatibility_digest,limitations_blocking,limitation_report_digest,audit_digest,stabilization_healthy,ready,readiness_digest,assessed_by_actor_id,assessed_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)`, a.ID, a.ProgramID, a.ProjectID, a.ObservationID, a.StateVersion, a.SourceSupportAvailable, a.SourceHealthy, sourceHealthDigest, sourceAccessDigest, pullCompletedAt, deltaID, deltaDigest, a.CustomerImpactCount, impactDigest, a.ApplicationCompatible, compatibilityDigest, a.LimitationsBlocking, limitationDigest, auditDigest, observationHealthy, a.Ready, readinessRaw, c.ActorID, a.AssessedAt) + if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, translate(err, "append rollback readiness") + } + var checkpoint billingmigration.RollbackReadinessCheckpoint + if ready { + checkpointRaw := stabilizationDigest("mosaic-migration-rollback-readiness-checkpoint-v1", struct { + Program, Assessment string + Version, Epoch int64 + Authority, Policy, Evidence, Readiness []byte + }{a.ProgramID, a.ID, version, c.Input.ExpectedAuthorityEpoch, authorityRaw, policyDigest, observationDigest, readinessRaw}) + checkpoint = billingmigration.RollbackReadinessCheckpoint{ID: stabilizationID("mrc", checkpointRaw), ProgramID: a.ProgramID, ProjectID: a.ProjectID, AssessmentID: a.ID, AuthorityDigest: authorityDigest, PolicyDigest: billingmigration.FormatDigest(policyDigest), EvidenceDigest: billingmigration.FormatDigest(observationDigest), ReadinessDigest: a.ReadinessDigest, CheckpointDigest: billingmigration.FormatDigest(checkpointRaw), StateVersion: version, AuthorityEpoch: c.Input.ExpectedAuthorityEpoch, CreatedAt: a.AssessedAt} + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_rollback_readiness_checkpoints(id,program_id,project_id,assessment_id,assessment_ready,state_version,authority_epoch,authority_digest,policy_digest,evidence_digest,readiness_digest,checkpoint_digest,created_by_actor_id,created_at) VALUES($1,$2,$3,$4,true,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, checkpoint.ID, checkpoint.ProgramID, checkpoint.ProjectID, checkpoint.AssessmentID, checkpoint.StateVersion, checkpoint.AuthorityEpoch, authorityRaw, policyDigest, observationDigest, readinessRaw, checkpointRaw, c.ActorID, checkpoint.CreatedAt) + if err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, translate(err, "append rollback checkpoint") + } + } + if _, err = tx.Exec(ctx, `INSERT INTO billing_migration_command_idempotency(id,program_id,project_id,command_kind,idempotency_key,request_digest,resource_id,created_at) VALUES('mci_'||$1,$2,$3,'rollback_readiness',$4,$5,$1,$6)`, a.ID, a.ProgramID, a.ProjectID, c.Input.IdempotencyKey, c.RequestDigest, a.AssessedAt); err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + metadata, _ := json.Marshal(map[string]any{"ready": ready, "assessmentId": a.ID, "checkpointId": checkpoint.ID, "sourceSupportAvailable": sourceSupport, "limitationsBlocking": limitationsBlocking}) + if _, err = tx.Exec(ctx, `INSERT INTO audit_events(id,actor_id,organization_id,project_id,environment_id,action,resource_type,resource_id,metadata,created_at) VALUES('aud_'||$1,$2,$3,$4,$5,'billing.migration.rollback.readiness.assessed','billing_migration_program',$6,$7,$8)`, a.ID, c.ActorID, organization, a.ProjectID, environment, a.ProgramID, metadata, a.AssessedAt); err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.RollbackReadinessAssessment{}, billingmigration.RollbackReadinessCheckpoint{}, false, err + } + return a, checkpoint, false, nil +} + +const rollbackAssessmentSelect = `SELECT a.id,a.program_id,a.project_id,a.observation_id,a.latest_delta_id,a.readiness_digest,a.state_version,a.source_support_available,a.source_healthy,a.application_compatible,a.limitations_blocking,a.ready,a.customer_impact_count,a.assessed_at FROM billing_migration_rollback_readiness_assessments a` + +func scanRollbackAssessment(row pgx.Row) (billingmigration.RollbackReadinessAssessment, error) { + var a billingmigration.RollbackReadinessAssessment + var readiness []byte + err := row.Scan(&a.ID, &a.ProgramID, &a.ProjectID, &a.ObservationID, &a.LatestDeltaID, &readiness, &a.StateVersion, &a.SourceSupportAvailable, &a.SourceHealthy, &a.ApplicationCompatible, &a.LimitationsBlocking, &a.Ready, &a.CustomerImpactCount, &a.AssessedAt) + if errors.Is(err, pgx.ErrNoRows) { + return a, billingmigration.ErrNotFound + } + a.ReadinessDigest = billingmigration.FormatDigest(readiness) + return a, err +} + +const rollbackCheckpointSelect = `SELECT c.id,c.program_id,c.project_id,c.assessment_id,c.authority_digest,c.policy_digest,c.evidence_digest,c.readiness_digest,c.checkpoint_digest,c.state_version,c.authority_epoch,c.created_at FROM billing_migration_rollback_readiness_checkpoints c` + +func scanRollbackCheckpoint(row pgx.Row) (billingmigration.RollbackReadinessCheckpoint, error) { + var c billingmigration.RollbackReadinessCheckpoint + var authority, policy, evidence, readiness, checkpoint []byte + err := row.Scan(&c.ID, &c.ProgramID, &c.ProjectID, &c.AssessmentID, &authority, &policy, &evidence, &readiness, &checkpoint, &c.StateVersion, &c.AuthorityEpoch, &c.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return c, billingmigration.ErrNotFound + } + c.AuthorityDigest = billingmigration.FormatDigest(authority) + c.PolicyDigest = billingmigration.FormatDigest(policy) + c.EvidenceDigest = billingmigration.FormatDigest(evidence) + c.ReadinessDigest = billingmigration.FormatDigest(readiness) + c.CheckpointDigest = billingmigration.FormatDigest(checkpoint) + return c, err +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/source_execution.go b/apps/api/internal/platform/billingmigrationpostgres/source_execution.go new file mode 100644 index 00000000..27726683 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/source_execution.go @@ -0,0 +1,609 @@ +package billingmigrationpostgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +var _ billingmigration.SourceObjectRepository = (*Repository)(nil) +var _ billingmigration.SourceExecutionRepository = (*Repository)(nil) + +func (r *Repository) ReserveSourceObject(ctx context.Context, write billingmigration.ReserveSourceObject) (billingmigration.SourceObject, bool, error) { + o := write.SourceObject + command, err := r.pool.Exec(ctx, `INSERT INTO billing_migration_source_objects(id,program_id,project_id,reservation_key,reservation_digest,reservation_generation,write_token_digest,object_key,source_channel,adapter_version,schema_version,state,reserved_at) + SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'reserved',$12 + WHERE EXISTS(SELECT 1 FROM billing_migration_programs WHERE id=$2 AND project_id=$3) + ON CONFLICT(program_id,reservation_key) DO NOTHING`, o.ObjectID, o.ProgramID, o.ProjectID, o.ReservationKey, o.ReservationDigest, o.ReservationGeneration, o.WriteTokenDigest, o.ObjectKey, o.SourceChannel, o.AdapterVersion, o.SchemaVersion, write.Now) + if err != nil { + return billingmigration.SourceObject{}, false, translate(err, "reserve migration source object") + } + var result billingmigration.SourceObject + var envelopeVersion, chunkSize, chunkCount *int + var algorithm, keyID, errorCode *string + var verifiedAt *time.Time + var nonce, aad, plainDigest, cipherDigest []byte + var plainSize, cipherSize *int64 + err = r.pool.QueryRow(ctx, `SELECT id,program_id,project_id,reservation_key,reservation_digest,reservation_generation,write_token_digest,object_key,source_channel,adapter_version,schema_version,state,error_code,reserved_at,verified_at,envelope_version,algorithm,key_id,nonce,chunk_size,chunk_count,aad_digest,plaintext_digest,plaintext_size_bytes,ciphertext_digest,ciphertext_size_bytes FROM billing_migration_source_objects WHERE program_id=$1 AND project_id=$2 AND reservation_key=$3`, o.ProgramID, o.ProjectID, o.ReservationKey).Scan(&result.ObjectID, &result.ProgramID, &result.ProjectID, &result.ReservationKey, &result.ReservationDigest, &result.ReservationGeneration, &result.WriteTokenDigest, &result.ObjectKey, &result.SourceChannel, &result.AdapterVersion, &result.SchemaVersion, &result.State, &errorCode, &result.ReservedAt, &verifiedAt, &envelopeVersion, &algorithm, &keyID, &nonce, &chunkSize, &chunkCount, &aad, &plainDigest, &plainSize, &cipherDigest, &cipherSize) + if errors.Is(err, pgx.ErrNoRows) { + return result, false, billingmigration.ErrInvalid + } + if err != nil { + return result, false, err + } + if string(result.ReservationDigest) != string(o.ReservationDigest) || result.ObjectKey != o.ObjectKey || result.SourceChannel != o.SourceChannel || result.AdapterVersion != o.AdapterVersion || result.SchemaVersion != o.SchemaVersion { + return result, false, billingmigration.ErrIdempotencyConflict + } + if errorCode != nil { + result.ErrorCode = *errorCode + } + if verifiedAt != nil { + result.VerifiedAt = *verifiedAt + } + if envelopeVersion != nil { + result.Envelope = billingmigration.SourceObjectEnvelope{Version: *envelopeVersion, Algorithm: *algorithm, KeyID: *keyID, Nonce: nonce, ChunkSize: *chunkSize, ChunkCount: *chunkCount, AADDigest: aad, PlaintextDigest: plainDigest, PlaintextSize: *plainSize, CiphertextDigest: cipherDigest, CiphertextSize: *cipherSize} + } + return result, command.RowsAffected() == 0, nil +} + +func (r *Repository) VerifySourceObject(ctx context.Context, write billingmigration.VerifySourceObject) error { + e := write.Envelope + command, err := r.pool.Exec(ctx, `UPDATE billing_migration_source_objects SET state='verified',envelope_version=$6,algorithm=$7,key_id=$8,nonce=$9,chunk_size=$10,chunk_count=$11,aad_digest=$12,plaintext_digest=$13,plaintext_size_bytes=$14,ciphertext_digest=$15,ciphertext_size_bytes=$16,verified_at=$17,error_code=NULL WHERE id=$1 AND program_id=$2 AND project_id=$3 AND state='reserved' AND reservation_generation=$4 AND write_token_digest=$5`, write.ObjectID, write.ProgramID, write.ProjectID, write.ReservationGeneration, write.WriteTokenDigest, e.Version, e.Algorithm, e.KeyID, e.Nonce, e.ChunkSize, e.ChunkCount, e.AADDigest, e.PlaintextDigest, e.PlaintextSize, e.CiphertextDigest, e.CiphertextSize, write.Now) + if err != nil { + return translate(err, "verify migration source object") + } + if command.RowsAffected() != 1 { + return billingmigration.ErrConflict + } + return nil +} + +func (r *Repository) FailSourceObject(ctx context.Context, projectID, programID, objectID, errorCode string, at time.Time) error { + command, err := r.pool.Exec(ctx, `UPDATE billing_migration_source_objects SET state='failed',error_code=$4,failed_at=$5 WHERE id=$1 AND program_id=$2 AND project_id=$3 AND state='reserved'`, objectID, programID, projectID, errorCode, at) + if err != nil { + return translate(err, "fail migration source object") + } + if command.RowsAffected() != 1 { + return billingmigration.ErrConflict + } + return nil +} + +func (r *Repository) AppendVerifiedSource(ctx context.Context, write billingmigration.SourceObjectManifestWrite) error { + currentAccess := int64(0) + persistedRecordIDs := make(map[string]string, len(write.Records)) + for _, record := range write.Records { + if record.CurrentAccess { + currentAccess++ + } + } + if int64(len(write.Records)) != write.Manifest.Manifest.RecordCount || currentAccess != write.Manifest.Manifest.CurrentAccessRecordCount { + return billingmigration.ErrInvalid + } + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + var objectKey string + var checksum []byte + var size int64 + err = tx.QueryRow(ctx, `SELECT object_key,plaintext_digest,plaintext_size_bytes FROM billing_migration_source_objects WHERE id=$1 AND program_id=$2 AND project_id=$3 AND state='verified' FOR UPDATE`, write.SourceObjectID, write.Manifest.Manifest.ProgramID, write.Manifest.ProjectID).Scan(&objectKey, &checksum, &size) + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.ErrConflict + } + if err != nil { + return err + } + if objectKey != write.Manifest.ObjectKey || string(checksum) != string(write.Manifest.ObjectChecksum) || size != write.Manifest.ObjectSizeBytes { + return billingmigration.ErrStaleDigest + } + if err := requireProgramVersionStateTx(ctx, tx, write.Manifest.ProjectID, write.Manifest.Manifest.ProgramID, write.ExpectedStateVersion, billingmigration.StateMapping, billingmigration.StateImporting, billingmigration.StateDryRun, billingmigration.StateShadowing); err != nil { + return err + } + m := write.Manifest.Manifest + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_source_manifests(id,program_id,project_id,state_version,adapter_version,provider_api_version,schema_version,record_count,current_access_record_count,object_key,object_checksum,object_size_bytes,object_encryption,manifest_digest,source_watermark,captured_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'AES-256-GCM',$13,$14,$15)`, m.ManifestID, m.ProgramID, write.Manifest.ProjectID, m.StateVersion, m.AdapterVersion, m.ProviderAPIVersion, m.SchemaVersion, m.RecordCount, m.CurrentAccessRecordCount, objectKey, checksum, size, write.Manifest.ManifestDigest, write.Manifest.SourceWatermark, m.CapturedAt) + if err != nil { + return translate(err, "append verified migration manifest") + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_source_object_manifests(source_object_id,manifest_id,program_id,project_id,binding_digest,bound_at) VALUES($1,$2,$3,$4,$5,$6)`, write.SourceObjectID, m.ManifestID, m.ProgramID, write.Manifest.ProjectID, write.BindingDigest, write.Now) + if err != nil { + return translate(err, "bind migration source object") + } + for _, record := range write.Records { + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_source_records(id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,source_cursor,record_digest,current_access,normalization_schema_version,evidence_kind,observed_at,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT(program_id,source_kind,source_identifier,source_revision,record_digest) DO NOTHING`, record.ID, m.ProgramID, write.Manifest.ProjectID, m.ManifestID, record.SourceKind, record.SourceIdentifier, record.SourceRevision, record.SourceCursor, record.RecordDigest, record.CurrentAccess, record.NormalizationSchemaVersion, record.EvidenceKind, record.ObservedAt, record.CreatedAt) + if err != nil { + return translate(err, "append normalized migration source record") + } + var persistedID string + err = tx.QueryRow(ctx, `SELECT id FROM billing_migration_source_records WHERE program_id=$1 AND source_kind=$2 AND source_identifier=$3 AND source_revision=$4 AND record_digest=$5`, m.ProgramID, record.SourceKind, record.SourceIdentifier, record.SourceRevision, record.RecordDigest).Scan(&persistedID) + if err != nil { + return err + } + persistedRecordIDs[record.ID] = persistedID + entitlements := record.EntitlementIDs + if entitlements == nil { + entitlements = []string{} + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_source_record_relationships(source_record_id,program_id,project_id,customer_source_identifier,product_source_identifier,entitlement_source_identifiers,external_application_id,store,provider_environment,store_identifier,mosaic_product_id,ownership,ownership_digest,quarantine_reason,relationship_digest,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13,$14,$15,$16) ON CONFLICT(source_record_id) DO NOTHING`, persistedID, m.ProgramID, write.Manifest.ProjectID, nullIfEmpty(record.CustomerID), nullIfEmpty(record.ProductID), entitlements, nullIfEmpty(record.ExternalAppID), nullIfEmpty(record.Store), nullIfEmpty(record.SourceEnvironment), nullIfEmpty(record.StoreIdentifier), nullIfEmpty(record.TargetProductID), nullIfEmpty(string(record.Ownership)), record.OwnershipDigest, nullIfEmpty(record.QuarantineReason), relationshipDigest(record), record.CreatedAt) + if err != nil { + return translate(err, "append normalized migration source relationship") + } + } + if work := write.ImportWork; work != nil { + importCount := 0 + for _, record := range write.Records { + if record.ProviderReference != nil && record.QuarantineReason == "" { + importCount++ + } + } + if work.Batch.RecordCount != importCount { + return billingmigration.ErrInvalid + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_import_batches(id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest,expected_program_state_version,status,record_count,cursor_before,cursor_after,attempt_count,lease_generation,created_at,updated_at,due_at,max_attempts) VALUES($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9,$10,'',0,0,$11,$11,$11,8)`, work.Batch.BatchID, work.Batch.ProgramID, work.ProjectID, work.ManifestID, work.MappingSetID, work.Batch.IdempotencyKey, work.RequestDigest, work.Batch.StateVersion, work.Batch.RecordCount, work.CursorBefore, work.CreatedAt) + if err != nil { + return translate(err, "queue verified migration import") + } + ordinal := 0 + for _, record := range write.Records { + if record.ProviderReference == nil || record.QuarantineReason != "" { + continue + } + ref := record.ProviderReference + if (ref.Provider != "app_store" || ref.ReferenceKind != "app_store_transaction_id") && + (ref.Provider != "google_play" || ref.ReferenceKind != "google_play_order_id") { + return billingmigration.ErrInvalid + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,source_product_identifier,mosaic_product_id,expected_store_product_identifier,expected_store_environment) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, work.Batch.BatchID, work.Batch.ProgramID, work.ProjectID, persistedRecordIDs[record.ID], ordinal, ref.Provider, ref.EnvironmentID, ref.ApplicationID, ref.Reference, ref.ReferenceKind, nullIfEmpty(ref.SourceProductID), nullIfEmpty(ref.TargetProductID), nullIfEmpty(ref.ExpectedStoreProductID), ref.ExpectedStoreEnvironment) + if err != nil { + return translate(err, "bind migration import record") + } + ordinal++ + } + } + if write.SourcePullJobID != "" { + var importID any + if write.ImportWork != nil { + importID = write.ImportWork.Batch.BatchID + } + tag, err := tx.Exec(ctx, `UPDATE billing_migration_source_pull_jobs SET status='completed',result_source_object_id=$6,result_manifest_id=$7,result_import_batch_id=$8,resume_cursor=$9,final_watermark=$10,evidence_digest=$11,record_count=$12,current_access_count=$13,import_record_count=$14,lease_owner=NULL,lease_expires_at=NULL,last_error_code=NULL,completed_at=$15,updated_at=$15 WHERE id=$1 AND program_id=$2 AND project_id=$3 AND status='running' AND lease_owner=$4 AND lease_generation=$5 AND lease_expires_at>$15`, write.SourcePullJobID, m.ProgramID, write.Manifest.ProjectID, write.SourcePullOwner, write.SourcePullGeneration, write.SourceObjectID, m.ManifestID, importID, write.SourcePullResumeCursor, write.SourcePullFinalWatermark, write.SourcePullEvidenceDigest, len(write.Records), currentAccess, func() int { + if write.ImportWork == nil { + return 0 + } + return write.ImportWork.Batch.RecordCount + }(), write.Now) + if err != nil { + return translate(err, "settle migration source pull") + } + if tag.RowsAffected() != 1 { + return billingmigration.ErrLeaseLost + } + if len(write.SourcePullProvenCapabilities) > 0 { + command, err := billingmigration.SourcePullCapabilityAssessment(write.Manifest.ProjectID, m.ProgramID, m.StateVersion, m.ProviderAPIVersion, write.SourcePullProvenCapabilities, write.SourcePullEvidenceDigest) + if err != nil { + return err + } + if _, _, err = appendCapabilityAssessmentTx(ctx, tx, command); err != nil { + return err + } + } + } + return tx.Commit(ctx) +} + +func (r *Repository) LeaseImport(ctx context.Context, workerID string, now, leaseUntil time.Time) (billingmigration.ExecutionLease, bool, error) { + return r.leaseExecution(ctx, "import", workerID, now, leaseUntil) +} +func (r *Repository) LeaseRun(ctx context.Context, workerID string, now, leaseUntil time.Time) (billingmigration.ExecutionLease, bool, error) { + return r.leaseExecution(ctx, "run", workerID, now, leaseUntil) +} +func (r *Repository) LeaseFinalDelta(ctx context.Context, workerID string, now, leaseUntil time.Time) (billingmigration.ExecutionLease, bool, error) { + return r.leaseExecution(ctx, "final_delta", workerID, now, leaseUntil) +} + +func (r *Repository) leaseExecution(ctx context.Context, kind, workerID string, now, leaseUntil time.Time) (billingmigration.ExecutionLease, bool, error) { + table := "billing_migration_import_batches" + columns := `id,program_id,project_id,expected_program_state_version,attempt_count,max_attempts,NULL::bytea,NULL::bytea,NULL::bytea,NULL::bytea` + if kind == "run" { + table = "billing_migration_run_jobs" + columns = `id,program_id,project_id,expected_program_state_version,attempt_count,max_attempts,manifest_digest,mapping_digest,policy_digest,NULL::bytea` + } + if kind == "final_delta" { + table = "billing_migration_final_delta_jobs" + columns = `id,program_id,project_id,expected_program_state_version,attempt_count,max_attempts,manifest_digest,mapping_digest,NULL::bytea,evidence_digest` + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingmigration.ExecutionLease{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + query := fmt.Sprintf(`SELECT %s FROM %s WHERE due_at<=$1 AND attempt_count$7`, + s.JobID, s.ProgramID, s.ProjectID, s.Owner, s.Generation, s.RetryAt, s.SettledAt) + if err != nil { + return translate(err, "defer pending migration import") + } + if tag.RowsAffected() != 1 { + return billingmigration.ErrLeaseLost + } + return nil +} +func (r *Repository) SettleRun(ctx context.Context, s billingmigration.ExecutionSettlement) error { + return r.settleSimple(ctx, "run", s) +} + +func (r *Repository) settleSimple(ctx context.Context, kind string, s billingmigration.ExecutionSettlement) error { + if s.Status != "completed" && s.Status != "failed" { + return billingmigration.ErrInvalid + } + table := "billing_migration_import_batches" + attemptKind := "import" + resultColumn := "" + if kind == "run" { + table = "billing_migration_run_jobs" + attemptKind = s.JobKind + resultColumn = "result_run_id" + } + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + var attempts, max, recordCount int + var runKind string + if kind == "run" { + query := fmt.Sprintf(`SELECT attempt_count,max_attempts,run_kind FROM %s WHERE id=$1 AND program_id=$2 AND project_id=$3 AND status='running' AND lease_owner=$4 AND lease_generation=$5 AND lease_expires_at>$6 FOR UPDATE`, table) + err = tx.QueryRow(ctx, query, s.JobID, s.ProgramID, s.ProjectID, s.Owner, s.Generation, s.SettledAt).Scan(&attempts, &max, &runKind) + attemptKind = runKind + } else { + query := fmt.Sprintf(`SELECT attempt_count,max_attempts,record_count FROM %s WHERE id=$1 AND program_id=$2 AND project_id=$3 AND status='running' AND lease_owner=$4 AND lease_generation=$5 AND lease_expires_at>$6 FOR UPDATE`, table) + err = tx.QueryRow(ctx, query, s.JobID, s.ProgramID, s.ProjectID, s.Owner, s.Generation, s.SettledAt).Scan(&attempts, &max, &recordCount) + } + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.ErrLeaseLost + } + if err != nil { + return err + } + phase := s.Status + if phase != "completed" { + phase = "failed" + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_execution_attempts(id,program_id,project_id,job_kind,job_id,lease_owner,lease_generation,attempt_phase,started_attempt_id,result_digest,error_code,recorded_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, executionAttemptID(s.JobID, s.Generation, phase), s.ProgramID, s.ProjectID, attemptKind, s.JobID, s.Owner, s.Generation, phase, executionAttemptID(s.JobID, s.Generation, "started"), s.ResultDigest, nullIfEmpty(s.ErrorCode), s.SettledAt) + if err != nil { + return err + } + if s.Status == "completed" { + if kind == "import" && (s.ValidatedCount < 0 || s.QuarantinedCount < 0 || s.ValidatedCount+s.QuarantinedCount != recordCount || recordCount != len(s.References)) { + return billingmigration.ErrInvalid + } + if kind == "import" { + rows, queryErr := tx.Query(ctx, `SELECT provider,environment_id,application_id,provider_reference,reference_kind,coalesce(source_product_identifier,''),coalesce(mosaic_product_id,''),coalesce(expected_store_product_identifier,''),expected_store_environment FROM billing_migration_import_batch_records WHERE import_batch_id=$1 ORDER BY ordinal`, s.JobID) + if queryErr != nil { + return queryErr + } + index := 0 + for rows.Next() { + var ref billingmigration.KnownProviderReference + if err = rows.Scan(&ref.Provider, &ref.EnvironmentID, &ref.ApplicationID, &ref.Reference, &ref.ReferenceKind, &ref.SourceProductID, &ref.TargetProductID, &ref.ExpectedStoreProductID, &ref.ExpectedStoreEnvironment); err != nil { + rows.Close() + return err + } + expected := billingmigration.KnownProviderReference{} + if index < len(s.References) { + expected = s.References[index] + } + if index >= len(s.References) || ref != expected { + rows.Close() + return billingmigration.ErrInvalid + } + index++ + } + rows.Close() + if err = rows.Err(); err != nil { + return err + } + if index != recordCount { + return billingmigration.ErrInvalid + } + } + if kind == "run" { + if s.Run == nil || s.Run.SourceWatermark == "" || s.Run.ProviderWatermark == "" || s.Run.ShadowWatermark == "" { + return billingmigration.ErrInvalid + } + if runKind == "dry_run" && len(s.PreparedPointers) != 0 { + return billingmigration.ErrInvalid + } + var manifestDigest, mappingDigest, policyDigest []byte + err = tx.QueryRow(ctx, `SELECT + (SELECT manifest_digest FROM billing_migration_source_manifests WHERE program_id=p.id ORDER BY captured_at DESC,id DESC LIMIT 1), + (SELECT mapping_digest FROM billing_migration_mapping_sets WHERE program_id=p.id AND status='frozen' ORDER BY version DESC LIMIT 1), + p.policy_digest FROM billing_migration_programs p WHERE p.id=$1 AND p.project_id=$2`, s.ProgramID, s.ProjectID).Scan(&manifestDigest, &mappingDigest, &policyDigest) + if err != nil { + return err + } + if string(manifestDigest) != string(s.ManifestDigest) || string(mappingDigest) != string(s.MappingDigest) || string(policyDigest) != string(s.PolicyDigest) { + return billingmigration.ErrStaleDigest + } + counts := billingmigration.Counts{} + for _, item := range s.Run.Divergences { + switch item.Divergence.Classification { + case "critical": + counts.Critical++ + case "blocking": + counts.Blocking++ + case "warning": + counts.Warning++ + case "informational": + counts.Informational++ + default: + return billingmigration.ErrInvalid + } + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_runs(id,program_id,project_id,run_kind,state_version,manifest_digest,mapping_digest,policy_digest,source_watermark,provider_watermark,shadow_watermark,critical_count,blocking_count,warning_count,informational_count,run_digest,completed_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, s.ResultID, s.ProgramID, s.ProjectID, runKind, s.ExpectedStateVersion, s.ManifestDigest, s.MappingDigest, s.PolicyDigest, s.Run.SourceWatermark, s.Run.ProviderWatermark, s.Run.ShadowWatermark, counts.Critical, counts.Blocking, counts.Warning, counts.Informational, s.ResultDigest, s.SettledAt) + if err != nil { + return translate(err, "record migration run result") + } + for _, item := range s.Run.Divergences { + d := item.Divergence + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_divergences(id,program_id,project_id,run_id,state_version,classification,reason,evidence_digest,classification_rule_version,observed_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, d.DivergenceID, s.ProgramID, s.ProjectID, s.ResultID, d.StateVersion, d.Classification, d.Reason, item.EvidenceDigest, d.ClassificationRuleVersion, d.ObservedAt) + if err != nil { + return translate(err, "record executed run divergence") + } + } + if runKind == "dry_run" && len(s.Run.ShadowSnapshots) != 0 { + return billingmigration.ErrInvalid + } + for _, snap := range s.Run.ShadowSnapshots { + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_shadow_snapshots(id,program_id,project_id,environment_id,application_id,platform,billing_customer_id,source_snapshot_id,mosaic_snapshot_id,shadow_digest,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, snap.ID, s.ProgramID, s.ProjectID, snap.EnvironmentID, snap.ApplicationID, snap.Platform, snap.BillingCustomerID, nullIfEmpty(snap.SourceSnapshotID), snap.MosaicSnapshotID, snap.ShadowDigest, s.SettledAt) + if err != nil { + return translate(err, "record executed shadow snapshot") + } + } + for _, p := range s.PreparedPointers { + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_scope_prepared_pointers(program_id,project_id,environment_id,application_id,platform,billing_customer_id,prepared_snapshot_id,prepared_digest,prepared_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(program_id,application_id,platform,billing_customer_id) DO UPDATE SET prepared_snapshot_id=EXCLUDED.prepared_snapshot_id,prepared_digest=EXCLUDED.prepared_digest,prepared_at=EXCLUDED.prepared_at`, s.ProgramID, s.ProjectID, p.EnvironmentID, p.ApplicationID, p.Platform, p.BillingCustomerID, p.SnapshotID, p.PreparedDigest, s.SettledAt) + if err != nil { + return translate(err, "write migration prepared pointer") + } + } + } + set := fmt.Sprintf(`UPDATE %s SET status='completed',lease_owner=NULL,lease_expires_at=NULL,last_error_code=NULL,updated_at=$6%s WHERE id=$1 AND program_id=$2 AND project_id=$3 AND lease_owner=$4 AND lease_generation=$5`, table, map[bool]string{true: ",result_run_id=$7", false: ",validated_count=$7,quarantined_count=$8,cursor_after=$9"}[kind == "run"]) + var command pgconnCommandTag + if kind == "run" { + tag, e := tx.Exec(ctx, set, s.JobID, s.ProgramID, s.ProjectID, s.Owner, s.Generation, s.SettledAt, s.ResultID) + err = e + command = tag + } else { + tag, e := tx.Exec(ctx, set, s.JobID, s.ProgramID, s.ProjectID, s.Owner, s.Generation, s.SettledAt, s.ValidatedCount, s.QuarantinedCount, s.CursorAfter) + err = e + command = tag + } + if err != nil { + return err + } + if command.RowsAffected() != 1 { + return billingmigration.ErrLeaseLost + } + if kind == "import" { + var pullID string + err = tx.QueryRow(ctx, `SELECT id FROM billing_migration_source_pull_jobs WHERE result_import_batch_id=$1 AND program_id=$2 AND project_id=$3 AND intent='final_delta' AND status='completed' FOR UPDATE`, s.JobID, s.ProgramID, s.ProjectID).Scan(&pullID) + if err == nil { + finalJobID := "mfd_pull_" + pullID + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_final_delta_jobs(id,program_id,project_id,idempotency_key,request_digest,expected_program_state_version,manifest_digest,mapping_digest,evidence_digest,status,due_at,lease_generation,attempt_count,max_attempts,created_at,updated_at) + SELECT $1,pull.program_id,pull.project_id,'source-pull-final-delta:'||pull.id,$2,pull.expected_program_state_version,manifest.manifest_digest,mapping.mapping_digest,$3,'pending',$4,0,0,8,$4,$4 + FROM billing_migration_source_pull_jobs pull JOIN billing_migration_source_manifests manifest ON manifest.id=pull.result_manifest_id AND manifest.program_id=pull.program_id AND manifest.project_id=pull.project_id JOIN billing_migration_mapping_sets mapping ON mapping.id=pull.mapping_set_id AND mapping.program_id=pull.program_id AND mapping.project_id=pull.project_id AND mapping.status='frozen' WHERE pull.id=$5 ON CONFLICT(program_id,idempotency_key) DO NOTHING`, finalJobID, s.ResultDigest, s.ResultDigest, s.SettledAt, pullID) + if err != nil { + return translate(err, "queue final delta from source pull") + } + _, err = tx.Exec(ctx, `UPDATE billing_migration_source_pull_jobs SET result_final_delta_job_id=$2,updated_at=$3 WHERE id=$1 AND result_final_delta_job_id IS NULL`, pullID, finalJobID, s.SettledAt) + if err != nil { + return err + } + } else if !errors.Is(err, pgx.ErrNoRows) { + return err + } + } + } else { + status := "pending" + if attempts >= max { + status = "failed" + } + updateQuery := fmt.Sprintf(`UPDATE %s SET status=$6,lease_owner=NULL,lease_expires_at=NULL,last_error_code=$7,due_at=$8,updated_at=$9 WHERE id=$1 AND program_id=$2 AND project_id=$3 AND lease_owner=$4 AND lease_generation=$5`, table) + tag, err := tx.Exec(ctx, updateQuery, s.JobID, s.ProgramID, s.ProjectID, s.Owner, s.Generation, status, s.ErrorCode, s.RetryAt, s.SettledAt) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return billingmigration.ErrLeaseLost + } + } + _ = resultColumn + return tx.Commit(ctx) +} + +// local interface keeps pgx's command tag concrete type out of domain code. +type pgconnCommandTag interface{ RowsAffected() int64 } + +func nullIfEmpty(value string) any { + if value == "" { + return nil + } + return value +} + +func (r *Repository) SettleFinalDelta(ctx context.Context, s billingmigration.ExecutionSettlement) error { + if s.Status != "completed" && s.Status != "failed" { + return billingmigration.ErrInvalid + } + if s.Status == "completed" && s.FinalDelta == nil { + return billingmigration.ErrInvalid + } + d := s.FinalDelta + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + var attempts, max int + err = tx.QueryRow(ctx, `SELECT attempt_count,max_attempts FROM billing_migration_final_delta_jobs WHERE id=$1 AND program_id=$2 AND project_id=$3 AND status='running' AND lease_owner=$4 AND lease_generation=$5 AND lease_expires_at>$6 FOR UPDATE`, s.JobID, s.ProgramID, s.ProjectID, s.Owner, s.Generation, s.SettledAt).Scan(&attempts, &max) + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.ErrLeaseLost + } + if err != nil { + return err + } + phase := s.Status + if phase != "completed" { + phase = "failed" + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_execution_attempts(id,program_id,project_id,job_kind,job_id,lease_owner,lease_generation,attempt_phase,started_attempt_id,result_digest,error_code,recorded_at) VALUES($1,$2,$3,'final_delta',$4,$5,$6,$7,$8,$9,$10,$11)`, executionAttemptID(s.JobID, s.Generation, phase), s.ProgramID, s.ProjectID, s.JobID, s.Owner, s.Generation, phase, executionAttemptID(s.JobID, s.Generation, "started"), s.ResultDigest, nullIfEmpty(s.ErrorCode), s.SettledAt) + if err != nil { + return err + } + if s.Status != "completed" { + status := "pending" + if attempts >= max { + status = "failed" + } + tag, err := tx.Exec(ctx, `UPDATE billing_migration_final_delta_jobs SET status=$6,lease_owner=NULL,lease_expires_at=NULL,last_error_code=$7,due_at=$8,updated_at=$9 WHERE id=$1 AND program_id=$2 AND project_id=$3 AND lease_owner=$4 AND lease_generation=$5`, s.JobID, s.ProgramID, s.ProjectID, s.Owner, s.Generation, status, s.ErrorCode, s.RetryAt, s.SettledAt) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return billingmigration.ErrLeaseLost + } + return tx.Commit(ctx) + } + var stateVersion int64 + var manifest, mapping []byte + err = tx.QueryRow(ctx, `SELECT state_version,(SELECT manifest_digest FROM billing_migration_source_manifests WHERE program_id=p.id ORDER BY captured_at DESC,id DESC LIMIT 1),(SELECT mapping_digest FROM billing_migration_mapping_sets WHERE program_id=p.id AND status='frozen' ORDER BY version DESC LIMIT 1) FROM billing_migration_programs p WHERE id=$1 AND project_id=$2 AND state IN ('shadowing','ready') FOR UPDATE`, s.ProgramID, s.ProjectID).Scan(&stateVersion, &manifest, &mapping) + if errors.Is(err, pgx.ErrNoRows) { + return billingmigration.ErrStaleState + } + if err != nil { + return err + } + if stateVersion != s.ExpectedStateVersion { + return billingmigration.ErrStaleState + } + if d.StateVersion != s.ExpectedStateVersion || string(manifest) != string(d.ManifestDigest) || string(mapping) != string(d.MappingDigest) || string(s.ManifestDigest) != string(d.ManifestDigest) || string(s.MappingDigest) != string(d.MappingDigest) || string(s.EvidenceDigest) != string(d.EvidenceDigest) { + return billingmigration.ErrStaleDigest + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_final_deltas(id,program_id,project_id,state_version,manifest_digest,mapping_digest,evidence_digest,final_watermark_digest,source_watermark,provider_watermark,shadow_watermark,delta_digest,completed_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, d.ID, s.ProgramID, s.ProjectID, d.StateVersion, d.ManifestDigest, d.MappingDigest, d.EvidenceDigest, d.FinalWatermarkDigest, d.SourceWatermark, d.ProviderWatermark, d.ShadowWatermark, d.DeltaDigest, s.SettledAt) + if err != nil { + return translate(err, "append final delta") + } + cohortID := "mcs_" + d.ID + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_final_delta_cohort_sets(id,final_delta_id,program_id,project_id,customer_count,cohort_digest,frozen_at) VALUES($1,$2,$3,$4,$5,$6,$7)`, cohortID, d.ID, s.ProgramID, s.ProjectID, len(d.Cohort), d.CohortDigest, s.SettledAt) + if err != nil { + return translate(err, "freeze final delta cohort") + } + for _, c := range d.Cohort { + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_final_delta_cohort_customers(cohort_set_id,program_id,project_id,billing_customer_id,customer_digest) VALUES($1,$2,$3,$4,$5)`, cohortID, s.ProgramID, s.ProjectID, c.BillingCustomerID, c.CustomerDigest) + if err != nil { + return err + } + } + for _, p := range s.PreparedPointers { + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_final_delta_prepared_pointers(final_delta_job_id,lease_generation,program_id,project_id,environment_id,application_id,platform,billing_customer_id,prepared_snapshot_id,prepared_digest,prepared_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, s.JobID, s.Generation, s.ProgramID, s.ProjectID, p.EnvironmentID, p.ApplicationID, p.Platform, p.BillingCustomerID, p.SnapshotID, p.PreparedDigest, s.SettledAt) + if err != nil { + return translate(err, "bind final delta prepared pointer") + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_scope_prepared_pointers(program_id,project_id,environment_id,application_id,platform,billing_customer_id,prepared_snapshot_id,prepared_digest,prepared_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(program_id,application_id,platform,billing_customer_id) DO UPDATE SET prepared_snapshot_id=EXCLUDED.prepared_snapshot_id,prepared_digest=EXCLUDED.prepared_digest,prepared_at=EXCLUDED.prepared_at`, s.ProgramID, s.ProjectID, p.EnvironmentID, p.ApplicationID, p.Platform, p.BillingCustomerID, p.SnapshotID, p.PreparedDigest, s.SettledAt) + if err != nil { + return err + } + } + var invalid bool + err = tx.QueryRow(ctx, `SELECT (SELECT count(*) FROM billing_migration_final_delta_prepared_pointers WHERE final_delta_job_id=$3 AND lease_generation=$4)<>(SELECT count(*) FROM billing_migration_program_scopes WHERE program_id=$1)*(SELECT count(*) FROM billing_migration_final_delta_cohort_customers WHERE cohort_set_id=$2) OR EXISTS(SELECT 1 FROM billing_migration_program_scopes scope CROSS JOIN billing_migration_final_delta_cohort_customers cohort WHERE scope.program_id=$1 AND cohort.cohort_set_id=$2 AND NOT EXISTS(SELECT 1 FROM billing_migration_final_delta_prepared_pointers pointer WHERE pointer.final_delta_job_id=$3 AND pointer.lease_generation=$4 AND pointer.application_id=scope.application_id AND pointer.platform=scope.platform AND pointer.billing_customer_id=cohort.billing_customer_id)) OR EXISTS(SELECT 1 FROM billing_migration_final_delta_prepared_pointers pointer WHERE pointer.final_delta_job_id=$3 AND pointer.lease_generation=$4 AND NOT EXISTS(SELECT 1 FROM billing_migration_final_delta_cohort_customers cohort WHERE cohort.cohort_set_id=$2 AND cohort.billing_customer_id=pointer.billing_customer_id))`, s.ProgramID, cohortID, s.JobID, s.Generation).Scan(&invalid) + if err != nil { + return err + } + if invalid { + return billingmigration.ErrPointerCoverage + } + tag, err := tx.Exec(ctx, `UPDATE billing_migration_final_delta_jobs SET status='completed',result_final_delta_id=$6,lease_owner=NULL,lease_expires_at=NULL,last_error_code=NULL,updated_at=$7 WHERE id=$1 AND program_id=$2 AND project_id=$3 AND lease_owner=$4 AND lease_generation=$5`, s.JobID, s.ProgramID, s.ProjectID, s.Owner, s.Generation, d.ID, s.SettledAt) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return billingmigration.ErrLeaseLost + } + return tx.Commit(ctx) +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/source_execution_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/source_execution_integration_test.go new file mode 100644 index 00000000..c1135561 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/source_execution_integration_test.go @@ -0,0 +1,233 @@ +package billingmigrationpostgres_test + +import ( + "context" + "database/sql" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +func TestSourceExecutionMigrationAndRepositoryInvariants(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err = db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + // Empty down/up proves reversibility before Package A evidence exists. + if err = goose.DownToContext(ctx, db, ".", 54); err != nil { + t.Fatalf("empty 00055 down: %v", err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("00055 re-up: %v", err) + } + seedMigrationTenant(t, ctx, db) + seedSourceExecutionProgram(t, ctx, db) + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + repository := billingmigrationpostgres.New(pool) + now := time.Now().UTC().Truncate(time.Microsecond) + reservation := billingmigration.ReserveSourceObject{SourceObject: billingmigration.SourceObject{SourceObjectScope: billingmigration.SourceObjectScope{ProjectID: "project_one", ProgramID: "program_source", ObjectID: "object_one", AdapterVersion: billingmigration.AdapterVersion, SchemaVersion: "v1"}, ReservationKey: "source-pull-one", ReservationDigest: bytesOf(0x21), ReservationGeneration: 1, WriteTokenDigest: bytesOf(0x20), ObjectKey: billingmigration.DeterministicSourceObjectKey("project_one", "program_source", "object_one"), SourceChannel: billingmigration.SourceChannelRevenueCat}, Now: now} + object, replay, err := repository.ReserveSourceObject(ctx, reservation) + if err != nil || replay { + t.Fatalf("reserve replay=%v err=%v", replay, err) + } + loser := reservation + loser.WriteTokenDigest = bytesOf(0x29) + reservedAgain, replay, err := repository.ReserveSourceObject(ctx, loser) + if err != nil || !replay { + t.Fatalf("idempotent reserve replay=%v err=%v", replay, err) + } + if string(reservedAgain.WriteTokenDigest) != string(bytesOf(0x20)) { + t.Fatal("losing reservation overwrote winning write identity") + } + envelope := billingmigration.SourceObjectEnvelope{Version: 1, Algorithm: "AES-256-GCM-CHUNKED", KeyID: "source_key", Nonce: make([]byte, 12), ChunkSize: 16384, ChunkCount: 1, AADDigest: bytesOf(0x22), PlaintextDigest: bytesOf(0x23), PlaintextSize: 7, CiphertextDigest: bytesOf(0x24), CiphertextSize: 128} + if err = repository.VerifySourceObject(ctx, billingmigration.VerifySourceObject{ProjectID: "project_one", ProgramID: "program_source", ObjectID: object.ObjectID, ReservationGeneration: 1, WriteTokenDigest: bytesOf(0x29), Envelope: envelope, Now: now}); !errors.Is(err, billingmigration.ErrConflict) { + t.Fatalf("losing writer verified reservation err=%v", err) + } + if err = repository.VerifySourceObject(ctx, billingmigration.VerifySourceObject{ProjectID: "project_one", ProgramID: "program_source", ObjectID: object.ObjectID, ReservationGeneration: 1, WriteTokenDigest: bytesOf(0x20), Envelope: envelope, Now: now}); err != nil { + t.Fatal(err) + } + manifest := billingmigration.ManifestWrite{Manifest: billingmigration.SourceManifest{ProgramID: "program_source", StateVersion: 1, ManifestID: "manifest_one", AdapterVersion: billingmigration.AdapterVersion, ProviderAPIVersion: "v2", SchemaVersion: "v1", CapturedAt: now}, ProjectID: "project_one", ObjectKey: object.ObjectKey, ObjectChecksum: bytesOf(0xff), ObjectSizeBytes: 7, ManifestDigest: bytesOf(0x25), SourceWatermark: "opaque"} + write := billingmigration.SourceObjectManifestWrite{ExpectedStateVersion: 1, SourceObjectID: "object_one", Manifest: manifest, BindingDigest: bytesOf(0x26), Now: now} + if err = repository.AppendVerifiedSource(ctx, write); !errors.Is(err, billingmigration.ErrStaleDigest) { + t.Fatalf("checksum mismatch error = %v", err) + } + var manifestCount int + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_source_manifests WHERE id='manifest_one'`).Scan(&manifestCount); err != nil || manifestCount != 0 { + t.Fatalf("manifest appended before checksum verification count=%d err=%v", manifestCount, err) + } + write.Manifest.ObjectChecksum = envelope.PlaintextDigest + if err = repository.AppendVerifiedSource(ctx, write); err != nil { + t.Fatalf("append verified source: %v", err) + } + + // One active lease wins. A stale owner/generation cannot settle it. + // Package D requires every delta/final-delta pull to bind its exact + // predecessor. Seed a completed zero-record snapshot before the legacy + // compound fixture below using the same frozen mapping. + for _, statement := range []string{ + `INSERT INTO billing_migration_mapping_sets(id,program_id,project_id,version,status,mapping_digest,expected_program_state_version,created_by_actor_id,created_at,frozen_at) VALUES('mapping_one','program_source','project_one',1,'frozen',decode(repeat('31',32),'hex'),1,'owner_one',$1,$1)`, + `INSERT INTO billing_migration_import_batches(id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest,expected_program_state_version,status,record_count,validated_count,quarantined_count,cursor_before,cursor_after,attempt_count,lease_generation,created_at,updated_at,due_at,max_attempts) VALUES('batch_predecessor','program_source','project_one','manifest_one','mapping_one','batch-predecessor',decode(repeat('38',32),'hex'),1,'completed',0,0,0,'','cursor',0,0,$1,$1,$1,3)`, + `INSERT INTO billing_migration_source_pull_jobs(id,program_id,project_id,intent,idempotency_key,request_digest,expected_program_state_version,starting_cursor,starting_watermark,starting_watermark_digest,predecessor_pull_job_id,mapping_set_id,status,result_source_object_id,result_manifest_id,result_import_batch_id,resume_cursor,final_watermark,evidence_digest,record_count,current_access_count,import_record_count,due_at,lease_generation,attempt_count,max_attempts,created_by_actor_id,started_at,completed_at,created_at,updated_at) VALUES('pull_predecessor','program_source','project_one','snapshot','pull-predecessor',decode(repeat('38',32),'hex'),1,'','',NULL,NULL,'mapping_one','completed','object_one','manifest_one','batch_predecessor','cursor','watermark',decode(repeat('38',32),'hex'),0,0,0,$1,1,1,3,'owner_one',$1,$1,$1,$1)`, + } { + if _, err = pool.Exec(ctx, statement, now); err != nil { + t.Fatal(err) + } + } + for _, statement := range []string{ + `INSERT INTO billing_migration_source_records(id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,source_cursor,record_digest,current_access,normalization_schema_version,evidence_kind,observed_at,created_at) VALUES('record_import','program_source','project_one','manifest_one','transaction','known_store_ref','1','opaque',decode(repeat('35',32),'hex'),true,'v1','trusted_provider_api',$1,$1)`, + `INSERT INTO billing_migration_import_batches(id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest,expected_program_state_version,status,record_count,cursor_before,cursor_after,attempt_count,lease_generation,created_at,updated_at,due_at,max_attempts) VALUES('batch_one','program_source','project_one','manifest_one','mapping_one','batch-one',decode(repeat('32',32),'hex'),1,'pending',1,'','',0,0,$1,$1,$1,3)`, + `INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,expected_store_environment) VALUES('batch_one','program_source','project_one','record_import',0,'app_store','environment_one','app_one','known_store_ref','app_store_transaction_id','production') RETURNING $1`, + `INSERT INTO billing_migration_source_pull_jobs(id,program_id,project_id,intent,idempotency_key,request_digest,expected_program_state_version,starting_cursor,starting_watermark,starting_watermark_digest,predecessor_pull_job_id,mapping_set_id,status,result_source_object_id,result_manifest_id,result_import_batch_id,resume_cursor,final_watermark,evidence_digest,record_count,current_access_count,import_record_count,due_at,lease_generation,attempt_count,max_attempts,created_by_actor_id,started_at,completed_at,created_at,updated_at) VALUES('pull_auto','program_source','project_one','final_delta','pull-auto',decode(repeat('36',32),'hex'),1,'cursor','watermark',decode(repeat('38',32),'hex'),'pull_predecessor','mapping_one','completed','object_one','manifest_one','batch_one','','final',decode(repeat('37',32),'hex'),1,1,1,$1,1,1,3,'owner_one',$1,$1,$1,$1)`, + } { + if _, err = pool.Exec(ctx, statement, now); err != nil { + t.Fatal(err) + } + } + // The plaintext import queue admits only official non-secret join handles. + // Both typed variants work, while neither a generic handle nor a bearer-grade + // Google purchase token can be represented even through direct SQL. + if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_source_records(id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,source_cursor,record_digest,current_access,normalization_schema_version,evidence_kind,observed_at,created_at) VALUES + ('record_google_order','program_source','project_one','manifest_one','transaction','google_order','1','opaque',decode(repeat('81',32),'hex'),true,'v1','trusted_provider_api',$1,$1), + ('record_generic_ref','program_source','project_one','manifest_one','transaction','generic_ref','1','opaque',decode(repeat('82',32),'hex'),true,'v1','trusted_provider_api',$1,$1), + ('record_purchase_token','program_source','project_one','manifest_one','transaction','purchase_token','1','opaque',decode(repeat('83',32),'hex'),true,'v1','trusted_provider_api',$1,$1)`, now); err != nil { + t.Fatal(err) + } + if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_import_batches(id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest,expected_program_state_version,status,record_count,validated_count,cursor_before,cursor_after,attempt_count,lease_generation,created_at,updated_at,due_at,max_attempts) VALUES + ('batch_google_order','program_source','project_one','manifest_one','mapping_one','batch-google-order',decode(repeat('84',32),'hex'),1,'completed',1,1,'','',0,0,$1,$1,$1,3), + ('batch_generic_ref','program_source','project_one','manifest_one','mapping_one','batch-generic-ref',decode(repeat('85',32),'hex'),1,'completed',1,1,'','',0,0,$1,$1,$1,3), + ('batch_purchase_token','program_source','project_one','manifest_one','mapping_one','batch-purchase-token',decode(repeat('86',32),'hex'),1,'completed',1,1,'','',0,0,$1,$1,$1,3)`, now); err != nil { + t.Fatal(err) + } + if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,expected_store_environment) VALUES('batch_google_order','program_source','project_one','record_google_order',0,'google_play','environment_one','app_one','GPA.1234-5678','google_play_order_id','production')`); err != nil { + t.Fatalf("official Google order reference was rejected: %v", err) + } + for _, unsafe := range []struct{ batch, record, kind string }{ + {"batch_generic_ref", "record_generic_ref", "provider_reference"}, + {"batch_purchase_token", "record_purchase_token", "google_play_purchase_token"}, + } { + _, insertErr := pool.Exec(ctx, `INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,expected_store_environment) VALUES($1,'program_source','project_one',$2,0,'google_play','environment_one','app_one','plaintext-secret',$3,'production')`, unsafe.batch, unsafe.record, unsafe.kind) + var pgErr *pgconn.PgError + if !errors.As(insertErr, &pgErr) || pgErr.Code != "23514" { + t.Fatalf("unsafe reference kind %q schema error=%v", unsafe.kind, insertErr) + } + } + lease, leased, err := repository.LeaseImport(ctx, "worker_one", now, now.Add(time.Minute)) + if err != nil || !leased { + t.Fatalf("lease=%v err=%v", leased, err) + } + if _, leased, err = repository.LeaseImport(ctx, "worker_two", now, now.Add(time.Minute)); err != nil || leased { + t.Fatalf("concurrent lease=%v err=%v", leased, err) + } + stale := billingmigration.ExecutionSettlement{ExecutionLease: lease, Status: "completed", ResultDigest: bytesOf(0x33), SettledAt: now.Add(time.Second)} + stale.Generation-- + if err = repository.SettleImport(ctx, stale); !errors.Is(err, billingmigration.ErrLeaseLost) { + t.Fatalf("stale settlement error=%v", err) + } + partial := billingmigration.ExecutionSettlement{ExecutionLease: lease, Status: "completed", ResultDigest: bytesOf(0x33), SettledAt: now.Add(time.Second)} + if err = repository.SettleImport(ctx, partial); !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("partial import coverage error=%v", err) + } + arbitrary := billingmigration.ExecutionSettlement{ExecutionLease: lease, Status: "completed", ValidatedCount: 1, ResultDigest: bytesOf(0x33), SettledAt: now.Add(time.Second)} + arbitrary.References = append([]billingmigration.KnownProviderReference(nil), lease.References...) + arbitrary.References[0].Reference = "caller_substitute" + if err = repository.SettleImport(ctx, arbitrary); !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("arbitrary import reference error=%v", err) + } + valid := billingmigration.ExecutionSettlement{ExecutionLease: lease, Status: "completed", ValidatedCount: 1, ResultDigest: bytesOf(0x34), SettledAt: now.Add(time.Second)} + if err = repository.SettleImport(ctx, valid); err != nil { + t.Fatalf("settle valid lease: %v", err) + } + var autoDeltaCount int + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_final_delta_jobs WHERE id='mfd_pull_pull_auto' AND evidence_digest=decode(repeat('34',32),'hex') AND status='pending'`).Scan(&autoDeltaCount); err != nil || autoDeltaCount != 1 { + t.Fatalf("final-delta pull did not auto-enqueue evaluation count=%d err=%v", autoDeltaCount, err) + } + + // Final delta settlement rechecks current digests and exact cohort×scope + // prepared coverage without touching the live current-pointer table. + for _, statement := range []string{ + `UPDATE billing_migration_programs SET state='shadowing',state_version=2,updated_at=$1 WHERE id='program_source'`, + `INSERT INTO customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id,snapshot_version,rule_version,computed_at,as_of,checksum,change_reason,created_at) VALUES('snapshot_prepared','project_one','environment_one','customer_one',1,1,$1,$1,decode(repeat('41',32),'hex'),'migration_prepared',$1)`, + `INSERT INTO billing_migration_final_delta_jobs(id,program_id,project_id,idempotency_key,request_digest,expected_program_state_version,manifest_digest,mapping_digest,evidence_digest,status,due_at,lease_generation,attempt_count,max_attempts,created_at,updated_at) VALUES('delta_job_one','program_source','project_one','delta-job-one',decode(repeat('42',32),'hex'),2,decode(repeat('25',32),'hex'),decode(repeat('31',32),'hex'),decode(repeat('43',32),'hex'),'pending',$1,0,0,3,$1,$1)`, + } { + if _, err = pool.Exec(ctx, statement, now); err != nil { + t.Fatal(err) + } + } + deltaLease, leased, err := repository.LeaseFinalDelta(ctx, "worker_delta", now, now.Add(time.Minute)) + if err != nil || !leased { + t.Fatalf("final delta lease=%v err=%v", leased, err) + } + delta := &billingmigration.FinalDeltaResult{ID: "delta_one", StateVersion: 2, ManifestDigest: bytesOf(0x99), MappingDigest: bytesOf(0x31), EvidenceDigest: bytesOf(0x43), FinalWatermarkDigest: bytesOf(0x44), DeltaDigest: bytesOf(0x45), CohortDigest: bytesOf(0x46), SourceWatermark: now, ProviderWatermark: now, ShadowWatermark: now, Cohort: []billingmigration.CohortCustomer{{BillingCustomerID: "customer_one", CustomerDigest: bytesOf(0x47)}}} + deltaSettlement := billingmigration.ExecutionSettlement{ExecutionLease: deltaLease, Status: "completed", ResultDigest: bytesOf(0x45), FinalDelta: delta, SettledAt: now.Add(2 * time.Second)} + if err = repository.SettleFinalDelta(ctx, deltaSettlement); !errors.Is(err, billingmigration.ErrStaleDigest) { + t.Fatalf("final delta digest drift error=%v", err) + } + delta.ManifestDigest = bytesOf(0x25) + if err = repository.SettleFinalDelta(ctx, deltaSettlement); !errors.Is(err, billingmigration.ErrPointerCoverage) { + t.Fatalf("final delta incomplete coverage error=%v", err) + } + deltaSettlement.PreparedPointers = []billingmigration.PreparedPointer{{EnvironmentID: "environment_one", ApplicationID: "app_one", Platform: "ios", BillingCustomerID: "customer_one", SnapshotID: "snapshot_prepared", PreparedDigest: bytesOf(0x41)}} + if err = repository.SettleFinalDelta(ctx, deltaSettlement); err != nil { + t.Fatalf("settle exact final delta cohort×scope: %v", err) + } + var livePointers int + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_scope_current_pointers WHERE project_id='project_one'`).Scan(&livePointers); err != nil || livePointers != 0 { + t.Fatalf("final delta mutated live pointers count=%d err=%v", livePointers, err) + } + + // Populated Down refuses before removing any Package A structure. + err = goose.DownToContext(ctx, db, ".", 54) + if err == nil || !strings.Contains(err.Error(), "immutable migration validation evidence exists") { + t.Fatalf("populated down guard error=%v", err) + } + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_source_objects`).Scan(&manifestCount); err != nil || manifestCount != 1 { + t.Fatalf("guard caused partial loss count=%d err=%v", manifestCount, err) + } +} + +func seedSourceExecutionProgram(t *testing.T, ctx context.Context, db *sql.DB) { + t.Helper() + if _, err := db.ExecContext(ctx, `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) VALUES('credential_source','project_one','revenuecat','rc_project','active',1,'AES-256-GCM','credential_key',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),'owner_one',now())`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) VALUES('program_source','project_one','environment_one','revenuecat',$1,'credential_source','mapping',1,0,7,7,decode(repeat('11',32),'hex'),decode(repeat('12',32),'hex'),'program-source',decode(repeat('13',32),'hex'),'owner_one',now(),now())`, billingmigration.AdapterVersion); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO billing_migration_program_scopes(program_id,project_id,environment_id,application_id,platform,created_at) VALUES('program_source','project_one','environment_one','app_one','ios',now())`); err != nil { + t.Fatal(err) + } +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/source_pull.go b/apps/api/internal/platform/billingmigrationpostgres/source_pull.go new file mode 100644 index 00000000..f63f65fb --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/source_pull.go @@ -0,0 +1,206 @@ +package billingmigrationpostgres + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "sort" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +var _ billingmigration.SourcePullRepository = (*Repository)(nil) + +func sourcePullID(programID, key string) string { + digest := sha256.Sum256([]byte("mosaic-source-pull-v1\x1f" + programID + "\x1f" + key)) + return "msp_" + hex.EncodeToString(digest[:12]) +} + +func (r *Repository) QueueSourcePull(ctx context.Context, command billingmigration.SourcePullCommand) (billingmigration.SourcePullJob, bool, error) { + auth, err := r.Authorize(ctx, command.Actor, command.ProjectID, billingmigration.CapabilityManageSource) + if err != nil || auth.OrganizationID == "" { + return billingmigration.SourcePullJob{}, false, err + } + id := sourcePullID(command.ProgramID, command.IdempotencyKey) + tag, err := r.pool.Exec(ctx, `INSERT INTO billing_migration_source_pull_jobs(id,program_id,project_id,intent,idempotency_key,request_digest,expected_program_state_version,starting_cursor,starting_watermark,starting_watermark_digest,predecessor_pull_job_id,mapping_set_id,status,due_at,max_attempts,created_by_actor_id,created_at,updated_at) + SELECT $1,p.id,p.project_id,$4,$5,$6,$7,$8,$9,$10,predecessor.id,m.id,'pending',$11,8,$12,$11,$11 + FROM billing_migration_programs p JOIN LATERAL (SELECT id FROM billing_migration_mapping_sets WHERE program_id=p.id AND project_id=p.project_id AND status='frozen' ORDER BY version DESC LIMIT 1) m ON true + LEFT JOIN LATERAL (SELECT candidate.id FROM billing_migration_source_pull_jobs candidate WHERE $4<>'snapshot' AND candidate.program_id=p.id AND candidate.project_id=p.project_id AND candidate.status='completed' AND candidate.resume_cursor=$8 AND candidate.final_watermark=$9 AND candidate.evidence_digest=$10 AND ($4<>'final_delta' OR candidate.id=(SELECT head.id FROM billing_migration_source_pull_jobs head WHERE head.program_id=p.id AND head.project_id=p.project_id AND head.status='completed' ORDER BY head.completed_at DESC,head.id DESC LIMIT 1)) ORDER BY candidate.completed_at DESC,candidate.id DESC LIMIT 1) predecessor ON true + WHERE p.id=$2 AND p.project_id=$3 AND p.state_version=$7 AND p.state IN ('mapping','importing','dry_run','shadowing','ready') + AND (($4='snapshot' AND predecessor.id IS NULL) OR ($4<>'snapshot' AND predecessor.id IS NOT NULL)) + ON CONFLICT(program_id,idempotency_key) DO NOTHING`, id, command.ProgramID, command.ProjectID, command.Intent, command.IdempotencyKey, command.RequestDigest, command.ExpectedStateVersion, command.StartingCursor, command.StartingWatermark, command.StartingWatermarkDigest, command.CreatedAt, command.Actor.ID) + if err != nil { + return billingmigration.SourcePullJob{}, false, translate(err, "queue migration source pull") + } + job, err := r.sourcePullJob(ctx, command.ProjectID, command.ProgramID, command.IdempotencyKey) + if err != nil { + if errors.Is(err, billingmigration.ErrConflict) && command.Intent != billingmigration.SourcePullSnapshot { + return job, false, billingmigration.ErrStaleCheckpoint + } + return job, false, err + } + if string(job.RequestDigest) != string(command.RequestDigest) { + return job, true, billingmigration.ErrIdempotencyConflict + } + return job, tag.RowsAffected() == 0, nil +} + +func (r *Repository) sourcePullJob(ctx context.Context, projectID, programID, key string) (billingmigration.SourcePullJob, error) { + var j billingmigration.SourcePullJob + var startedAt, completedAt, failedAt *time.Time + err := r.pool.QueryRow(ctx, `SELECT job.id,job.project_id,job.program_id,job.intent,job.status,job.starting_cursor,job.starting_watermark,job.starting_watermark_digest,coalesce(job.predecessor_pull_job_id,''),job.idempotency_key,job.request_digest,job.expected_program_state_version,job.lease_generation,job.attempt_count,job.max_attempts,job.created_at,job.updated_at,job.started_at,job.completed_at,job.failed_at,coalesce(job.result_source_object_id,''),coalesce(job.result_manifest_id,''),coalesce(job.result_import_batch_id,''),coalesce(job.result_final_delta_job_id,''),coalesce(import.status,''),coalesce(job.last_error_code,''),job.evidence_digest,source.plaintext_digest,manifest.manifest_digest,import.request_digest,final.request_digest FROM billing_migration_source_pull_jobs job LEFT JOIN billing_migration_source_objects source ON source.id=job.result_source_object_id AND source.program_id=job.program_id AND source.project_id=job.project_id LEFT JOIN billing_migration_source_manifests manifest ON manifest.id=job.result_manifest_id AND manifest.program_id=job.program_id AND manifest.project_id=job.project_id LEFT JOIN billing_migration_import_batches import ON import.id=job.result_import_batch_id AND import.program_id=job.program_id AND import.project_id=job.project_id LEFT JOIN billing_migration_final_delta_jobs final ON final.id=job.result_final_delta_job_id AND final.program_id=job.program_id AND final.project_id=job.project_id WHERE job.project_id=$1 AND job.program_id=$2 AND job.idempotency_key=$3`, projectID, programID, key).Scan(&j.ID, &j.ProjectID, &j.ProgramID, &j.Intent, &j.Status, &j.StartingCursor, &j.StartingWatermark, &j.StartingWatermarkDigest, &j.PredecessorPullJobID, &j.IdempotencyKey, &j.RequestDigest, &j.ExpectedStateVersion, &j.LeaseGeneration, &j.AttemptCount, &j.MaxAttempts, &j.CreatedAt, &j.UpdatedAt, &startedAt, &completedAt, &failedAt, &j.ResultSourceObjectID, &j.ResultManifestID, &j.ResultImportBatchID, &j.ResultFinalDeltaJobID, &j.ResultImportStatus, &j.FailureCode, &j.EvidenceDigest, &j.SourceObjectDigest, &j.ManifestDigest, &j.ImportDigest, &j.FinalDeltaDigest) + if errors.Is(err, pgx.ErrNoRows) { + return j, billingmigration.ErrConflict + } + if err == nil { + if startedAt != nil { + j.StartedAt = *startedAt + } + if completedAt != nil { + j.CompletedAt = *completedAt + } + if failedAt != nil { + j.FailedAt = *failedAt + } + } + return j, err +} + +func (r *Repository) LeaseSourcePull(ctx context.Context, workerID string, now, leaseUntil time.Time) (billingmigration.SourcePullLease, bool, error) { + if workerID == "" || !leaseUntil.After(now) { + return billingmigration.SourcePullLease{}, false, billingmigration.ErrInvalid + } + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.SourcePullLease{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + var id string + err = tx.QueryRow(ctx, `SELECT id FROM billing_migration_source_pull_jobs WHERE (status='pending' AND due_at<=$1 OR status='running' AND lease_expires_at<=$1) AND attempt_count 0 { + d := sha256.Sum256(source.Ownership) + record.OwnershipDigest = d[:] + } + if source.Kind == "subscription" && record.QuarantineReason == "" { + expectedEnvironment := "sandbox" + if lease.MosaicEnvironmentMode == "production" { + expectedEnvironment = "production" + } + if source.Environment != "production" && source.Environment != "sandbox" { + record.QuarantineReason = "unsupported_environment" + } else if source.Environment != expectedEnvironment { + record.QuarantineReason = "environment_mismatch" + } + } + if source.Kind == "subscription" && record.QuarantineReason == "" { + candidates := bindings[source.ProductID] + matched := candidates[:0] + for _, candidate := range candidates { + if candidate.platform == source.Platform { + matched = append(matched, candidate) + } + } + switch len(matched) { + case 0: + record.QuarantineReason = "missing_application_binding" + case 1: + product, hasProductEvidence := productEvidence[source.ProductID] + if source.ProviderReference == "" { + record.QuarantineReason = "missing_provider_reference" + } else if !hasProductEvidence || product.StoreIdentifier == "" || product.ExternalAppID == "" || product.Platform != source.Platform { + record.QuarantineReason = "missing_application_binding" + } else { + record.TargetProductID = matched[0].targetProduct + record.ProviderReference = &billingmigration.KnownProviderReference{Provider: source.Provider, EnvironmentID: lease.EnvironmentID, ApplicationID: matched[0].app, Reference: source.ProviderReference, ReferenceKind: source.ReferenceKind, SourceProductID: source.ProductID, TargetProductID: matched[0].targetProduct, ExpectedStoreProductID: product.StoreIdentifier, ExpectedStoreEnvironment: source.Environment} + } + default: + record.QuarantineReason = "ambiguous_application" + } + } + result = append(result, record) + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result, nil +} + +func (r *Repository) SettleSourcePull(ctx context.Context, s billingmigration.SourcePullSettlement) error { + if s.Status != "failed" { + return billingmigration.ErrInvalid + } + status := "pending" + if s.Lease.AttemptCount >= s.Lease.MaxAttempts { + status = "failed" + } + tag, err := r.pool.Exec(ctx, `UPDATE billing_migration_source_pull_jobs SET status=$6,lease_owner=NULL,lease_expires_at=NULL,last_error_code=$7,due_at=$8,started_at=CASE WHEN $6='pending' THEN NULL ELSE started_at END,failed_at=CASE WHEN $6='failed' THEN $9 ELSE NULL END,updated_at=$9 WHERE id=$1 AND program_id=$2 AND project_id=$3 AND status='running' AND lease_owner=$4 AND lease_generation=$5 AND lease_expires_at>$9`, s.Lease.ID, s.Lease.ProgramID, s.Lease.ProjectID, s.Lease.Owner, s.Lease.LeaseGeneration, status, s.ErrorCode, s.RetryAt, s.SettledAt) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return billingmigration.ErrLeaseLost + } + return nil +} + +func relationshipDigest(record billingmigration.NormalizedSourceRecord) []byte { + value, _ := json.Marshal([]any{record.CustomerID, record.ProductID, record.TargetProductID, record.EntitlementIDs, record.ExternalAppID, record.Store, record.SourceEnvironment, record.StoreIdentifier, record.Ownership, record.QuarantineReason}) + d := sha256.Sum256(value) + return d[:] +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go new file mode 100644 index 00000000..b2f2f3d4 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go @@ -0,0 +1,214 @@ +package billingmigrationpostgres_test + +import ( + "context" + "database/sql" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +func TestSourcePullLeaseAndFrozenApplicationBinding(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err = db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + if err = goose.DownToContext(ctx, db, ".", 57); err != nil { + t.Fatalf("empty 00058 down: %v", err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("00058 re-up: %v", err) + } + seedMigrationTenant(t, ctx, db) + if _, err = db.ExecContext(ctx, `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) VALUES('credential_source','project_one','revenuecat','rc_project','active',1,'AES-256-GCM','credential_key',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),'owner_one',now())`); err != nil { + t.Fatal(err) + } + if _, err = db.ExecContext(ctx, `INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) VALUES('program_source','project_one','environment_one','revenuecat',$1,'credential_source','mapping',1,0,7,7,decode(repeat('11',32),'hex'),decode(repeat('12',32),'hex'),'program-source',decode(repeat('13',32),'hex'),'owner_one',now(),now())`, billingmigration.AdapterVersion); err != nil { + t.Fatal(err) + } + if _, err = db.ExecContext(ctx, `INSERT INTO billing_migration_program_scopes(program_id,project_id,environment_id,application_id,platform,created_at) VALUES('program_source','project_one','environment_one','app_one','ios',now())`); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Microsecond) + initialCapability, err := billingmigration.SourcePullCapabilityAssessment("project_one", "program_source", 1, billingmigration.ProviderAPIV2, []string{billingmigration.SourceCapabilityReadCustomers}, bytesOf(0x49)) + if err != nil { + t.Fatal(err) + } + if _, err = db.ExecContext(ctx, `INSERT INTO billing_migration_capability_assessments(id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) VALUES($1,'program_source','project_one',1,'v2',ARRAY['read_customers'],$2,$3)`, initialCapability.AssessmentID, initialCapability.AssessmentDigest, now.Add(-time.Minute)); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) VALUES('app_two','project_one','App Two','ios','com.example.two',$1,$1)`, + `INSERT INTO billing_migration_program_scopes(program_id,project_id,environment_id,application_id,platform,created_at) VALUES('program_source','project_one','environment_one','app_two','ios',$1)`, + `INSERT INTO products(id,project_id,key,internal_name,description,type,status,metadata_source,readiness_ready,readiness_reasons,created_at,updated_at) VALUES('product_one','project_one','one','One','','subscription','draft','mock',false,'[]',$1,$1)`, + `INSERT INTO products(id,project_id,key,internal_name,description,type,status,metadata_source,readiness_ready,readiness_reasons,created_at,updated_at) VALUES('product_two','project_one','two','Two','','subscription','draft','mock',false,'[]',$1,$1)`, + `INSERT INTO billing_migration_mapping_sets(id,program_id,project_id,version,status,mapping_digest,expected_program_state_version,created_by_actor_id,created_at,frozen_at) VALUES('mapping_pull','program_source','project_one',1,'frozen',decode(repeat('51',32),'hex'),1,'owner_one',$1,$1)`, + `INSERT INTO billing_migration_mapping_entries(id,mapping_set_id,program_id,project_id,source_kind,source_identifier,target_id,match_kind,application_id,platform,created_at) VALUES('map_pull_one','mapping_pull','program_source','project_one','product','rc_product','product_one','exact','app_one','ios',$1),('map_pull_two','mapping_pull','program_source','project_one','product','rc_product','product_one','exact','app_two','ios',$1)`, + `INSERT INTO billing_migration_mapping_entries(id,mapping_set_id,program_id,project_id,source_kind,source_identifier,target_id,match_kind,application_id,platform,created_at) VALUES('map_pull_exact','mapping_pull','program_source','project_one','product','rc_product_exact','product_two','exact','app_one','ios',$1)`, + } { + if _, err = db.ExecContext(ctx, statement, now); err != nil { + t.Fatal(err) + } + } + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + repository := billingmigrationpostgres.New(pool) + service := billingmigration.NewSourcePullService(repository, func() time.Time { return now }) + seedCompletedSourcePullCheckpoint(t, ctx, db, "old", "old-cursor", "old-watermark", bytesOf(0x53), now.Add(-2*time.Minute)) + seedCompletedSourcePullCheckpoint(t, ctx, db, "latest", "opaque", "watermark", bytesOf(0x50), now.Add(-time.Minute)) + _, _, err = service.Queue(ctx, billingmigration.SourcePullCommand{Actor: billingmigration.Actor{ID: "owner_one"}, ProjectID: "project_one", ProgramID: "program_source", Intent: billingmigration.SourcePullDelta, StartingCursor: "caller-cursor", StartingWatermark: "watermark", StartingWatermarkDigest: bytesOf(0x50), IdempotencyKey: "arbitrary-position", ExpectedStateVersion: 1}) + if !errors.Is(err, billingmigration.ErrStaleCheckpoint) { + t.Fatalf("arbitrary caller position err=%v", err) + } + _, _, err = service.Queue(ctx, billingmigration.SourcePullCommand{Actor: billingmigration.Actor{ID: "owner_one"}, ProjectID: "project_one", ProgramID: "program_source", Intent: billingmigration.SourcePullFinalDelta, StartingCursor: "old-cursor", StartingWatermark: "old-watermark", StartingWatermarkDigest: bytesOf(0x53), IdempotencyKey: "stale-final", ExpectedStateVersion: 1}) + if !errors.Is(err, billingmigration.ErrStaleCheckpoint) { + t.Fatalf("non-head final delta err=%v", err) + } + job, replay, err := service.Queue(ctx, billingmigration.SourcePullCommand{Actor: billingmigration.Actor{ID: "owner_one"}, ProjectID: "project_one", ProgramID: "program_source", Intent: billingmigration.SourcePullFinalDelta, StartingCursor: "opaque", StartingWatermark: "watermark", StartingWatermarkDigest: bytesOf(0x50), IdempotencyKey: "final-one", ExpectedStateVersion: 1}) + if err != nil || replay || job.Status != "pending" { + t.Fatalf("queue job=%#v replay=%v err=%v", job, replay, err) + } + if job.PredecessorPullJobID != "pull_latest" { + t.Fatalf("predecessor=%q", job.PredecessorPullJobID) + } + lease, leased, err := repository.LeaseSourcePull(ctx, "worker_one", now, now.Add(time.Minute)) + if err != nil || !leased { + t.Fatalf("lease=%v err=%v", leased, err) + } + if _, leased, err = repository.LeaseSourcePull(ctx, "worker_two", now, now.Add(time.Minute)); err != nil || leased { + t.Fatalf("concurrent lease=%v err=%v", leased, err) + } + records, err := repository.BindSourcePullRecords(ctx, lease, []billingmigration.SourcePullRecord{{Kind: "subscription", SourceIdentifier: "sub", SourceRevision: "1", Digest: bytesOf(0x52), CurrentAccess: true, ObservedAt: now, ProductID: "rc_product", Store: "app_store", Environment: "production", Provider: "app_store", Platform: "ios", ReferenceKind: "app_store_transaction_id", ProviderReference: "transaction"}}) + if err != nil || len(records) != 1 || records[0].QuarantineReason != "ambiguous_application" || records[0].ProviderReference != nil { + t.Fatalf("ambiguous binding records=%#v err=%v", records, err) + } + stale := lease + stale.LeaseGeneration-- + err = repository.SettleSourcePull(ctx, billingmigration.SourcePullSettlement{Lease: stale, Status: "failed", ErrorCode: "test_failure", RetryAt: now.Add(time.Minute), SettledAt: now.Add(time.Second)}) + if !errors.Is(err, billingmigration.ErrLeaseLost) { + t.Fatalf("stale settlement err=%v", err) + } + environmentRecords, err := repository.BindSourcePullRecords(ctx, lease, []billingmigration.SourcePullRecord{ + {Kind: "product", SourceIdentifier: "rc_product_exact", SourceRevision: "1", Digest: bytesOf(0x54), ObservedAt: now, ExternalAppID: "rc_app", Store: "app_store", Provider: "app_store", Platform: "ios", StoreIdentifier: "sku.exact"}, + {Kind: "subscription", SourceIdentifier: "sub_valid", SourceRevision: "1", Digest: bytesOf(0x55), CurrentAccess: true, ObservedAt: now, ProductID: "rc_product_exact", Store: "app_store", Environment: "production", Provider: "app_store", Platform: "ios", ReferenceKind: "app_store_transaction_id", ProviderReference: "transaction_valid"}, + {Kind: "subscription", SourceIdentifier: "sub_mismatch", SourceRevision: "1", Digest: bytesOf(0x56), CurrentAccess: true, ObservedAt: now, ProductID: "rc_product_exact", Store: "app_store", Environment: "sandbox", Provider: "app_store", Platform: "ios", ReferenceKind: "app_store_transaction_id", ProviderReference: "transaction_mismatch"}, + {Kind: "subscription", SourceIdentifier: "sub_unsupported", SourceRevision: "1", Digest: bytesOf(0x57), CurrentAccess: true, ObservedAt: now, ProductID: "rc_product_exact", Store: "app_store", Environment: "staging", Provider: "app_store", Platform: "ios", ReferenceKind: "app_store_transaction_id", ProviderReference: "transaction_unsupported"}, + }) + if err != nil { + t.Fatal(err) + } + byID := make(map[string]billingmigration.NormalizedSourceRecord) + for _, record := range environmentRecords { + byID[record.SourceIdentifier] = record + } + if ref := byID["sub_valid"].ProviderReference; ref == nil || ref.ExpectedStoreEnvironment != "production" { + t.Fatalf("valid environment binding=%#v", ref) + } + if byID["sub_mismatch"].QuarantineReason != "environment_mismatch" || byID["sub_unsupported"].QuarantineReason != "unsupported_environment" { + t.Fatalf("environment quarantines mismatch=%q unsupported=%q", byID["sub_mismatch"].QuarantineReason, byID["sub_unsupported"].QuarantineReason) + } + reservation := billingmigration.ReserveSourceObject{SourceObject: billingmigration.SourceObject{SourceObjectScope: billingmigration.SourceObjectScope{ProjectID: "project_one", ProgramID: "program_source", ObjectID: "object_capability", AdapterVersion: billingmigration.AdapterVersion, SchemaVersion: "revenuecat-migration-source-v2"}, ReservationKey: "source-pull-capability", ReservationDigest: bytesOf(0x5a), ReservationGeneration: 1, WriteTokenDigest: bytesOf(0x5b), ObjectKey: billingmigration.DeterministicSourceObjectKey("project_one", "program_source", "object_capability"), SourceChannel: billingmigration.SourceChannelRevenueCat}, Now: now} + object, replay, err := repository.ReserveSourceObject(ctx, reservation) + if err != nil || replay { + t.Fatalf("reserve capability object replay=%v err=%v", replay, err) + } + envelope := billingmigration.SourceObjectEnvelope{Version: 1, Algorithm: "AES-256-GCM-CHUNKED", KeyID: "source_key", Nonce: make([]byte, 12), ChunkSize: 16384, ChunkCount: 1, AADDigest: bytesOf(0x5c), PlaintextDigest: bytesOf(0x5d), PlaintextSize: 10, CiphertextDigest: bytesOf(0x5e), CiphertextSize: 128} + if err = repository.VerifySourceObject(ctx, billingmigration.VerifySourceObject{ProjectID: "project_one", ProgramID: "program_source", ObjectID: object.ObjectID, ReservationGeneration: 1, WriteTokenDigest: bytesOf(0x5b), Envelope: envelope, Now: now}); err != nil { + t.Fatal(err) + } + write := billingmigration.SourceObjectManifestWrite{ + ExpectedStateVersion: 1, SourceObjectID: object.ObjectID, SourcePullJobID: lease.ID, SourcePullOwner: lease.Owner, SourcePullGeneration: lease.LeaseGeneration, SourcePullEvidenceDigest: bytesOf(0x5f), SourcePullResumeCursor: "next-capability-cursor", SourcePullFinalWatermark: "capability-watermark", SourcePullProvenCapabilities: []string{billingmigration.SourceCapabilityReadCustomers, billingmigration.SourceCapabilityReadSubscriptions, billingmigration.SourceCapabilityReadAliases, billingmigration.SourceCapabilityIncrementalDelta}, Records: environmentRecords, BindingDigest: bytesOf(0x60), Now: now.Add(2 * time.Second), + } + write.Manifest = billingmigration.ManifestWrite{Manifest: billingmigration.SourceManifest{ManifestID: "manifest_capability", ProgramID: "program_source", StateVersion: 1, AdapterVersion: billingmigration.AdapterVersion, ProviderAPIVersion: billingmigration.ProviderAPIV2, SchemaVersion: "revenuecat-migration-source-v2", RecordCount: int64(len(environmentRecords)), CurrentAccessRecordCount: 3, CapturedAt: write.Now}, ProjectID: "project_one", ObjectKey: object.ObjectKey, ObjectChecksum: envelope.PlaintextDigest, ObjectSizeBytes: envelope.PlaintextSize, ManifestDigest: bytesOf(0x61), SourceWatermark: write.SourcePullFinalWatermark} + write.ImportWork = &billingmigration.ImportBatchWrite{Batch: billingmigration.ImportBatch{BatchID: "batch_capability", ProgramID: "program_source", StateVersion: 1, IdempotencyKey: "source-pull:" + lease.ID, RecordCount: 1}, ProjectID: "project_one", ManifestID: "manifest_capability", MappingSetID: lease.MappingSetID, RequestDigest: bytesOf(0x62), CursorBefore: lease.StartingCursor, CreatedAt: write.Now} + if err = repository.AppendVerifiedSource(ctx, write); err != nil { + t.Fatalf("append verified source with capability assessment: %v", err) + } + var capabilityCount int + var latestCapabilities []string + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_capability_assessments WHERE program_id='program_source'`).Scan(&capabilityCount); err != nil { + t.Fatal(err) + } + if err = pool.QueryRow(ctx, `SELECT capabilities FROM billing_migration_capability_assessments WHERE program_id='program_source' ORDER BY assessed_at DESC,id DESC LIMIT 1`).Scan(&latestCapabilities); err != nil { + t.Fatal(err) + } + if capabilityCount != 2 || strings.Join(latestCapabilities, ",") != "incremental_delta,read_aliases,read_customers,read_subscriptions" { + t.Fatalf("capability promotion count=%d latest=%#v", capabilityCount, latestCapabilities) + } + replayCapability, err := billingmigration.SourcePullCapabilityAssessment("project_one", "program_source", 1, billingmigration.ProviderAPIV2, write.SourcePullProvenCapabilities, write.SourcePullEvidenceDigest) + if err != nil { + t.Fatal(err) + } + if _, appended, err := repository.AppendCapabilityAssessment(ctx, replayCapability); err != nil || appended { + t.Fatalf("capability replay appended=%v err=%v", appended, err) + } + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_capability_assessments WHERE program_id='program_source'`).Scan(&capabilityCount); err != nil || capabilityCount != 2 { + t.Fatalf("capability replay count=%d err=%v", capabilityCount, err) + } + if err = goose.DownToContext(ctx, db, ".", 58); err != nil { + t.Fatalf("down to 58: %v", err) + } + err = goose.DownToContext(ctx, db, ".", 57) + if err == nil || !strings.Contains(err.Error(), "durable source-pull evidence exists") { + t.Fatalf("populated 00058 down err=%v", err) + } + version, versionErr := goose.GetDBVersionContext(ctx, db) + if versionErr != nil || version != 58 { + t.Fatalf("guard version=%d err=%v", version, versionErr) + } + var pullCount int + if err = db.QueryRowContext(ctx, `SELECT count(*) FROM billing_migration_source_pull_jobs`).Scan(&pullCount); err != nil || pullCount == 0 { + t.Fatalf("guard lost pulls count=%d err=%v", pullCount, err) + } +} + +func seedCompletedSourcePullCheckpoint(t *testing.T, ctx context.Context, db *sql.DB, suffix, cursor, watermark string, digest []byte, completedAt time.Time) { + t.Helper() + objectID, manifestID, batchID, pullID := "object_"+suffix, "manifest_"+suffix, "batch_"+suffix, "pull_"+suffix + statements := []struct { + query string + args []any + }{ + {`INSERT INTO billing_migration_source_objects(id,program_id,project_id,reservation_key,reservation_digest,reservation_generation,write_token_digest,object_key,source_channel,adapter_version,schema_version,state,envelope_version,algorithm,key_id,nonce,chunk_size,chunk_count,aad_digest,plaintext_digest,plaintext_size_bytes,ciphertext_digest,ciphertext_size_bytes,reserved_at,verified_at) VALUES($1,'program_source','project_one',$2,$3,1,$3,$4,'revenuecat_api_v2',$5,'revenuecat-migration-source-v2','verified',1,'AES-256-GCM-CHUNKED','source_key',decode(repeat('01',12),'hex'),16384,1,$3,$3,1,$3,32,$6,$6)`, []any{objectID, "reservation_" + suffix, digest, "object/" + suffix, billingmigration.AdapterVersion, completedAt}}, + {`INSERT INTO billing_migration_source_manifests(id,program_id,project_id,state_version,adapter_version,provider_api_version,schema_version,record_count,current_access_record_count,object_key,object_checksum,object_size_bytes,object_encryption,manifest_digest,source_watermark,captured_at) VALUES($1,'program_source','project_one',1,$2,'v2','revenuecat-migration-source-v2',0,0,$3,$4,1,'AES-256-GCM',$4,$5,$6)`, []any{manifestID, billingmigration.AdapterVersion, "object/" + suffix, digest, watermark, completedAt}}, + {`INSERT INTO billing_migration_import_batches(id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest,expected_program_state_version,status,record_count,validated_count,quarantined_count,cursor_before,cursor_after,attempt_count,lease_generation,created_at,updated_at,due_at,max_attempts) VALUES($1,'program_source','project_one',$2,'mapping_pull',$3,$4,1,'completed',0,0,0,'',$5,0,0,$6,$6,$6,8)`, []any{batchID, manifestID, "batch-" + suffix, digest, cursor, completedAt}}, + {`INSERT INTO billing_migration_source_pull_jobs(id,program_id,project_id,intent,idempotency_key,request_digest,expected_program_state_version,starting_cursor,starting_watermark,starting_watermark_digest,predecessor_pull_job_id,mapping_set_id,status,result_source_object_id,result_manifest_id,result_import_batch_id,resume_cursor,final_watermark,evidence_digest,record_count,current_access_count,import_record_count,due_at,lease_generation,attempt_count,max_attempts,created_by_actor_id,started_at,completed_at,created_at,updated_at) VALUES($1,'program_source','project_one','snapshot',$2,$3,1,'','',NULL,NULL,'mapping_pull','completed',$4,$5,$6,$7,$8,$3,0,0,0,$9,1,1,8,'owner_one',$9,$9,$9,$9)`, []any{pullID, "pull-" + suffix, digest, objectID, manifestID, batchID, cursor, watermark, completedAt}}, + } + for _, statement := range statements { + if _, err := db.ExecContext(ctx, statement.query, statement.args...); err != nil { + t.Fatal(err) + } + } +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/stabilization.go b/apps/api/internal/platform/billingmigrationpostgres/stabilization.go new file mode 100644 index 00000000..967d8359 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/stabilization.go @@ -0,0 +1,298 @@ +package billingmigrationpostgres + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +// accessAPIEvidenceFreshness is intentionally shorter than an operator +// observation cadence. An old successful request cannot prove the access API +// is healthy now; after this bound, missing traffic/evidence fails closed as +// access_api_unknown. This is an internal monitoring invariant, not a public +// API or migration policy knob. +const accessAPIEvidenceFreshness = 2 * time.Minute + +func stabilizationDigest(domain string, value any) []byte { + raw, _ := json.Marshal(value) + sum := sha256.Sum256(append([]byte(domain+"\x00"), raw...)) + return sum[:] +} +func stabilizationID(prefix string, raw []byte) string { + return prefix + "_" + hex.EncodeToString(raw[:12]) +} + +func (r *Repository) RecordTrustedAccessAPISignal(ctx context.Context, s billingmigration.TrustedAccessAPISignal) error { + if s.ID == "" || s.ProgramID == "" || s.ProjectID == "" || s.RequestCount < 0 || s.ErrorCount < 0 || s.ErrorCount > s.RequestCount || !s.WindowEndedAt.After(s.WindowStartedAt) || len(s.EvidenceDigest) != 32 { + return billingmigration.ErrInvalid + } + _, err := r.pool.Exec(ctx, `INSERT INTO billing_migration_access_api_signal_windows(id,program_id,project_id,window_started_at,window_ended_at,request_count,error_count,evidence_digest) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, s.ID, s.ProgramID, s.ProjectID, s.WindowStartedAt.UTC(), s.WindowEndedAt.UTC(), s.RequestCount, s.ErrorCount, s.EvidenceDigest) + return translate(err, "append trusted access API signal") +} + +// RecordTrustedAccessAPIResult maps one authenticated, completed trusted-server +// access check to every stabilizing Program in the exact Project/Environment. +// PostgreSQL supplies the evidence clock, and a random nonce keeps concurrent +// API instances from collapsing equal outcomes into one immutable row. +func (r *Repository) RecordTrustedAccessAPIResult(ctx context.Context, projectID, environmentID string, elapsed time.Duration, failed bool) error { + if r == nil || r.pool == nil || projectID == "" || environmentID == "" { + return billingmigration.ErrInvalid + } + if elapsed < time.Microsecond { + elapsed = time.Microsecond + } + if elapsed > time.Minute { + elapsed = time.Minute + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin trusted access API signal: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var endedAt time.Time + if err = tx.QueryRow(ctx, `SELECT transaction_timestamp()`).Scan(&endedAt); err != nil { + return fmt.Errorf("read trusted access API evidence clock: %w", err) + } + rows, err := tx.Query(ctx, `SELECT id,project_id FROM billing_migration_programs WHERE project_id=$1 AND environment_id=$2 AND state='stabilizing' ORDER BY id`, projectID, environmentID) + if err != nil { + return fmt.Errorf("select stabilizing programs for access API signal: %w", err) + } + type program struct{ id, projectID string } + programs := make([]program, 0, 1) + for rows.Next() { + var p program + if err = rows.Scan(&p.id, &p.projectID); err != nil { + rows.Close() + return fmt.Errorf("scan stabilizing program for access API signal: %w", err) + } + programs = append(programs, p) + } + if err = rows.Err(); err != nil { + rows.Close() + return fmt.Errorf("iterate stabilizing programs for access API signal: %w", err) + } + rows.Close() + + for _, p := range programs { + nonce := make([]byte, 16) + if _, err = rand.Read(nonce); err != nil { + return fmt.Errorf("generate trusted access API evidence nonce: %w", err) + } + errorCount := int64(0) + if failed { + errorCount = 1 + } + startedAt := endedAt.Add(-elapsed) + evidence := stabilizationDigest("mosaic-migration-access-api-signal-v1", struct { + Program, Project string + Started, Ended time.Time + Requests, Errors int64 + Nonce []byte + }{p.id, p.projectID, startedAt.UTC(), endedAt.UTC(), 1, errorCount, nonce}) + signal := billingmigration.TrustedAccessAPISignal{ + ID: stabilizationID("maw", evidence), ProgramID: p.id, ProjectID: p.projectID, + WindowStartedAt: startedAt, WindowEndedAt: endedAt, + RequestCount: 1, ErrorCount: errorCount, EvidenceDigest: evidence, + } + if _, err = tx.Exec(ctx, `INSERT INTO billing_migration_access_api_signal_windows(id,program_id,project_id,window_started_at,window_ended_at,request_count,error_count,evidence_digest,recorded_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$5)`, signal.ID, signal.ProgramID, signal.ProjectID, signal.WindowStartedAt.UTC(), signal.WindowEndedAt.UTC(), signal.RequestCount, signal.ErrorCount, signal.EvidenceDigest); err != nil { + return translate(err, "append trusted access API signal") + } + } + if err = tx.Commit(ctx); err != nil { + return fmt.Errorf("commit trusted access API signal: %w", err) + } + return nil +} + +func (r *Repository) FreezeStabilizationPolicy(ctx context.Context, c billingmigration.FreezeStabilizationPolicyCommand) (billingmigration.StabilizationPolicy, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.StabilizationPolicy{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + resource, replay, err := operationCommandReplay(ctx, tx, c.Input.ProgramID, "stabilization_policy", c.Input.IdempotencyKey, c.RequestDigest) + if err != nil { + return billingmigration.StabilizationPolicy{}, false, err + } + if replay { + p, err := scanStabilizationPolicy(tx.QueryRow(ctx, stabilizationPolicySelect+` WHERE id=$1 AND project_id=$2`, resource, c.Input.ProjectID)) + return p, true, err + } + var state string + var version int64 + if err = tx.QueryRow(ctx, `SELECT state,state_version FROM billing_migration_programs WHERE id=$1 AND project_id=$2 FOR UPDATE`, c.Input.ProgramID, c.Input.ProjectID).Scan(&state, &version); errors.Is(err, pgx.ErrNoRows) { + return billingmigration.StabilizationPolicy{}, false, billingmigration.ErrNotFound + } else if err != nil { + return billingmigration.StabilizationPolicy{}, false, err + } + if state != billingmigration.StateStabilizing || version != c.Input.ExpectedStateVersion { + return billingmigration.StabilizationPolicy{}, false, billingmigration.ErrStaleState + } + raw := stabilizationDigest("mosaic-migration-stabilization-policy-v1", struct { + Program string + Version int64 + Thresholds billingmigration.StabilizationThresholds + }{c.Input.ProgramID, version, c.Input.Thresholds}) + p := billingmigration.StabilizationPolicy{ID: stabilizationID("msp", raw), ProgramID: c.Input.ProgramID, ProjectID: c.Input.ProjectID, StateVersion: version, Thresholds: c.Input.Thresholds, PolicyDigest: billingmigration.FormatDigest(raw), FrozenByActorID: c.ActorID} + t := p.Thresholds + err = tx.QueryRow(ctx, `INSERT INTO billing_migration_stabilization_policies(id,program_id,project_id,state_version,authority_mismatch_max,access_api_error_max,sdk_sync_failure_max,divergence_max,validation_backlog_max,source_delta_lag_max_seconds,webhook_failure_max,webhook_freshness_max_seconds,quarantine_max,support_case_max,old_app_version_max,worker_unhealthy_max,policy_digest,frozen_by_actor_id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) RETURNING frozen_at`, p.ID, p.ProgramID, p.ProjectID, p.StateVersion, t.AuthorityMismatchMax, t.AccessAPIErrorMax, t.SDKSyncFailureMax, t.DivergenceMax, t.ValidationBacklogMax, t.SourceDeltaLagMaxSeconds, t.WebhookFailureMax, t.WebhookFreshnessMaxSeconds, t.QuarantineMax, t.SupportCaseMax, t.OldAppVersionMax, t.WorkerUnhealthyMax, raw, c.ActorID).Scan(&p.FrozenAt) + if err != nil { + return billingmigration.StabilizationPolicy{}, false, translate(err, "freeze stabilization policy") + } + if _, err = tx.Exec(ctx, `INSERT INTO billing_migration_command_idempotency(id,program_id,project_id,command_kind,idempotency_key,request_digest,resource_id,created_at) VALUES('mci_'||$1,$2,$3,'stabilization_policy',$4,$5,$1,$6)`, p.ID, p.ProgramID, p.ProjectID, c.Input.IdempotencyKey, c.RequestDigest, p.FrozenAt); err != nil { + return billingmigration.StabilizationPolicy{}, false, err + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.StabilizationPolicy{}, false, err + } + return p, false, nil +} + +const stabilizationPolicySelect = `SELECT id,program_id,project_id,state_version,authority_mismatch_max,access_api_error_max,sdk_sync_failure_max,divergence_max,validation_backlog_max,source_delta_lag_max_seconds,webhook_failure_max,webhook_freshness_max_seconds,quarantine_max,support_case_max,old_app_version_max,worker_unhealthy_max,policy_digest,frozen_by_actor_id,frozen_at FROM billing_migration_stabilization_policies` + +func scanStabilizationPolicy(row pgx.Row) (billingmigration.StabilizationPolicy, error) { + var p billingmigration.StabilizationPolicy + var raw []byte + t := &p.Thresholds + err := row.Scan(&p.ID, &p.ProgramID, &p.ProjectID, &p.StateVersion, &t.AuthorityMismatchMax, &t.AccessAPIErrorMax, &t.SDKSyncFailureMax, &t.DivergenceMax, &t.ValidationBacklogMax, &t.SourceDeltaLagMaxSeconds, &t.WebhookFailureMax, &t.WebhookFreshnessMaxSeconds, &t.QuarantineMax, &t.SupportCaseMax, &t.OldAppVersionMax, &t.WorkerUnhealthyMax, &raw, &p.FrozenByActorID, &p.FrozenAt) + if errors.Is(err, pgx.ErrNoRows) { + return p, billingmigration.ErrNotFound + } + p.PolicyDigest = billingmigration.FormatDigest(raw) + return p, err +} + +func (r *Repository) RecordStabilization(ctx context.Context, c billingmigration.RecordStabilizationCommand) (billingmigration.StabilizationObservation, bool, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return billingmigration.StabilizationObservation{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + resource, replay, err := operationCommandReplay(ctx, tx, c.Input.ProgramID, "stabilization_observation", c.Input.IdempotencyKey, c.RequestDigest) + if err != nil { + return billingmigration.StabilizationObservation{}, false, err + } + if replay { + o, err := scanStabilizationObservation(tx.QueryRow(ctx, stabilizationObservationSelect+` WHERE o.id=$1 AND o.project_id=$2`, resource, c.Input.ProjectID)) + return o, true, err + } + var state, environment string + var version int64 + var now time.Time + if err = tx.QueryRow(ctx, `SELECT state,state_version,environment_id,clock_timestamp() FROM billing_migration_programs WHERE id=$1 AND project_id=$2 FOR UPDATE`, c.Input.ProgramID, c.Input.ProjectID).Scan(&state, &version, &environment, &now); errors.Is(err, pgx.ErrNoRows) { + return billingmigration.StabilizationObservation{}, false, billingmigration.ErrNotFound + } else if err != nil { + return billingmigration.StabilizationObservation{}, false, err + } + if state != billingmigration.StateStabilizing || version != c.Input.ExpectedStateVersion { + return billingmigration.StabilizationObservation{}, false, billingmigration.ErrStaleState + } + p, err := scanStabilizationPolicy(tx.QueryRow(ctx, stabilizationPolicySelect+` WHERE program_id=$1 AND project_id=$2`, c.Input.ProgramID, c.Input.ProjectID)) + if err != nil { + return billingmigration.StabilizationObservation{}, false, err + } + policyRaw, _ := billingmigration.ParseDigest(p.PolicyDigest) + if !bytes.Equal(policyRaw, c.ExpectedPolicyDigest) { + return billingmigration.StabilizationObservation{}, false, billingmigration.ErrStaleDigest + } + m := billingmigration.StabilizationMetrics{} + var sourceAt, webhookAt *time.Time + var accessKnown bool + err = tx.QueryRow(ctx, `SELECT + (SELECT count(*) FROM billing_migration_program_scopes ps LEFT JOIN billing_migration_authority_scopes a ON a.project_id=ps.project_id AND a.environment_id=ps.environment_id AND a.application_id=ps.application_id AND a.platform=ps.platform WHERE ps.program_id=$1 AND ps.project_id=$2 AND (a.current_authority IS DISTINCT FROM 'mosaic' OR a.current_epoch IS DISTINCT FROM $4 OR a.active_program_id IS DISTINCT FROM $1)), + COALESCE((SELECT sum(error_count) FROM billing_migration_access_api_signal_windows WHERE program_id=$1 AND project_id=$2 AND window_ended_at>=GREATEST($3,$7-($8*interval '1 second'))),$5+1), + EXISTS(SELECT 1 FROM billing_migration_access_api_signal_windows WHERE program_id=$1 AND project_id=$2 AND request_count>0 AND window_ended_at>=GREATEST($3,$7-($8*interval '1 second'))), + COALESCE((SELECT sum(traffic_count) FROM billing_migration_v2_sync_observations WHERE program_id=$1 AND project_id=$2 AND observed_at>=$3 AND sync_result<>'accepted'),0), + (SELECT count(*) FROM billing_migration_divergences d LEFT JOIN billing_migration_divergence_resolutions x ON x.divergence_id=d.id WHERE d.program_id=$1 AND d.project_id=$2 AND x.id IS NULL), + (SELECT count(*) FROM billing_migration_import_batch_records ir JOIN billing_migration_source_records sr ON sr.id=ir.source_record_id AND sr.program_id=ir.program_id AND sr.project_id=ir.project_id WHERE ir.program_id=$1 AND ir.project_id=$2 AND sr.current_access AND NOT EXISTS(SELECT 1 FROM billing_migration_validation_bindings vb WHERE vb.program_id=ir.program_id AND vb.project_id=ir.project_id AND vb.environment_id=ir.environment_id AND vb.expected_application_id=ir.application_id AND vb.provider=ir.provider AND vb.reference_kind=ir.reference_kind AND vb.reference_digest=CASE WHEN ir.provider='app_store' THEN sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||ir.provider_reference,'UTF8')) ELSE sha256(convert_to('mosaic-billing-google-order-v1'||chr(0)||ir.provider_reference,'UTF8')) END AND vb.expected_store_environment=ir.expected_store_environment AND (ir.expected_store_product_identifier IS NULL OR (vb.expected_store_product_identifier=ir.expected_store_product_identifier AND vb.expected_mosaic_product_id=ir.mosaic_product_id)) AND vb.status IN ('validated','quarantined')))+ + (SELECT count(*) FROM billing_migration_import_batches WHERE program_id=$1 AND project_id=$2 AND status IN ('pending','running')), + COALESCE((SELECT source_watermark FROM billing_migration_final_deltas WHERE program_id=$1 AND project_id=$2 ORDER BY completed_at DESC,id DESC LIMIT 1),NULL), + (SELECT count(*) FROM webhook_deliveries d JOIN webhook_events e ON e.id=d.webhook_event_id AND e.project_id=d.project_id JOIN billing_migration_authority_scopes a ON a.id=e.authority_scope_id AND a.project_id=e.project_id JOIN webhook_destinations destination ON destination.id=d.webhook_destination_id AND destination.project_id=d.project_id WHERE d.project_id=$2 AND d.environment_id=$6 AND a.active_program_id=$1 AND e.contract_version=2 AND e.event_type IN ('authority.cutover.completed','authority.rollback.completed','authority.stabilization.completed') AND destination.status='active' AND d.created_at>=$3 AND d.status IN ('failed','exhausted')), + (SELECT CASE WHEN count(*)=count(last_success) THEN min(last_success) END FROM (SELECT ps.application_id,ps.platform,destination.id,(SELECT max(d.completed_at) FROM webhook_deliveries d JOIN webhook_events e ON e.id=d.webhook_event_id AND e.project_id=d.project_id JOIN billing_migration_authority_scopes a ON a.id=e.authority_scope_id AND a.project_id=e.project_id WHERE d.webhook_destination_id=destination.id AND a.project_id=ps.project_id AND a.environment_id=ps.environment_id AND a.application_id=ps.application_id AND a.platform=ps.platform AND a.active_program_id=ps.program_id AND e.contract_version=2 AND e.event_type IN ('authority.cutover.completed','authority.rollback.completed','authority.stabilization.completed') AND d.status='succeeded') last_success FROM billing_migration_program_scopes ps CROSS JOIN webhook_destinations destination WHERE ps.program_id=$1 AND ps.project_id=$2 AND destination.project_id=ps.project_id AND destination.environment_id=ps.environment_id AND destination.status='active' AND destination.contract_version=2 AND destination.event_types && ARRAY['authority.cutover.completed','authority.rollback.completed','authority.stabilization.completed']::text[]) coverage), + (SELECT count(*) FROM billing_quarantine_records WHERE project_id=$2 AND environment_id=$6 AND status IN ('open','retrying'))+(SELECT count(*) FROM billing_migration_source_record_relationships WHERE program_id=$1 AND project_id=$2 AND quarantine_reason IS NOT NULL), + (SELECT count(*) FROM billing_migration_cases WHERE program_id=$1 AND project_id=$2 AND status IN ('open','in_progress')), + (SELECT count(*) FROM billing_migration_supported_app_versions WHERE program_id=$1 AND project_id=$2 AND (NOT supported OR NOT authority_aware)), + (SELECT count(*) FROM billing_migration_run_jobs WHERE program_id=$1 AND project_id=$2 AND (status='failed' OR (status='running' AND lease_expires_at<$7)))+(SELECT count(*) FROM billing_migration_final_delta_jobs WHERE program_id=$1 AND project_id=$2 AND (status='failed' OR (status='running' AND lease_expires_at<$7)))+(SELECT count(*) FROM billing_migration_source_pull_jobs WHERE program_id=$1 AND project_id=$2 AND (status='failed' OR (status='running' AND lease_expires_at<$7)))`, c.Input.ProgramID, c.Input.ProjectID, p.FrozenAt, c.Input.ExpectedAuthorityEpoch, p.Thresholds.AccessAPIErrorMax, environment, now, int64(accessAPIEvidenceFreshness/time.Second)).Scan(&m.AuthorityMismatches, &m.AccessAPIErrors, &accessKnown, &m.SDKSyncFailures, &m.Divergences, &m.ValidationBacklog, &sourceAt, &m.WebhookFailures, &webhookAt, &m.QuarantinedRecords, &m.SupportCases, &m.OldAppVersions, &m.UnhealthyWorkers) + if err != nil { + return billingmigration.StabilizationObservation{}, false, err + } + breaches := billingmigration.StabilizationBreaches(p.Thresholds, m) + if !accessKnown { + breaches = append(breaches, "access_api_unknown") + } + if sourceAt == nil { + v := now.Add(-time.Duration(p.Thresholds.SourceDeltaLagMaxSeconds+1) * time.Second) + sourceAt = &v + breaches = append(breaches, "source_delta_unknown") + } + if webhookAt == nil { + v := now.Add(-time.Duration(p.Thresholds.WebhookFreshnessMaxSeconds+1) * time.Second) + webhookAt = &v + breaches = append(breaches, "webhook_unknown") + } + m.SourceDeltaLagSeconds = int64(now.Sub(*sourceAt).Seconds()) + m.WebhookAgeSeconds = int64(now.Sub(*webhookAt).Seconds()) + breaches = append(breaches, billingmigration.StabilizationBreaches(p.Thresholds, m)...) + breaches = uniqueSorted(breaches) + evidence := stabilizationDigest("mosaic-migration-stabilization-observation-v1", struct { + Program string + Version, Epoch int64 + Policy []byte + Metrics billingmigration.StabilizationMetrics + Breaches []string + Source, Webhook, At time.Time + }{c.Input.ProgramID, version, c.Input.ExpectedAuthorityEpoch, policyRaw, m, breaches, sourceAt.UTC(), webhookAt.UTC(), now.UTC()}) + o := billingmigration.StabilizationObservation{ID: stabilizationID("mso", evidence), ProgramID: c.Input.ProgramID, ProjectID: c.Input.ProjectID, PolicyID: p.ID, PolicyDigest: p.PolicyDigest, EvidenceDigest: billingmigration.FormatDigest(evidence), StateVersion: version, AuthorityEpoch: c.Input.ExpectedAuthorityEpoch, Metrics: m, SourceWatermark: sourceAt.UTC(), WebhookLastSuccessAt: webhookAt.UTC(), ObservedAt: now.UTC(), BreachCodes: breaches, Healthy: len(breaches) == 0} + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_stabilization_observations(id,program_id,project_id,policy_id,state_version,authority_epoch,authority_mismatches,access_api_errors,sdk_sync_failures,divergences,validation_backlog,source_delta_lag_seconds,webhook_failures,webhook_age_seconds,quarantined_records,support_cases,old_app_versions,unhealthy_workers,source_watermark,webhook_last_success_at,breach_codes,healthy,evidence_digest,observed_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)`, o.ID, o.ProgramID, o.ProjectID, o.PolicyID, o.StateVersion, o.AuthorityEpoch, m.AuthorityMismatches, m.AccessAPIErrors, m.SDKSyncFailures, m.Divergences, m.ValidationBacklog, m.SourceDeltaLagSeconds, m.WebhookFailures, m.WebhookAgeSeconds, m.QuarantinedRecords, m.SupportCases, m.OldAppVersions, m.UnhealthyWorkers, o.SourceWatermark, o.WebhookLastSuccessAt, o.BreachCodes, o.Healthy, evidence, o.ObservedAt) + if err != nil { + return billingmigration.StabilizationObservation{}, false, translate(err, "append stabilization observation") + } + if _, err = tx.Exec(ctx, `INSERT INTO billing_migration_command_idempotency(id,program_id,project_id,command_kind,idempotency_key,request_digest,resource_id,created_at) VALUES('mci_'||$1,$2,$3,'stabilization_observation',$4,$5,$1,$6)`, o.ID, o.ProgramID, o.ProjectID, c.Input.IdempotencyKey, c.RequestDigest, o.ObservedAt); err != nil { + return billingmigration.StabilizationObservation{}, false, err + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.StabilizationObservation{}, false, err + } + return o, false, nil +} + +func uniqueSorted(values []string) []string { + sort.Strings(values) + out := values[:0] + for _, v := range values { + if len(out) == 0 || out[len(out)-1] != v { + out = append(out, v) + } + } + return out +} + +const stabilizationObservationSelect = `SELECT o.id,o.program_id,o.project_id,o.policy_id,p.policy_digest,o.state_version,o.authority_epoch,o.authority_mismatches,o.access_api_errors,o.sdk_sync_failures,o.divergences,o.validation_backlog,o.source_delta_lag_seconds,o.webhook_failures,o.webhook_age_seconds,o.quarantined_records,o.support_cases,o.old_app_versions,o.unhealthy_workers,o.source_watermark,o.webhook_last_success_at,o.breach_codes,o.healthy,o.evidence_digest,o.observed_at FROM billing_migration_stabilization_observations o JOIN billing_migration_stabilization_policies p ON p.id=o.policy_id` + +func scanStabilizationObservation(row pgx.Row) (billingmigration.StabilizationObservation, error) { + var o billingmigration.StabilizationObservation + var policy, evidence []byte + m := &o.Metrics + err := row.Scan(&o.ID, &o.ProgramID, &o.ProjectID, &o.PolicyID, &policy, &o.StateVersion, &o.AuthorityEpoch, &m.AuthorityMismatches, &m.AccessAPIErrors, &m.SDKSyncFailures, &m.Divergences, &m.ValidationBacklog, &m.SourceDeltaLagSeconds, &m.WebhookFailures, &m.WebhookAgeSeconds, &m.QuarantinedRecords, &m.SupportCases, &m.OldAppVersions, &m.UnhealthyWorkers, &o.SourceWatermark, &o.WebhookLastSuccessAt, &o.BreachCodes, &o.Healthy, &evidence, &o.ObservedAt) + if errors.Is(err, pgx.ErrNoRows) { + return o, billingmigration.ErrNotFound + } + o.PolicyDigest = billingmigration.FormatDigest(policy) + o.EvidenceDigest = billingmigration.FormatDigest(evidence) + if err != nil { + return o, fmt.Errorf("scan stabilization observation: %w", err) + } + return o, nil +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go new file mode 100644 index 00000000..4c0ad585 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go @@ -0,0 +1,176 @@ +package billingmigrationpostgres_test + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" + + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +func TestStabilizationAndRollbackReadinessUsePersistedEvidence(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Now().UTC().Truncate(time.Microsecond) + seedReadyProgram(t, ctx, db, now) + _, err := pool.Exec(ctx, `UPDATE billing_migration_programs SET state='stabilizing',state_version=7,updated_at=$1 WHERE id='program_ready'; UPDATE billing_migration_authority_scopes SET current_authority='mosaic',current_epoch=1,authority_digest=decode(repeat('a1',32),'hex'),updated_at=$1 WHERE active_program_id='program_ready'; INSERT INTO billing_migration_capability_assessments(id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) VALUES('capability_rollback','program_ready','project_one',7,'v2',ARRAY['read_customers','read_subscriptions','read_aliases','incremental_delta'],decode(repeat('af',32),'hex'),$1-interval '1 minute'); INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('stable_ios','program_ready','project_one','app_one','ios','2.10.0+ios.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('a2',32),'hex'),$1),('stable_android_incomplete','program_ready','project_one','app_two','android','2.10.0+android.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('a3',32),'hex'),$1)`, now) + if err != nil { + t.Fatal(err) + } + repo := billingmigrationpostgres.New(pool) + thresholds := billingmigration.StabilizationThresholds{AuthorityMismatchMax: 0, AccessAPIErrorMax: 1, SDKSyncFailureMax: 0, DivergenceMax: 0, ValidationBacklogMax: 0, SourceDeltaLagMaxSeconds: 3600, WebhookFailureMax: 0, WebhookFreshnessMaxSeconds: 3600, QuarantineMax: 0, SupportCaseMax: 0, OldAppVersionMax: 0, WorkerUnhealthyMax: 0} + policy, _, err := repo.FreezeStabilizationPolicy(ctx, billingmigration.FreezeStabilizationPolicyCommand{Input: billingmigration.FreezeStabilizationPolicyInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: "policy", ExpectedStateVersion: 7, Thresholds: thresholds}, ActorID: "owner_one", RequestDigest: bytes.Repeat([]byte{0xb1}, 32)}) + if err != nil { + t.Fatal(err) + } + // Simulate two API instances completing adjacent windows. The later success + // must not hide the earlier error: stabilization sums all fresh immutable + // windows instead of selecting whichever instance wrote last. + if err = repo.RecordTrustedAccessAPIResult(ctx, "project_one", "environment_one", 20*time.Millisecond, true); err != nil { + t.Fatal(err) + } + if err = repo.RecordTrustedAccessAPIResult(ctx, "project_one", "environment_one", 10*time.Millisecond, false); err != nil { + t.Fatal(err) + } + policyRaw, _ := billingmigration.ParseDigest(policy.PolicyDigest) + observe := billingmigration.RecordStabilizationCommand{Input: billingmigration.RecordStabilizationInput{ProjectID: "project_one", ProgramID: "program_ready", IdempotencyKey: "observe", ExpectedStateVersion: 7, ExpectedAuthorityEpoch: 1, ExpectedPolicyDigest: policy.PolicyDigest}, ActorID: "owner_one", RequestDigest: bytes.Repeat([]byte{0xb3}, 32), ExpectedPolicyDigest: policyRaw} + observation, replay, err := repo.RecordStabilization(ctx, observe) + if err != nil || replay { + t.Fatalf("observation=%#v replay=%v err=%v", observation, replay, err) + } + // No successful webhook evidence exists. It is unknown/blocking rather than + // being silently treated as zero failures and healthy. + if observation.Healthy || !containsString(observation.BreachCodes, "webhook_unknown") { + t.Fatalf("untrusted missing telemetry became healthy: %+v", observation) + } + if observation.Metrics.AccessAPIErrors != 1 || containsString(observation.BreachCodes, "access_api_unknown") { + t.Fatalf("multi-instance access evidence was masked or treated as unknown: %+v", observation) + } + if replayed, replayedFlag, err := repo.RecordStabilization(ctx, observe); err != nil || !replayedFlag || replayed.ID != observation.ID { + t.Fatalf("observation replay=%#v replay=%v err=%v", replayed, replayedFlag, err) + } + stale := observe + stale.RequestDigest = bytes.Repeat([]byte{0xb4}, 32) + if _, _, err = repo.RecordStabilization(ctx, stale); !errors.Is(err, billingmigration.ErrIdempotencyConflict) { + t.Fatalf("changed observation request error=%v", err) + } + observationRaw, _ := billingmigration.ParseDigest(observation.EvidenceDigest) + assessment, checkpoint, _, err := repo.AssessRollbackReadiness(ctx, billingmigration.AssessRollbackReadinessCommand{Input: billingmigration.AssessRollbackReadinessInput{ProjectID: "project_one", ProgramID: "program_ready", ObservationID: observation.ID, IdempotencyKey: "readiness", ExpectedStateVersion: 7, ExpectedAuthorityEpoch: 1, ExpectedObservationDigest: observation.EvidenceDigest}, ActorID: "owner_one", RequestDigest: bytes.Repeat([]byte{0xb5}, 32), ExpectedObservationDigest: observationRaw}) + if err != nil { + t.Fatal(err) + } + if assessment.Ready || assessment.SourceSupportAvailable || checkpoint.ID != "" { + t.Fatalf("missing latest final source pull minted checkpoint: assessment=%#v checkpoint=%#v", assessment, checkpoint) + } + _, err = pool.Exec(ctx, `INSERT INTO billing_migration_source_objects(id,program_id,project_id,reservation_key,reservation_digest,reservation_generation,write_token_digest,object_key,source_channel,adapter_version,schema_version,state,envelope_version,algorithm,key_id,nonce,chunk_size,chunk_count,aad_digest,plaintext_digest,plaintext_size_bytes,ciphertext_digest,ciphertext_size_bytes,reserved_at,verified_at) VALUES('object_stable','program_ready','project_one','stable',decode(repeat('c1',32),'hex'),1,decode(repeat('c2',32),'hex'),'private/stable','revenuecat_api_v2','v2','v1','verified',1,'AES-256-GCM-CHUNKED','key',decode(repeat('01',12),'hex'),16384,1,decode(repeat('c3',32),'hex'),decode(repeat('c4',32),'hex'),1,decode(repeat('c5',32),'hex'),17,$1,$1); + INSERT INTO billing_migration_source_objects(id,program_id,project_id,reservation_key,reservation_digest,reservation_generation,write_token_digest,object_key,source_channel,adapter_version,schema_version,state,envelope_version,algorithm,key_id,nonce,chunk_size,chunk_count,aad_digest,plaintext_digest,plaintext_size_bytes,ciphertext_digest,ciphertext_size_bytes,reserved_at,verified_at) VALUES('object_predecessor','program_ready','project_one','predecessor',decode(repeat('d1',32),'hex'),1,decode(repeat('d2',32),'hex'),'private/predecessor','revenuecat_api_v2','v2','v1','verified',1,'AES-256-GCM-CHUNKED','key',decode(repeat('02',12),'hex'),16384,1,decode(repeat('d3',32),'hex'),decode(repeat('d4',32),'hex'),0,decode(repeat('d5',32),'hex'),16,$1,$1); + INSERT INTO billing_migration_source_manifests(id,program_id,project_id,state_version,adapter_version,provider_api_version,schema_version,record_count,current_access_record_count,object_key,object_checksum,object_size_bytes,object_encryption,manifest_digest,source_watermark,captured_at) VALUES('manifest_predecessor','program_ready','project_one',7,'v2','v2','v1',0,0,'private/predecessor',decode(repeat('d5',32),'hex'),16,'AES-256-GCM',decode(repeat('d6',32),'hex'),'predecessor-watermark',$1); + INSERT INTO billing_migration_import_batches(id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest,expected_program_state_version,status,record_count,validated_count,quarantined_count,cursor_before,cursor_after,attempt_count,lease_generation,created_at,updated_at,due_at,max_attempts) VALUES('batch_predecessor','program_ready','project_one','manifest_predecessor','mapping_ready','predecessor-batch',decode(repeat('d7',32),'hex'),7,'completed',0,0,0,'','',1,1,$1,$1,$1,8); + INSERT INTO billing_migration_import_batches(id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest,expected_program_state_version,status,record_count,validated_count,quarantined_count,cursor_before,cursor_after,attempt_count,lease_generation,created_at,updated_at,due_at,max_attempts) VALUES('batch_stable','program_ready','project_one','manifest_ready','mapping_ready','stable-batch',decode(repeat('c6',32),'hex'),7,'completed',2,1,0,'','',1,1,$1,$1,$1,8); + INSERT INTO billing_migration_source_records(id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,record_digest,current_access,normalization_schema_version,evidence_kind,observed_at,created_at) VALUES('record_unvalidated','program_ready','project_one','manifest_ready','subscription','subscription-unvalidated','1',decode(repeat('de',32),'hex'),true,'v1','trusted_source_export',$1,$1); + INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,expected_store_environment) VALUES('batch_stable','program_ready','project_one','record_ready',0,'app_store','environment_one','app_one','transaction-stable','app_store_transaction_id','production'); + INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,expected_store_environment) VALUES('batch_stable','program_ready','project_one','record_unvalidated',1,'app_store','environment_one','app_one','transaction-unvalidated','app_store_transaction_id','production'); + INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_stable','project_one','org_one','environment_one','production','app_one','app_store','migration_known_reference','store_reconciliation',decode(repeat('da',32),'hex'),decode(repeat('db',32),'hex'),sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-stable','UTF8')),'not_retained','verified_transport','unclassified','accepted','stable',$1,$1+interval '1 hour'); + INSERT INTO billing_validation_attempts(id,project_id,environment_id,raw_input_id,attempt_number,validator_version,started_at,completed_at,outcome,retryable,store_environment,latency_ms,correlation_id) VALUES('attempt_stable','project_one','environment_one','raw_stable',1,2,$1,$1,'validated',false,'production',1,'stable'); + INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_stable','program_ready','project_one','environment_one','raw_stable','app_store','app_store_transaction_id',sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-stable','UTF8')),'app_one','store.product','production','customer_two','validated','attempt_stable',decode(repeat('dd',32),'hex'),$1,$1,$1); + INSERT INTO billing_migration_source_pull_jobs(id,program_id,project_id,intent,idempotency_key,request_digest,expected_program_state_version,starting_cursor,starting_watermark,starting_watermark_digest,mapping_set_id,status,result_source_object_id,result_manifest_id,result_import_batch_id,resume_cursor,final_watermark,evidence_digest,record_count,current_access_count,import_record_count,due_at,lease_generation,attempt_count,max_attempts,created_by_actor_id,started_at,completed_at,created_at,updated_at) VALUES('pull_completed_predecessor','program_ready','project_one','snapshot','completed-predecessor',decode(repeat('c7',32),'hex'),7,'','',NULL,'mapping_ready','completed','object_predecessor','manifest_predecessor','batch_predecessor','cursor','watermark',decode(repeat('c9',32),'hex'),0,0,0,$1,1,1,8,'owner_one',$1,$1,$1,$1); + INSERT INTO billing_migration_source_pull_jobs(id,program_id,project_id,intent,idempotency_key,request_digest,expected_program_state_version,starting_cursor,starting_watermark,starting_watermark_digest,predecessor_pull_job_id,mapping_set_id,status,result_source_object_id,result_manifest_id,result_import_batch_id,result_final_delta_job_id,resume_cursor,final_watermark,evidence_digest,record_count,current_access_count,import_record_count,due_at,lease_generation,attempt_count,max_attempts,created_by_actor_id,started_at,completed_at,created_at,updated_at) VALUES('pull_final_stable','program_ready','project_one','final_delta','final-stable',decode(repeat('c8',32),'hex'),7,'cursor','watermark',decode(repeat('c9',32),'hex'),'pull_completed_predecessor','mapping_ready','completed','object_stable','manifest_ready','batch_stable','delta_job_ready','','final-watermark',decode(repeat('ca',32),'hex'),2,2,1,$1,1,1,8,'owner_one',$1,$1,$1,$1); + INSERT INTO webhook_destinations(id,project_id,environment_id,url,status,event_types,description,contract_version,last_successful_test_at,created_at,updated_at) VALUES('stable_webhook','project_one','environment_one','https://stable.example.test','active',ARRAY['authority.cutover.completed'],'stable',2,$1,$1,$1),('stable_webhook_missing','project_one','environment_one','https://missing.example.test','active',ARRAY['authority.cutover.completed'],'missing coverage',2,$1,$1,$1); + INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id,snapshot_version,payload,occurred_at,created_at,contract_version,authority_scope_id,authority_epoch,authority_kind,transition_state,correlation_id,snapshot_authority_digest) VALUES('stable_event','project_one','environment_one','authority.cutover.completed','customer_one',0,'{}',$1,$1,2,'authority_ready_ios',1,'mosaic','stabilizing','stable-correlation',decode(repeat('a1',32),'hex')); + INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id,snapshot_version,payload,occurred_at,created_at,contract_version,authority_scope_id,authority_epoch,authority_kind,transition_state,correlation_id,snapshot_authority_digest) VALUES('stable_event_android','project_one','environment_one','authority.cutover.completed','customer_one',0,'{}',$1,$1,2,'authority_ready_android',1,'mosaic','stabilizing','stable-correlation-android',decode(repeat('a1',32),'hex')); + INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,created_at,updated_at,completed_at) VALUES('stable_delivery','project_one','environment_one','stable_event','stable_webhook','succeeded',1,8,$1,$1,$1),('stable_delivery_android','project_one','environment_one','stable_event_android','stable_webhook','succeeded',1,8,$1,$1,$1)`, now) + if err != nil { + t.Fatal(err) + } + observe2 := observe + observe2.Input.IdempotencyKey = "observe-negative-evidence" + observe2.RequestDigest = bytes.Repeat([]byte{0xcb}, 32) + negativeObservation, _, err := repo.RecordStabilization(ctx, observe2) + if err != nil { + t.Fatal(err) + } + if negativeObservation.Metrics.ValidationBacklog != 1 || !containsString(negativeObservation.BreachCodes, "webhook_unknown") { + t.Fatalf("exact-reference backlog or destination coverage was lost: %+v", negativeObservation) + } + negativeRaw, _ := billingmigration.ParseDigest(negativeObservation.EvidenceDigest) + negativeAssessment, negativeCheckpoint, _, err := repo.AssessRollbackReadiness(ctx, billingmigration.AssessRollbackReadinessCommand{Input: billingmigration.AssessRollbackReadinessInput{ProjectID: "project_one", ProgramID: "program_ready", ObservationID: negativeObservation.ID, IdempotencyKey: "readiness-negative-evidence", ExpectedStateVersion: 7, ExpectedAuthorityEpoch: 1, ExpectedObservationDigest: negativeObservation.EvidenceDigest}, ActorID: "owner_one", RequestDigest: bytes.Repeat([]byte{0xcf}, 32), ExpectedObservationDigest: negativeRaw}) + if err != nil { + t.Fatal(err) + } + if negativeAssessment.Ready || !negativeAssessment.SourceSupportAvailable || negativeAssessment.ApplicationCompatible || negativeCheckpoint.ID != "" { + t.Fatalf("weak SDK policy or incomplete destination/backlog evidence minted checkpoint: assessment=%#v checkpoint=%#v", negativeAssessment, negativeCheckpoint) + } + if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_rollback_readiness_checkpoints(id,program_id,project_id,assessment_id,assessment_ready,state_version,authority_epoch,authority_digest,policy_digest,evidence_digest,readiness_digest,checkpoint_digest,created_by_actor_id,created_at) VALUES('forged_not_ready','program_ready','project_one',$1,true,7,1,decode(repeat('f1',32),'hex'),decode(repeat('f2',32),'hex'),decode(repeat('f3',32),'hex'),decode(repeat('f4',32),'hex'),decode(repeat('f5',32),'hex'),'owner_one',$2)`, negativeAssessment.ID, now); err == nil { + t.Fatal("database accepted checkpoint referencing a not-ready assessment") + } + _, err = pool.Exec(ctx, `INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_unvalidated','project_one','org_one','environment_one','production','app_one','app_store','migration_known_reference','store_reconciliation',decode(repeat('e1',32),'hex'),decode(repeat('e2',32),'hex'),sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-unvalidated','UTF8')),'not_retained','verified_transport','unclassified','accepted','unvalidated',$1,$1+interval '1 hour'); + INSERT INTO billing_validation_attempts(id,project_id,environment_id,raw_input_id,attempt_number,validator_version,started_at,completed_at,outcome,retryable,store_environment,latency_ms,correlation_id) VALUES('attempt_unvalidated','project_one','environment_one','raw_unvalidated',1,2,$1,$1,'validated',false,'production',1,'unvalidated'); + INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_unvalidated','program_ready','project_one','environment_one','raw_unvalidated','app_store','app_store_transaction_id',sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-unvalidated','UTF8')),'app_one','store.product','production','customer_two','validated','attempt_unvalidated',decode(repeat('e4',32),'hex'),$1,$1,$1); + INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('stable_android_complete','program_ready','project_one','app_two','android','2.10.0+android.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('e5',32),'hex'),$1); + INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,created_at,updated_at,completed_at) VALUES('stable_delivery_missing_ios','project_one','environment_one','stable_event','stable_webhook_missing','succeeded',1,8,$1,$1,$1),('stable_delivery_missing_android','project_one','environment_one','stable_event_android','stable_webhook_missing','succeeded',1,8,$1,$1,$1)`, now) + if err != nil { + t.Fatal(err) + } + observeReady := observe + observeReady.Input.IdempotencyKey = "observe-ready" + observeReady.RequestDigest = bytes.Repeat([]byte{0xd0}, 32) + readyObservation, _, err := repo.RecordStabilization(ctx, observeReady) + if err != nil { + t.Fatal(err) + } + if !readyObservation.Healthy || readyObservation.Metrics.ValidationBacklog != 0 { + t.Fatalf("terminal exact bindings or full destination coverage remained unhealthy: %+v", readyObservation) + } + readyRaw, _ := billingmigration.ParseDigest(readyObservation.EvidenceDigest) + readyAssessment, readyCheckpoint, _, err := repo.AssessRollbackReadiness(ctx, billingmigration.AssessRollbackReadinessCommand{Input: billingmigration.AssessRollbackReadinessInput{ProjectID: "project_one", ProgramID: "program_ready", ObservationID: readyObservation.ID, IdempotencyKey: "readiness-ready", ExpectedStateVersion: 7, ExpectedAuthorityEpoch: 1, ExpectedObservationDigest: readyObservation.EvidenceDigest}, ActorID: "owner_one", RequestDigest: bytes.Repeat([]byte{0xcc}, 32), ExpectedObservationDigest: readyRaw}) + if err != nil { + t.Fatal(err) + } + if !readyAssessment.Ready || !readyAssessment.SourceSupportAvailable || readyCheckpoint.ID == "" { + t.Fatalf("persisted prerequisites did not mint checkpoint: assessment=%#v checkpoint=%#v", readyAssessment, readyCheckpoint) + } + _, err = pool.Exec(ctx, `UPDATE billing_migration_source_pull_jobs SET status='failed',result_source_object_id=NULL,result_manifest_id=NULL,result_import_batch_id=NULL,evidence_digest=NULL,completed_at=NULL,failed_at=$1,last_error_code='broken_chain' WHERE id='pull_completed_predecessor'`, now.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + observe3 := observe + observe3.Input.IdempotencyKey = "observe-broken-chain" + observe3.RequestDigest = bytes.Repeat([]byte{0xcd}, 32) + brokenObservation, _, err := repo.RecordStabilization(ctx, observe3) + if err != nil { + t.Fatal(err) + } + brokenRaw, _ := billingmigration.ParseDigest(brokenObservation.EvidenceDigest) + brokenAssessment, brokenCheckpoint, _, err := repo.AssessRollbackReadiness(ctx, billingmigration.AssessRollbackReadinessCommand{Input: billingmigration.AssessRollbackReadinessInput{ProjectID: "project_one", ProgramID: "program_ready", ObservationID: brokenObservation.ID, IdempotencyKey: "readiness-broken-chain", ExpectedStateVersion: 7, ExpectedAuthorityEpoch: 1, ExpectedObservationDigest: brokenObservation.EvidenceDigest}, ActorID: "owner_one", RequestDigest: bytes.Repeat([]byte{0xce}, 32), ExpectedObservationDigest: brokenRaw}) + if err != nil { + t.Fatal(err) + } + if brokenAssessment.Ready || brokenAssessment.SourceSupportAvailable || brokenCheckpoint.ID != "" { + t.Fatalf("broken predecessor chain minted checkpoint: assessment=%#v checkpoint=%#v", brokenAssessment, brokenCheckpoint) + } + var checkpoints int + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_rollback_readiness_checkpoints WHERE program_id='program_ready'`).Scan(&checkpoints); err != nil || checkpoints != 1 { + t.Fatalf("checkpoint count=%d err=%v", checkpoints, err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.DownContext(ctx, db, "."); err == nil || !strings.Contains(err.Error(), "immutable stabilization or rollback-readiness evidence exists") { + t.Fatalf("migration61 down guard error=%v", err) + } +} + +func containsString(values []string, want string) bool { + for _, v := range values { + if v == want { + return true + } + } + return false +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/transition_delivery.go b/apps/api/internal/platform/billingmigrationpostgres/transition_delivery.go new file mode 100644 index 00000000..97840585 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/transition_delivery.go @@ -0,0 +1,365 @@ +package billingmigrationpostgres + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" +) + +var _ billingmigration.TransitionDeliveryRepository = (*Repository)(nil) +var _ billingmigration.RedeliveryRepository = (*Repository)(nil) + +func scanTransition(row pgx.Row) (billingmigration.TransitionOutbox, error) { + var out billingmigration.TransitionOutbox + var checkpoint, transition, completion *string + err := row.Scan(&out.ID, &out.ProgramID, &out.ProjectID, &out.AuthorityScopeID, + &checkpoint, &transition, &completion, &out.EventKind, &out.CorrelationID, + &out.AuthorityEpoch, &out.AttemptCount, &out.MaxAttempts, &out.LeaseGeneration, + &out.LeaseOwner, &out.LeaseExpiresAt, &out.CreatedAt, &out.DueAt) + if checkpoint != nil { + out.CheckpointID = *checkpoint + } + if transition != nil { + out.TransitionID = *transition + } + if completion != nil { + out.CompletionReportID = *completion + } + return out, err +} + +const transitionColumns = `id,program_id,project_id,authority_scope_id,checkpoint_id,transition_id, + completion_report_id,event_kind,correlation_id,authority_epoch,attempt_count,max_attempts, + lease_generation,coalesce(lease_owner,''),coalesce(lease_expires_at,'epoch'::timestamptz),created_at,due_at` + +func (r *Repository) AppendTransition(ctx context.Context, input billingmigration.AppendTransition) (billingmigration.TransitionOutbox, bool, error) { + if input.ID == "" { + sum := sha256.Sum256([]byte(input.ProgramID + "\x00" + input.AuthorityScopeID + "\x00" + input.EventKind + "\x00" + input.CorrelationID)) + input.ID = "mto_" + fmt.Sprintf("%x", sum[:16]) + } + if input.CreatedAt.IsZero() { + input.CreatedAt = time.Now().UTC() + } + if input.DueAt.IsZero() { + input.DueAt = input.CreatedAt + } + tag, err := r.pool.Exec(ctx, `INSERT INTO billing_migration_transition_outbox( + id,program_id,project_id,authority_scope_id,checkpoint_id,transition_id,completion_report_id, + event_kind,correlation_id,authority_epoch,status,due_at,created_at,updated_at) + VALUES($1,$2,$3,$4,$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,'pending',$11,$12,$12) + ON CONFLICT(program_id,authority_scope_id,event_kind,correlation_id) DO NOTHING`, + input.ID, input.ProgramID, input.ProjectID, input.AuthorityScopeID, input.CheckpointID, + input.TransitionID, input.CompletionReportID, input.EventKind, input.CorrelationID, input.AuthorityEpoch, input.DueAt, input.CreatedAt) + if err != nil { + return billingmigration.TransitionOutbox{}, false, translate(err, "append transition notification") + } + out, err := scanTransition(r.pool.QueryRow(ctx, `SELECT `+transitionColumns+` FROM billing_migration_transition_outbox + WHERE program_id=$1 AND authority_scope_id=$2 AND event_kind=$3 AND correlation_id=$4`, + input.ProgramID, input.AuthorityScopeID, input.EventKind, input.CorrelationID)) + if err != nil { + return out, false, err + } + replay := tag.RowsAffected() == 0 + if out.ProjectID != input.ProjectID || out.CheckpointID != input.CheckpointID || out.TransitionID != input.TransitionID || + out.CompletionReportID != input.CompletionReportID || out.AuthorityEpoch != input.AuthorityEpoch { + return billingmigration.TransitionOutbox{}, replay, billingmigration.ErrIdempotencyConflict + } + return out, replay, nil +} + +func (r *Repository) LeaseTransition(ctx context.Context, workerID string, now, leaseUntil time.Time) (billingmigration.TransitionOutbox, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingmigration.TransitionOutbox{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() + _, err = tx.Exec(ctx, `UPDATE billing_migration_transition_outbox SET status='failed',lease_owner=NULL, + lease_expires_at=NULL,last_error_code='attempts_exhausted',updated_at=$1 + WHERE status IN('pending','running') AND attempt_count>=max_attempts + AND (status='pending' OR lease_expires_at<=$1)`, now) + if err != nil { + return billingmigration.TransitionOutbox{}, false, err + } + var id string + err = tx.QueryRow(ctx, `SELECT id FROM billing_migration_transition_outbox + WHERE status IN('pending','running') AND due_at<=$1 AND attempt_count= billingwebhook.MaxAttemptsCeiling { + return billingmigration.Redelivery{}, false, billingmigration.ErrConflict + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_webhook_redeliveries(id,program_id,project_id,webhook_event_id, + webhook_destination_id,webhook_delivery_id,idempotency_key,request_digest,expected_state_version, + expected_event_digest,reason,actor_id,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + write.RedeliveryID, write.Input.ProgramID, write.Input.ProjectID, write.Input.EventID, write.Input.DestinationID, + deliveryID, write.Input.IdempotencyKey, write.RequestDigest, write.Input.ExpectedStateVersion, write.EventDigest, + write.Input.Reason, write.ActorID, write.CreatedAt) + if err != nil { + return billingmigration.Redelivery{}, false, translate(err, "record migration webhook redelivery") + } + _, err = tx.Exec(ctx, `UPDATE webhook_deliveries SET status='pending',skipped_reason=NULL,next_attempt_at=$3, + completed_at=NULL,max_attempts=LEAST($4,attempt_count+$5),leased_by=NULL,leased_until=NULL,updated_at=$3 + WHERE id=$1 AND project_id=$2`, deliveryID, write.Input.ProjectID, write.CreatedAt, + billingwebhook.MaxAttemptsCeiling, billingwebhook.DefaultMaxAttempts) + if err != nil { + return billingmigration.Redelivery{}, false, err + } + if err = tx.Commit(ctx); err != nil { + return billingmigration.Redelivery{}, false, err + } + return billingmigration.Redelivery{ID: write.RedeliveryID, ProgramID: write.Input.ProgramID, EventID: write.Input.EventID, + DestinationID: write.Input.DestinationID, DeliveryID: deliveryID, IdempotencyKey: write.Input.IdempotencyKey, + Reason: write.Input.Reason, ExpectedStateVersion: write.Input.ExpectedStateVersion, CreatedAt: write.CreatedAt}, false, nil +} diff --git a/apps/api/internal/platform/billingmigrationpostgres/transition_delivery_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/transition_delivery_integration_test.go new file mode 100644 index 00000000..95ce8163 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationpostgres/transition_delivery_integration_test.go @@ -0,0 +1,507 @@ +package billingmigrationpostgres_test + +import ( + "bytes" + "crypto/sha256" + "errors" + "testing" + "time" + + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingwebhookpostgres" +) + +// This regression protects the production gate from accepting proof produced +// for another receiver configuration or another contract. Only a fresh, +// delivered v2 authority event subscribed by the current destination counts. +func TestDestinationReadinessRequiresCurrentV2AuthorityProof(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC) + seedExecutionCheckpoint(t, ctx, db, now) + repository := billingwebhookpostgres.New(pool) + + _, err := pool.Exec(ctx, `INSERT INTO webhook_destinations(id,project_id,environment_id,url,status,event_types, + description,contract_version,last_successful_test_at,created_at,updated_at) VALUES + ('destination_readiness','project_one','environment_one','https://receiver.example.test','active', + ARRAY['customer.entitlements.changed'],'readiness regression',1,$1,$1,$1)`, now) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO webhook_signing_secrets(id,project_id,webhook_destination_id,status,envelope_version,algorithm,key_id, + nonce,ciphertext,fingerprint,created_at) VALUES('secret_readiness','project_one','destination_readiness','active',1, + 'AES-256-GCM','test',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),$1)`, now) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id, + customer_entitlement_snapshot_id,snapshot_version,payload,contract_version,occurred_at,created_at) + VALUES('event_readiness_v1','project_one','environment_one','customer.entitlements.changed','customer_one', + 'snapshot_activation',2,'{}'::jsonb,1,$1,$1)`, now) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO webhook_delivery_attempts(id,project_id,webhook_event_id,webhook_destination_id,attempt_number,outcome,attempted_at) + VALUES('attempt_readiness_v1','project_one','event_readiness_v1','destination_readiness',1,'delivered',$1)`, now) + if err != nil { + t.Fatal(err) + } + assertReadiness := func(want int, label string, recentAfter time.Time) { + t.Helper() + readiness, readErr := repository.DestinationReadiness(ctx, "project_one", "environment_one", recentAfter) + if readErr != nil || readiness.HealthyV2Count != want { + t.Fatalf("%s readiness=%#v err=%v", label, readiness, readErr) + } + } + assertReadiness(0, "v1 delivery", now.Add(-time.Hour)) + + v2 := 2 + authorityEvents := []string{billingwebhook.EventTypeAuthorityRollbackCompleted} + if _, err = repository.UpdateDestination(ctx, "project_one", "destination_readiness", billingwebhook.DestinationUpdate{ + EventTypes: authorityEvents, ContractVersion: &v2, + }, "owner_one", now.Add(time.Second)); err != nil { + t.Fatal(err) + } + assertReadiness(0, "v1 proof after contract flip", now.Add(-time.Hour)) + + var scopeID string + if err = pool.QueryRow(ctx, `SELECT id FROM billing_migration_authority_scopes + WHERE project_id='project_one' AND application_id='app_one' AND platform='ios'`).Scan(&scopeID); err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id, + customer_entitlement_snapshot_id,snapshot_version,payload,contract_version,authority_scope_id,authority_epoch, + authority_kind,transition_state,correlation_id,snapshot_authority_digest,occurred_at,created_at) + VALUES('event_readiness_v2','project_one','environment_one','authority.rollback.completed','customer_one', + 'snapshot_activation',2,'{}'::jsonb,2,$2,2,'source_rollback','rolled_back','correlation-readiness', + decode(repeat('aa',32),'hex'),$1,$1)`, now, scopeID) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO webhook_delivery_attempts(id,project_id,webhook_event_id,webhook_destination_id,attempt_number,outcome,attempted_at) + VALUES('attempt_readiness_v2','project_one','event_readiness_v2','destination_readiness',1,'delivered',$1)`, now.Add(2*time.Second)) + if err != nil { + t.Fatal(err) + } + assertReadiness(1, "fresh v2 authority proof", now.Add(-time.Hour)) + + changedURL := "https://changed.example.test" + if _, err = repository.UpdateDestination(ctx, "project_one", "destination_readiness", billingwebhook.DestinationUpdate{URL: &changedURL}, "owner_one", now.Add(3*time.Second)); err != nil { + t.Fatal(err) + } + assertReadiness(0, "URL change", now.Add(-time.Hour)) + var lastTest *time.Time + if err = pool.QueryRow(ctx, `SELECT last_successful_test_at FROM webhook_destinations WHERE id='destination_readiness'`).Scan(&lastTest); err != nil || lastTest != nil { + t.Fatalf("URL change retained successful-test evidence=%v err=%v", lastTest, err) + } + + _, err = pool.Exec(ctx, `INSERT INTO webhook_delivery_attempts(id,project_id,webhook_event_id,webhook_destination_id,attempt_number,outcome,attempted_at) + VALUES('attempt_readiness_v2_after_url','project_one','event_readiness_v2','destination_readiness',2,'delivered',$1)`, now.Add(4*time.Second)) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `UPDATE webhook_destinations SET last_successful_test_at=$1 WHERE id='destination_readiness'`, now.Add(4*time.Second)) + if err != nil { + t.Fatal(err) + } + assertReadiness(1, "proof after URL change", now.Add(-time.Hour)) + + changedEvents := []string{billingwebhook.EventTypeAuthorityStabilizationCompleted} + if _, err = repository.UpdateDestination(ctx, "project_one", "destination_readiness", billingwebhook.DestinationUpdate{EventTypes: changedEvents}, "owner_one", now.Add(5*time.Second)); err != nil { + t.Fatal(err) + } + assertReadiness(0, "event subscription change", now.Add(-time.Hour)) + if err = pool.QueryRow(ctx, `SELECT last_successful_test_at FROM webhook_destinations WHERE id='destination_readiness'`).Scan(&lastTest); err != nil || lastTest != nil { + t.Fatalf("event change retained successful-test evidence=%v err=%v", lastTest, err) + } + + _, err = pool.Exec(ctx, `UPDATE webhook_destinations SET last_successful_test_at=$1 WHERE id='destination_readiness'`, now.Add(6*time.Second)) + if err != nil { + t.Fatal(err) + } + v1 := 1 + if _, err = repository.UpdateDestination(ctx, "project_one", "destination_readiness", billingwebhook.DestinationUpdate{ContractVersion: &v1, + EventTypes: []string{billingwebhook.EventTypeEntitlementsChanged}}, "owner_one", now.Add(7*time.Second)); err != nil { + t.Fatal(err) + } + assertReadiness(0, "contract change", now.Add(-time.Hour)) + if err = pool.QueryRow(ctx, `SELECT last_successful_test_at FROM webhook_destinations WHERE id='destination_readiness'`).Scan(&lastTest); err != nil || lastTest != nil { + t.Fatalf("contract change retained successful-test evidence=%v err=%v", lastTest, err) + } +} + +func TestSuccessfulV2AuthorityAttemptAtomicallyPromotesDestinationFreshness(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 30, 11, 0, 0, 0, time.UTC) + seedExecutionCheckpoint(t, ctx, db, now) + repository := billingwebhookpostgres.New(pool) + + var scopeID string + if err := pool.QueryRow(ctx, `SELECT id FROM billing_migration_authority_scopes WHERE project_id='project_one' ORDER BY id LIMIT 1`).Scan(&scopeID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id, + customer_entitlement_snapshot_id,snapshot_version,payload,contract_version,authority_scope_id,authority_epoch, + authority_kind,transition_state,correlation_id,snapshot_authority_digest,occurred_at,created_at) + VALUES('event_freshness_v2','project_one','environment_one','authority.rollback.completed','customer_one', + 'snapshot_activation',2,'{}'::jsonb,2,$2,2,'source_rollback','rolled_back','freshness-proof', + decode(repeat('aa',32),'hex'),$1,$1)`, now, scopeID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id, + customer_entitlement_snapshot_id,snapshot_version,payload,contract_version,authority_scope_id,authority_epoch, + authority_kind,transition_state,correlation_id,snapshot_authority_digest,occurred_at,created_at) + VALUES('event_freshness_non_authority','project_one','environment_one','customer.entitlements.changed','customer_one', + NULL,0,'{}'::jsonb,2,$2,2,'mosaic','stable','freshness-non-authority', + decode(repeat('ab',32),'hex'),$1,$1)`, now, scopeID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO webhook_destinations(id,project_id,environment_id,url,status,event_types,description,contract_version,created_at,updated_at) VALUES + ('fresh_success','project_one','environment_one','https://success.example.test','active',ARRAY['authority.rollback.completed'],'success',2,$1,$1), + ('fresh_failed','project_one','environment_one','https://failed.example.test','active',ARRAY['authority.rollback.completed'],'failed',2,$1,$1), + ('fresh_v1','project_one','environment_one','https://v1.example.test','active',ARRAY['customer.entitlements.changed'],'v1',1,$1,$1), + ('fresh_non_authority','project_one','environment_one','https://non-authority.example.test','active',ARRAY['customer.entitlements.changed'],'non-authority',2,$1,$1), + ('fresh_stale','project_one','environment_one','https://stale.example.test','active',ARRAY['authority.rollback.completed'],'stale',2,$1,$1)`, now); err != nil { + t.Fatal(err) + } + for _, destinationID := range []string{"fresh_success", "fresh_failed", "fresh_v1", "fresh_non_authority", "fresh_stale"} { + if _, err := pool.Exec(ctx, `INSERT INTO webhook_signing_secrets(id,project_id,webhook_destination_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_at) + VALUES('secret_'||$1,'project_one',$1,'active',1,'AES-256-GCM','test',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),sha256(convert_to($1,'UTF8')),$2)`, destinationID, now); err != nil { + t.Fatal(err) + } + eventID := "event_freshness_v2" + if destinationID == "fresh_non_authority" { + eventID = "event_freshness_non_authority" + } + if _, err := pool.Exec(ctx, `INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,next_attempt_at,created_at,updated_at) + VALUES('delivery_'||$1,'project_one','environment_one',$2,$1,'pending',1,8,$3,$3,$3)`, destinationID, eventID, now); err != nil { + t.Fatal(err) + } + } + if _, err := pool.Exec(ctx, `UPDATE webhook_deliveries SET leased_destination_config_digest=webhook_destination_config_digest(webhook_destination_id,project_id) WHERE id LIKE 'delivery_fresh_%'`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE webhook_destinations SET url='https://changed-after-lease.example.test',updated_at=$1 WHERE id='fresh_stale'`, now.Add(2*time.Minute)); err != nil { + t.Fatal(err) + } + complete := func(destinationID, outcome, status string, attemptedAt time.Time) { + t.Helper() + completedAt := attemptedAt.Add(time.Second) + eventID := "event_freshness_v2" + if destinationID == "fresh_non_authority" { + eventID = "event_freshness_non_authority" + } + _, err := repository.CompleteAttempt(ctx, billingwebhook.AttemptResult{ + Delivery: billingwebhook.Delivery{ID: "delivery_" + destinationID, ProjectID: "project_one", EnvironmentID: "environment_one", EventID: eventID, DestinationID: destinationID, MaxAttempts: 8}, + AttemptNumber: 1, Outcome: outcome, Status: status, ErrorCode: map[bool]string{true: "destination_error"}[outcome != billingwebhook.OutcomeDelivered], + AttemptedAt: attemptedAt, RespondedAt: &completedAt, CompletedAt: &completedAt, + }) + if err != nil { + t.Fatalf("complete %s: %v", destinationID, err) + } + } + var serverBefore time.Time + if err := pool.QueryRow(ctx, `SELECT clock_timestamp()`).Scan(&serverBefore); err != nil { + t.Fatal(err) + } + forgedFuture := now.Add(365 * 24 * time.Hour) + complete("fresh_success", billingwebhook.OutcomeDelivered, billingwebhook.DeliverySucceeded, forgedFuture) + var serverAfter time.Time + if err := pool.QueryRow(ctx, `SELECT clock_timestamp()`).Scan(&serverAfter); err != nil { + t.Fatal(err) + } + complete("fresh_failed", billingwebhook.OutcomePermanentFailure, billingwebhook.DeliveryFailed, now.Add(time.Minute)) + complete("fresh_v1", billingwebhook.OutcomeDelivered, billingwebhook.DeliverySucceeded, now.Add(time.Minute)) + complete("fresh_non_authority", billingwebhook.OutcomeDelivered, billingwebhook.DeliverySucceeded, now.Add(time.Minute)) + complete("fresh_stale", billingwebhook.OutcomeDelivered, billingwebhook.DeliverySucceeded, now.Add(time.Minute)) + + rows, err := pool.Query(ctx, `SELECT id,last_successful_test_at FROM webhook_destinations WHERE id LIKE 'fresh_%' ORDER BY id`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + proofs := map[string]*time.Time{} + for rows.Next() { + var id string + var proof *time.Time + if err := rows.Scan(&id, &proof); err != nil { + t.Fatal(err) + } + proofs[id] = proof + } + if proofs["fresh_success"] == nil || proofs["fresh_success"].Before(serverBefore) || proofs["fresh_success"].After(serverAfter) || proofs["fresh_success"].Equal(forgedFuture) { + t.Fatalf("successful v2 proof=%v, server bounds=[%v,%v], forged=%v", proofs["fresh_success"], serverBefore, serverAfter, forgedFuture) + } + for _, id := range []string{"fresh_failed", "fresh_v1", "fresh_non_authority", "fresh_stale"} { + if proofs[id] != nil { + t.Fatalf("%s incorrectly promoted freshness=%v", id, proofs[id]) + } + } +} + +// This PostgreSQL test protects the Package B transaction boundary: the exact +// checkpoint cohort is expanded once per scope, an absent rollback baseline is +// represented as version zero, only v2 destinations receive transition +// events, and stable-event redelivery requeues the same immutable body. +func TestTransitionDeliveryAndStableEventRedelivery(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC) + seedExecutionCheckpoint(t, ctx, db, now) + _, err := pool.Exec(ctx, `INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) VALUES('org_one','member_redelivery','member',$1,$1)`, now) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO webhook_destinations(id,project_id,environment_id,url,status,event_types, + description,contract_version,created_at,updated_at) VALUES + ('destination_v1','project_one','environment_one','https://v1.example.test','active',ARRAY['customer.entitlements.changed'],'v1',1,$1,$1), + ('destination_v2','project_one','environment_one','https://v2.example.test','active',ARRAY['authority.rollback.completed'],'v2',2,$1,$1)`, now) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO webhook_signing_secrets(id,project_id,webhook_destination_id,status,envelope_version,algorithm,key_id, + nonce,ciphertext,fingerprint,created_at) VALUES('secret_v2','project_one','destination_v2','active',1,'AES-256-GCM','test', + decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),$1)`, now) + if err != nil { + t.Fatal(err) + } + readiness, err := billingwebhookpostgres.New(pool).DestinationReadiness(ctx, "project_one", "environment_one", now.Add(-time.Hour)) + if err != nil || readiness.Ready() { + t.Fatalf("unproven destination readiness=%#v err=%v", readiness, err) + } + _, err = pool.Exec(ctx, `UPDATE webhook_destinations SET last_successful_test_at=$2 WHERE id=$1`, `destination_v2`, now) + if err != nil { + t.Fatal(err) + } + readiness, err = billingwebhookpostgres.New(pool).DestinationReadiness(ctx, "project_one", "environment_one", now.Add(-time.Hour)) + if err != nil || readiness.Ready() { + t.Fatalf("test timestamp incorrectly counted as authority proof readiness=%#v err=%v", readiness, err) + } + _, err = pool.Exec(ctx, `UPDATE webhook_destinations SET last_successful_test_at=NULL WHERE id=$1`, `destination_v2`) + if err != nil { + t.Fatal(err) + } + rows, err := pool.Query(ctx, `SELECT id FROM billing_migration_authority_scopes WHERE active_program_id='program_ready' ORDER BY id`) + if err != nil { + t.Fatal(err) + } + var scopes []string + for rows.Next() { + var id string + if err = rows.Scan(&id); err != nil { + t.Fatal(err) + } + scopes = append(scopes, id) + } + rows.Close() + for index, scopeID := range scopes { + transitionID := "transition_rollback_" + string(rune('a'+index)) + _, err = pool.Exec(ctx, `INSERT INTO billing_migration_authority_transitions(id,program_id,project_id,authority_scope_id, + from_authority,to_authority,from_epoch,to_epoch,transition_kind,transition_digest,transitioned_at) + VALUES($1,'program_ready','project_one',$2,'mosaic','source_rollback',1,2,'rollback',decode(repeat('ab',32),'hex'),$3)`, + transitionID, scopeID, now) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO billing_migration_transition_outbox(id,program_id,project_id,authority_scope_id,transition_id,event_kind, + authority_epoch,status,created_at,updated_at) VALUES($4,'program_ready','project_one',$2,$1,'rollback_changed',2,'pending',$3,$3)`, + transitionID, scopeID, now, "outbox_rollback_"+string(rune('a'+index))) + if err != nil { + t.Fatal(err) + } + } + repository := billingmigrationpostgres.New(pool) + service := billingmigration.NewTransitionDeliveryService(repository, func() time.Time { return now.Add(time.Second) }) + for range scopes { + processed, processErr := service.ProcessOne(ctx, "transition-worker") + if processErr != nil || !processed { + t.Fatalf("process transition=%v err=%v", processed, processErr) + } + } + // Force the event insert to fail after leasing. The outbox must return to a + // due retry without creating a partial event or touching authority state. + _, _, err = service.Append(ctx, billingmigration.AppendTransition{ID: "outbox_forced_failure", ProgramID: "program_ready", + ProjectID: "project_one", AuthorityScopeID: scopes[0], CheckpointID: "checkpoint_execute", + EventKind: billingmigration.TransitionCutoverPending, CorrelationID: "checkpoint_execute_failure", + AuthorityEpoch: 0, CreatedAt: now.Add(500 * time.Millisecond), DueAt: now}) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `CREATE FUNCTION fail_test_transition_event() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced transition event failure'; END $$`) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `CREATE TRIGGER fail_test_transition_event BEFORE INSERT ON webhook_events FOR EACH ROW WHEN (NEW.transition_outbox_id='outbox_forced_failure') EXECUTE FUNCTION fail_test_transition_event()`) + if err != nil { + t.Fatal(err) + } + var authorityBefore []byte + if err = pool.QueryRow(ctx, `SELECT authority_digest FROM billing_migration_authority_scopes WHERE id=$1`, scopes[0]).Scan(&authorityBefore); err != nil { + t.Fatal(err) + } + processed, forcedErr := service.ProcessOne(ctx, "failure-worker") + if !processed || forcedErr == nil { + t.Fatalf("forced failure processed=%v err=%v", processed, forcedErr) + } + var retryStatus string + var partialEvents int + var authorityAfter []byte + if err = pool.QueryRow(ctx, `SELECT status FROM billing_migration_transition_outbox WHERE id='outbox_forced_failure'`).Scan(&retryStatus); err != nil { + t.Fatal(err) + } + if err = pool.QueryRow(ctx, `SELECT count(*) FROM webhook_events WHERE transition_outbox_id='outbox_forced_failure'`).Scan(&partialEvents); err != nil { + t.Fatal(err) + } + if err = pool.QueryRow(ctx, `SELECT authority_digest FROM billing_migration_authority_scopes WHERE id=$1`, scopes[0]).Scan(&authorityAfter); err != nil { + t.Fatal(err) + } + if retryStatus != "pending" || partialEvents != 0 || !bytes.Equal(authorityBefore, authorityAfter) { + t.Fatalf("forced failure status=%s events=%d authorityChanged=%v", retryStatus, partialEvents, !bytes.Equal(authorityBefore, authorityAfter)) + } + _, _ = pool.Exec(ctx, `DROP TRIGGER fail_test_transition_event ON webhook_events`) + _, _ = pool.Exec(ctx, `DROP FUNCTION fail_test_transition_event()`) + var events, absent, v1Deliveries, v2Deliveries int + err = pool.QueryRow(ctx, `SELECT + (SELECT count(*) FROM webhook_events WHERE project_id='project_one' AND contract_version=2 AND event_type='authority.rollback.completed'), + (SELECT count(*) FROM webhook_events WHERE project_id='project_one' AND contract_version=2 AND snapshot_version=0 AND customer_entitlement_snapshot_id IS NULL), + (SELECT count(*) FROM webhook_deliveries WHERE webhook_destination_id='destination_v1'), + (SELECT count(*) FROM webhook_deliveries WHERE webhook_destination_id='destination_v2')`).Scan(&events, &absent, &v1Deliveries, &v2Deliveries) + if err != nil || events != 2 || absent != 1 || v1Deliveries != 0 || v2Deliveries != 2 { + t.Fatalf("events=%d absent=%d v1=%d v2=%d err=%v", events, absent, v1Deliveries, v2Deliveries, err) + } + var eventID, deliveryID string + var body, digest []byte + err = pool.QueryRow(ctx, `SELECT event.id,event.payload_bytes,event.payload_digest,delivery.id FROM webhook_events event + JOIN webhook_deliveries delivery ON delivery.webhook_event_id=event.id WHERE event.contract_version=2 ORDER BY event.id LIMIT 1`).Scan(&eventID, &body, &digest, &deliveryID) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(body) + if !bytes.Equal(digest, sum[:]) { + t.Fatal("stored event digest does not cover exact bytes") + } + _, err = pool.Exec(ctx, `UPDATE webhook_deliveries SET status='succeeded',next_attempt_at=NULL,completed_at=$2,updated_at=$2 WHERE id=$1`, deliveryID, now) + if err != nil { + t.Fatal(err) + } + redeliveryService := billingmigration.NewRedeliveryService(repository, func() time.Time { return now.Add(2 * time.Second) }) + input := billingmigration.RedeliveryInput{ProjectID: "project_one", ProgramID: "program_ready", EventID: eventID, + DestinationID: "destination_v2", IdempotencyKey: "redelivery-1", ExpectedStateVersion: 6, + ExpectedEventDigest: billingmigration.FormatDigest(digest), Reason: "Replay the exact stored transition event after receiver recovery"} + if _, _, deniedErr := redeliveryService.Redeliver(ctx, billingmigration.Actor{ID: "member_redelivery"}, input); !errors.Is(deniedErr, billingmigration.ErrForbidden) { + t.Fatalf("capability error=%v", deniedErr) + } + staleState := input + staleState.IdempotencyKey = "redelivery-stale-state" + staleState.ExpectedStateVersion = 5 + if _, _, staleErr := redeliveryService.Redeliver(ctx, billingmigration.Actor{ID: "owner_one"}, staleState); !errors.Is(staleErr, billingmigration.ErrStaleState) { + t.Fatalf("stale state error=%v", staleErr) + } + staleDigest := input + staleDigest.IdempotencyKey = "redelivery-stale-digest" + staleDigest.ExpectedEventDigest = "sha256:" + string(bytes.Repeat([]byte{'a'}, 64)) + if _, _, staleErr := redeliveryService.Redeliver(ctx, billingmigration.Actor{ID: "owner_one"}, staleDigest); !errors.Is(staleErr, billingmigration.ErrStaleDigest) { + t.Fatalf("stale digest error=%v", staleErr) + } + wrongDestination := input + wrongDestination.IdempotencyKey = "redelivery-v1-destination" + wrongDestination.DestinationID = "destination_v1" + if _, _, tenantErr := redeliveryService.Redeliver(ctx, billingmigration.Actor{ID: "owner_one"}, wrongDestination); !errors.Is(tenantErr, billingmigration.ErrNotFound) { + t.Fatalf("destination contract isolation error=%v", tenantErr) + } + redelivery, replay, err := redeliveryService.Redeliver(ctx, billingmigration.Actor{ID: "owner_one"}, input) + if err != nil || replay || redelivery.DeliveryID != deliveryID { + t.Fatalf("redelivery=%#v replay=%v err=%v", redelivery, replay, err) + } + replayed, replay, err := redeliveryService.Redeliver(ctx, billingmigration.Actor{ID: "owner_one"}, input) + if err != nil || !replay || replayed.ID != redelivery.ID { + t.Fatalf("redelivery replay=%#v replay=%v err=%v", replayed, replay, err) + } + different := input + different.Reason = "A different command using the same key" + if _, _, err = redeliveryService.Redeliver(ctx, billingmigration.Actor{ID: "owner_one"}, different); !errors.Is(err, billingmigration.ErrIdempotencyConflict) { + t.Fatalf("different redelivery error=%v", err) + } + var leasedBody []byte + found := false + for attempt := 0; attempt < len(scopes); attempt++ { + leased, ok, leaseErr := billingwebhookpostgres.New(pool).LeaseDelivery(ctx, "delivery-worker", now.Add(3*time.Second), now.Add(time.Minute)) + if leaseErr != nil || !ok { + t.Fatalf("lease %d ok=%v err=%v", attempt, ok, leaseErr) + } + if leased.Delivery.ID == deliveryID { + leasedBody, found = leased.Body, true + break + } + } + if !found || !bytes.Equal(leasedBody, body) { + t.Fatal("redelivery did not lease the exact stored event bytes") + } + _, err = pool.Exec(ctx, `INSERT INTO webhook_delivery_attempts(id,project_id,webhook_event_id,webhook_destination_id, + webhook_delivery_id,attempt_number,max_attempts,outcome,attempted_at) VALUES('attempt_ready','project_one',$1, + 'destination_v2',$2,1,8,'delivered',$3)`, eventID, deliveryID, now) + if err != nil { + t.Fatal(err) + } + readiness, err = billingwebhookpostgres.New(pool).DestinationReadiness(ctx, "project_one", "environment_one", now.Add(-time.Hour)) + if err != nil || !readiness.Ready() || readiness.HealthyV2Count != 1 { + t.Fatalf("proven readiness=%#v err=%v", readiness, err) + } + + // Stale generations cannot settle another worker's lease; an exhausted row + // becomes terminal without being leased again. + _, err = pool.Exec(ctx, `UPDATE billing_migration_transition_outbox SET status='running',lease_owner='live-worker', + lease_expires_at=$2,lease_generation=9,attempt_count=1,max_attempts=8,due_at=$1 WHERE id='outbox_forced_failure'`, now, now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if staleErr := repository.FailTransition(ctx, billingmigration.TransitionFailure{OutboxID: "outbox_forced_failure", LeaseOwner: "live-worker", LeaseGeneration: 8, ErrorCode: "stale", RetryAt: now, FailedAt: now}); !errors.Is(staleErr, billingmigration.ErrConflict) { + t.Fatalf("stale lease error=%v", staleErr) + } + _, err = pool.Exec(ctx, `UPDATE billing_migration_transition_outbox SET status='pending',lease_owner=NULL,lease_expires_at=NULL, + attempt_count=max_attempts,due_at=$1 WHERE id='outbox_forced_failure'`, now) + if err != nil { + t.Fatal(err) + } + if _, ok, leaseErr := repository.LeaseTransition(ctx, "exhaustion-worker", now, now.Add(time.Minute)); leaseErr != nil || ok { + t.Fatalf("exhausted lease ok=%v err=%v", ok, leaseErr) + } + if err = pool.QueryRow(ctx, `SELECT status FROM billing_migration_transition_outbox WHERE id='outbox_forced_failure'`).Scan(&retryStatus); err != nil || retryStatus != "failed" { + t.Fatalf("exhausted status=%s err=%v", retryStatus, err) + } +} + +// This migration test catches both destructive downgrade and accidental v1 +// incompatibility: a v1-only database can redo 00056, while persisted v2 +// destination evidence prevents the down migration before any columns drop. +func TestMigration56V1RedoAndGuardedDown(t *testing.T) { + ctx, pool, db := executionDatabase(t) + now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC) + seedExecutionCheckpoint(t, ctx, db, now) + _, err := pool.Exec(ctx, `INSERT INTO webhook_destinations(id,project_id,environment_id,url,status,event_types,description, + created_at,updated_at) VALUES('destination_legacy','project_one','environment_one','https://legacy.example.test','active', + ARRAY['customer.entitlements.changed'],'legacy',$1,$1)`, now) + if err != nil { + t.Fatal(err) + } + if err = goose.DownToContext(ctx, db, ".", 55); err != nil { + t.Fatalf("v1 down: %v", err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("v1 redo: %v", err) + } + _, err = pool.Exec(ctx, `UPDATE webhook_destinations SET contract_version=2 WHERE id='destination_legacy'`) + if err != nil { + t.Fatal(err) + } + err = goose.DownToContext(ctx, db, ".", 55) + if err == nil { + t.Fatal("v2 evidence allowed destructive down") + } + version, versionErr := goose.GetDBVersionContext(ctx, db) + if versionErr != nil || version != 56 { + t.Fatalf("guard left version=%d err=%v down=%v", version, versionErr, err) + } +} diff --git a/apps/api/internal/platform/billingmigrationrepair/adapters.go b/apps/api/internal/platform/billingmigrationrepair/adapters.go new file mode 100644 index 00000000..8d915bc1 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationrepair/adapters.go @@ -0,0 +1,325 @@ +package billingmigrationrepair + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +const applicationUserAliasPrefix = "application_user_id:" + +type RepairStore interface { + ProgramProject(ctx context.Context, programID string) (string, error) + ProviderReference(ctx context.Context, programID, sourceRecordID string) (ProviderReferenceEvidence, error) + QuarantinedSourceReference(ctx context.Context, programID, sourceRecordID string) (ProviderReferenceEvidence, error) + MappingReplacement(ctx context.Context, programID, mappingSetID string) (MappingReplacementEvidence, error) + ProvenAlias(ctx context.Context, programID, mappingEntryID string) (ProvenAliasEvidence, error) + ActiveApplicationAliasCustomer(ctx context.Context, projectID string, digest []byte) (string, error) + AttachApplicationAlias(ctx context.Context, executionID string, evidence ProvenAliasEvidence, digest []byte) ([]byte, error) +} + +type ProviderReferenceEvidence struct { + ProjectID string + Reference billingmigration.KnownProviderReference + Digest []byte +} + +type MappingReplacementEvidence struct { + ProjectID string + CurrentMappingID string + NextMappingID string + BeforeDigest []byte + AfterDigest []byte + AffectedCount int +} + +type ProvenAliasEvidence struct { + ProjectID, MappingEntryID, BillingCustomerID, ApplicationUserID string + Digest []byte +} + +type ProviderEvidenceImporter interface { + RevalidateKnownReferences(ctx context.Context, projectID, programID string, references []billingmigration.KnownProviderReference) (billingmigration.ProviderEvidenceResult, error) +} + +type ProjectionReplayer interface { + RunReplay(ctx context.Context, keys billingprojection.ReplayScopeKeys, replay billingprojection.Replay, scope billingprojection.ReplayScope, limit int) ([]billingprojection.ReplayResult, error) +} + +type Production struct { + Store RepairStore + Provider ProviderEvidenceImporter + Projection ProjectionReplayer + ReplayKeys billingprojection.ReplayScopeKeys +} + +func NewProductionExecutor(store RepairStore, provider ProviderEvidenceImporter, projection ProjectionReplayer, keys billingprojection.ReplayScopeKeys) Executor { + production := &Production{ + Store: store, Provider: provider, Projection: projection, ReplayKeys: keys, + } + return Executor{ + Previewer: production, + Provider: production, + Facts: production, + Aliases: production, + Mappings: production, + Quarantine: production, + } +} + +func (p *Production) PreviewRepair(ctx context.Context, request billingmigration.RepairRequest) (billingmigration.RepairImpact, error) { + command, err := validatedCommand(request, false) + if err != nil { + return billingmigration.RepairImpact{}, err + } + if p == nil || p.Store == nil { + return billingmigration.RepairImpact{}, billingmigration.ErrUnavailable + } + switch request.Kind { + case billingmigration.RepairRevalidateProviderReference: + if err := requireSingleReference(command); err != nil { + return billingmigration.RepairImpact{}, err + } + evidence, err := p.Store.ProviderReference(ctx, command.ProgramID, command.References[0]) + if err != nil { + return billingmigration.RepairImpact{}, repairErr(err) + } + return billingmigration.RepairImpact{AffectedCount: 1, BeforeDigest: evidence.Digest, AfterDigest: evidence.Digest}, nil + case billingmigration.RepairReplayFactRange: + before, err := p.factReplayDigest(ctx, command.References) + if err != nil { + return billingmigration.RepairImpact{}, err + } + return billingmigration.RepairImpact{AffectedCount: len(command.References), BeforeDigest: before, AfterDigest: before}, nil + case billingmigration.RepairAttachProvenAlias: + if err := requireSingleReference(command); err != nil { + return billingmigration.RepairImpact{}, err + } + evidence, err := p.Store.ProvenAlias(ctx, command.ProgramID, command.References[0]) + if err != nil { + return billingmigration.RepairImpact{}, repairErr(err) + } + return billingmigration.RepairImpact{AffectedCount: 1, BeforeDigest: evidence.Digest, AfterDigest: hashDigest("mosaic-migration-repair-alias-preview-v1", evidence.Digest)}, nil + case billingmigration.RepairReplaceMappingSet: + if err := requireSingleReference(command); err != nil { + return billingmigration.RepairImpact{}, err + } + evidence, err := p.Store.MappingReplacement(ctx, command.ProgramID, command.References[0]) + if err != nil { + return billingmigration.RepairImpact{}, repairErr(err) + } + return billingmigration.RepairImpact{AffectedCount: evidence.AffectedCount, BeforeDigest: evidence.BeforeDigest, AfterDigest: evidence.AfterDigest}, nil + case billingmigration.RepairRetryQuarantinedRecord: + if err := requireSingleReference(command); err != nil { + return billingmigration.RepairImpact{}, err + } + evidence, err := p.Store.QuarantinedSourceReference(ctx, command.ProgramID, command.References[0]) + if err != nil { + return billingmigration.RepairImpact{}, repairErr(err) + } + return billingmigration.RepairImpact{AffectedCount: 1, BeforeDigest: evidence.Digest, AfterDigest: evidence.Digest}, nil + default: + return billingmigration.RepairImpact{}, fmt.Errorf("unsupported repair kind: %w", billingmigration.ErrInvalid) + } +} + +func (p *Production) RevalidateProviderReference(ctx context.Context, command RepairCommand) (billingmigration.RepairResult, error) { + if p == nil || p.Store == nil || p.Provider == nil { + return billingmigration.RepairResult{}, billingmigration.ErrUnavailable + } + evidence, err := p.Store.ProviderReference(ctx, command.ProgramID, command.References[0]) + if err != nil { + return billingmigration.RepairResult{}, repairErr(err) + } + return p.revalidate(ctx, command.ProgramID, evidence, "provider_validation_pending") +} + +func (p *Production) RetryQuarantinedRecord(ctx context.Context, command RepairCommand) (billingmigration.RepairResult, error) { + if p == nil || p.Store == nil || p.Provider == nil { + return billingmigration.RepairResult{}, billingmigration.ErrUnavailable + } + evidence, err := p.Store.QuarantinedSourceReference(ctx, command.ProgramID, command.References[0]) + if err != nil { + return billingmigration.RepairResult{}, repairErr(err) + } + return p.revalidate(ctx, command.ProgramID, evidence, "source_record_validation_pending") +} + +func (p *Production) revalidate(ctx context.Context, programID string, evidence ProviderReferenceEvidence, pendingCode string) (billingmigration.RepairResult, error) { + result, err := p.Provider.RevalidateKnownReferences(ctx, evidence.ProjectID, programID, []billingmigration.KnownProviderReference{evidence.Reference}) + repair := billingmigration.RepairResult{BeforeDigest: evidence.Digest, AfterDigest: evidence.Digest} + if errors.Is(err, billingmigration.ErrValidationPending) { + repair.ErrorCode = pendingCode + return repair, err + } + if err != nil { + repair.ErrorCode = "provider_revalidation_failed" + return repair, repairErr(err) + } + if result.Validated+result.Quarantined != 1 || result.Accepted != 0 || len(result.EvidenceDigest) != sha256DigestBytes { + repair.ErrorCode = "provider_revalidation_not_terminal" + return repair, billingmigration.ErrUnavailable + } + repair.AfterDigest = result.EvidenceDigest + return repair, nil +} + +func (p *Production) ReplayImmutableFactRange(ctx context.Context, command RepairCommand) (billingmigration.RepairResult, error) { + if p == nil || p.Store == nil || p.Projection == nil || p.ReplayKeys == nil { + return billingmigration.RepairResult{}, billingmigration.ErrUnavailable + } + before, err := p.factReplayDigest(ctx, command.References) + if err != nil { + return billingmigration.RepairResult{}, err + } + scopeKeys := make([]string, 0, len(command.References)) + for _, reference := range command.References { + scope, err := p.replayScope(ctx, command.ProgramID, reference) + if err != nil { + return billingmigration.RepairResult{BeforeDigest: before, AfterDigest: before, ErrorCode: "unsupported_fact_replay_scope"}, err + } + results, replayErr := p.Projection.RunReplay(ctx, p.ReplayKeys, billingprojection.Replay{}, scope, 100) + if replayErr != nil { + return billingmigration.RepairResult{BeforeDigest: before, AfterDigest: before, ErrorCode: "fact_replay_failed"}, repairErr(replayErr) + } + for _, result := range results { + scopeKeys = append(scopeKeys, result.ScopeKey) + } + } + sort.Strings(scopeKeys) + after := hashStrings("mosaic-migration-repair-fact-replay-terminal-v1", append([]string{command.ExecutionID}, scopeKeys...)...) + return billingmigration.RepairResult{BeforeDigest: before, AfterDigest: after}, nil +} + +func (p *Production) AttachProvenAlias(ctx context.Context, command RepairCommand) (billingmigration.RepairResult, error) { + if p == nil || p.Store == nil { + return billingmigration.RepairResult{}, billingmigration.ErrUnavailable + } + evidence, err := p.Store.ProvenAlias(ctx, command.ProgramID, command.References[0]) + if err != nil { + return billingmigration.RepairResult{}, repairErr(err) + } + digest := billing.AliasDigest(billingcustomer.AliasApplicationUser, evidence.ApplicationUserID) + after, err := p.Store.AttachApplicationAlias(ctx, command.ExecutionID, evidence, digest) + if err == nil { + return billingmigration.RepairResult{BeforeDigest: evidence.Digest, AfterDigest: after}, nil + } + activeCustomer, lookupErr := p.Store.ActiveApplicationAliasCustomer(ctx, evidence.ProjectID, digest) + if lookupErr != nil { + return billingmigration.RepairResult{BeforeDigest: evidence.Digest, AfterDigest: evidence.Digest, ErrorCode: "alias_lookup_failed"}, repairErr(lookupErr) + } + if activeCustomer == evidence.BillingCustomerID { + return billingmigration.RepairResult{BeforeDigest: evidence.Digest, AfterDigest: evidence.Digest}, nil + } + if activeCustomer != "" { + return billingmigration.RepairResult{BeforeDigest: evidence.Digest, AfterDigest: evidence.Digest, ErrorCode: "alias_resolves_elsewhere"}, billingmigration.ErrRollbackPrerequisite + } + return billingmigration.RepairResult{BeforeDigest: evidence.Digest, AfterDigest: evidence.Digest, ErrorCode: "alias_attach_failed"}, repairErr(err) +} + +func (p *Production) ReplaceMappingSetAndInvalidate(ctx context.Context, command RepairCommand) (billingmigration.RepairResult, error) { + if p == nil || p.Store == nil { + return billingmigration.RepairResult{}, billingmigration.ErrUnavailable + } + evidence, err := p.Store.MappingReplacement(ctx, command.ProgramID, command.References[0]) + if err != nil { + return billingmigration.RepairResult{}, repairErr(err) + } + if string(evidence.BeforeDigest) == string(evidence.AfterDigest) { + return billingmigration.RepairResult{BeforeDigest: evidence.BeforeDigest, AfterDigest: evidence.AfterDigest}, nil + } + return billingmigration.RepairResult{BeforeDigest: evidence.BeforeDigest, AfterDigest: evidence.AfterDigest}, nil +} + +func (p *Production) factReplayDigest(ctx context.Context, references []string) ([]byte, error) { + if len(references) == 0 || len(references) > maxRepairScopeReferences { + return nil, billingmigration.ErrInvalid + } + return hashStrings("mosaic-migration-repair-fact-replay-preview-v1", references...), nil +} + +func (p *Production) replayScope(ctx context.Context, programID, reference string) (billingprojection.ReplayScope, error) { + projectID, err := p.Store.ProgramProject(ctx, programID) + if err != nil { + return billingprojection.ReplayScope{}, repairErr(err) + } + switch { + case strings.HasPrefix(reference, "customer:"): + id := strings.TrimPrefix(reference, "customer:") + if id == "" { + return billingprojection.ReplayScope{}, billingmigration.ErrInvalid + } + return billingprojection.ReplayScope{ProjectID: projectID, CustomerID: id}, nil + case strings.HasPrefix(reference, "subscription:"): + id := strings.TrimPrefix(reference, "subscription:") + if id == "" { + return billingprojection.ReplayScope{}, billingmigration.ErrInvalid + } + return billingprojection.ReplayScope{ProjectID: projectID, SubscriptionInstanceID: id}, nil + case strings.HasPrefix(reference, "project_window:"): + payload := strings.TrimPrefix(reference, "project_window:") + parts := strings.Split(payload, ",") + if len(parts) != 2 { + return billingprojection.ReplayScope{}, billingmigration.ErrInvalid + } + start, startErr := time.Parse(time.RFC3339Nano, parts[0]) + end, endErr := time.Parse(time.RFC3339Nano, parts[1]) + if startErr != nil || endErr != nil || !end.After(start) { + return billingprojection.ReplayScope{}, billingmigration.ErrInvalid + } + return billingprojection.ReplayScope{ProjectID: projectID, WindowStart: &start, WindowEnd: &end}, nil + default: + return billingprojection.ReplayScope{}, billingmigration.ErrUnavailable + } +} + +func repairErr(err error) error { + if err == nil { + return nil + } + switch { + case errors.Is(err, billingmigration.ErrInvalid), errors.Is(err, billing.ErrInvalid): + return billingmigration.ErrInvalid + case errors.Is(err, billingmigration.ErrNotFound), errors.Is(err, billing.ErrNotFound): + return billingmigration.ErrNotFound + case errors.Is(err, billingmigration.ErrConflict), errors.Is(err, billing.ErrConflict): + return billingmigration.ErrRollbackPrerequisite + case errors.Is(err, billingmigration.ErrValidationPending): + return err + default: + return billingmigration.ErrUnavailable + } +} + +func hashDigest(domain string, digest []byte) []byte { + h := sha256.New() + _, _ = h.Write([]byte(domain)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write(digest) + return h.Sum(nil) +} + +func hashStrings(domain string, values ...string) []byte { + h := sha256.New() + _, _ = h.Write([]byte(domain)) + for _, value := range values { + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(value)) + } + return h.Sum(nil) +} + +func deterministicID(prefix string, values ...string) string { + sum := hashStrings("mosaic-id", values...) + return prefix + "_" + hex.EncodeToString(sum[:12]) +} diff --git a/apps/api/internal/platform/billingmigrationrepair/adapters_test.go b/apps/api/internal/platform/billingmigrationrepair/adapters_test.go new file mode 100644 index 00000000..d64cd79d --- /dev/null +++ b/apps/api/internal/platform/billingmigrationrepair/adapters_test.go @@ -0,0 +1,315 @@ +package billingmigrationrepair + +import ( + "context" + "errors" + "testing" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +func TestProductionProviderAdapterDispatchesKnownReferenceIdempotently(t *testing.T) { + store := newRepairStoreFake() + provider := &providerImporterFake{result: billingmigration.ProviderEvidenceResult{Validated: 1, EvidenceDigest: digestOf(9)}} + production := &Production{Store: store, Provider: provider} + + result, err := production.RevalidateProviderReference(context.Background(), RepairCommand{ProgramID: "program", References: []string{"source-record"}}) + if err != nil { + t.Fatalf("provider repair: %v", err) + } + replay, err := production.RevalidateProviderReference(context.Background(), RepairCommand{ProgramID: "program", References: []string{"source-record"}}) + if err != nil { + t.Fatalf("provider repair replay: %v", err) + } + if provider.calls != 2 || provider.refs[0].Reference != "provider-reference" { + t.Fatalf("provider importer calls=%d refs=%#v", provider.calls, provider.refs) + } + if string(result.AfterDigest) != string(digestOf(9)) || string(replay.AfterDigest) != string(result.AfterDigest) { + t.Fatalf("provider result=%#v replay=%#v", result, replay) + } +} + +func TestProductionProviderAdapterFailsClosedWhenValidationIsPending(t *testing.T) { + store := newRepairStoreFake() + provider := &providerImporterFake{result: billingmigration.ProviderEvidenceResult{Accepted: 1, EvidenceDigest: digestOf(8)}, err: billingmigration.ErrValidationPending} + production := &Production{Store: store, Provider: provider} + + result, err := production.RevalidateProviderReference(context.Background(), RepairCommand{ProgramID: "program", References: []string{"source-record"}}) + if !errors.Is(err, billingmigration.ErrValidationPending) { + t.Fatalf("pending error = %v", err) + } + if result.ErrorCode != "provider_validation_pending" || string(result.BeforeDigest) != string(result.AfterDigest) { + t.Fatalf("pending result = %#v", result) + } +} + +func TestProductionFactReplayDispatchesProjectionWithStableReplayDigest(t *testing.T) { + store := newRepairStoreFake() + projection := &projectionFake{results: []billingprojection.ReplayResult{{ + ScopeKey: "customer:bcu_one", Comparison: billingprojection.ComparisonChanged, Materialized: true, + }}} + production := &Production{Store: store, Projection: projection, ReplayKeys: replayKeysFake{}} + + result, err := production.ReplayImmutableFactRange(context.Background(), RepairCommand{ProgramID: "program", References: []string{"customer:bcu_one"}}) + if err != nil { + t.Fatalf("fact replay: %v", err) + } + projection.results = []billingprojection.ReplayResult{{ScopeKey: "customer:bcu_one", Comparison: billingprojection.ComparisonUnchanged}} + replay, err := production.ReplayImmutableFactRange(context.Background(), RepairCommand{ProgramID: "program", References: []string{"customer:bcu_one"}}) + if err != nil { + t.Fatalf("fact replay retry: %v", err) + } + if projection.calls != 2 || projection.scope.CustomerID != "bcu_one" || projection.scope.ProjectID != "project" { + t.Fatalf("projection calls=%d scope=%#v", projection.calls, projection.scope) + } + if string(result.AfterDigest) != string(replay.AfterDigest) || string(result.BeforeDigest) == string(result.AfterDigest) { + t.Fatalf("fact replay result=%#v replay=%#v", result, replay) + } +} + +func TestProductionFactReplayFailsClosedForUnsupportedScope(t *testing.T) { + production := &Production{Store: newRepairStoreFake(), Projection: &projectionFake{}, ReplayKeys: replayKeysFake{}} + + result, err := production.ReplayImmutableFactRange(context.Background(), RepairCommand{ProgramID: "program", References: []string{"fact-id-only"}}) + if !errors.Is(err, billingmigration.ErrUnavailable) { + t.Fatalf("fact replay error = %v, want unavailable", err) + } + if result.ErrorCode != "unsupported_fact_replay_scope" { + t.Fatalf("unsupported fact result = %#v", result) + } +} + +func TestProductionAliasAdapterAttachesWithStableExecutionIDReceipt(t *testing.T) { + store := newRepairStoreFake() + production := &Production{Store: store} + command := RepairCommand{ExecutionID: "mre_stable", ProgramID: "program", References: []string{"entry"}} + + result, err := production.AttachProvenAlias(context.Background(), command) + if err != nil { + t.Fatalf("alias repair: %v", err) + } + replay, err := production.AttachProvenAlias(context.Background(), command) + if err != nil { + t.Fatalf("alias repair replay: %v", err) + } + if store.aliasAttachAttempts != 2 || store.aliasInsertions != 1 { + t.Fatalf("alias attempts=%d insertions=%d", store.aliasAttachAttempts, store.aliasInsertions) + } + if string(result.AfterDigest) != string(replay.AfterDigest) || string(result.BeforeDigest) == string(result.AfterDigest) { + t.Fatalf("alias result=%#v replay=%#v", result, replay) + } +} + +func TestProductionAliasAdapterNoChangesWhenAliasAlreadyAttached(t *testing.T) { + store := newRepairStoreFake() + store.activeAliasCustomer = "bcu_one" + production := &Production{Store: store} + + result, err := production.AttachProvenAlias(context.Background(), RepairCommand{ProgramID: "program", References: []string{"entry"}}) + if err != nil { + t.Fatalf("alias no-change repair: %v", err) + } + if store.aliasInsertions != 0 { + t.Fatalf("alias attachment was attempted for already attached alias") + } + if string(result.BeforeDigest) != string(result.AfterDigest) { + t.Fatalf("already attached alias should be no-change: %#v", result) + } +} + +func TestProductionAliasAdapterFailsClosedWhenAliasResolvesElsewhere(t *testing.T) { + store := newRepairStoreFake() + store.activeAliasCustomer = "bcu_other" + production := &Production{Store: store} + + result, err := production.AttachProvenAlias(context.Background(), RepairCommand{ProgramID: "program", References: []string{"entry"}}) + if !errors.Is(err, billingmigration.ErrRollbackPrerequisite) { + t.Fatalf("alias conflict error = %v", err) + } + if result.ErrorCode != "alias_resolves_elsewhere" || store.aliasInsertions != 0 { + t.Fatalf("alias conflict result=%#v insertions=%d", result, store.aliasInsertions) + } +} + +func TestProductionMappingAdapterSucceedsOnlyWhenBoundMappingDiffers(t *testing.T) { + store := newRepairStoreFake() + production := &Production{Store: store} + + result, err := production.ReplaceMappingSetAndInvalidate(context.Background(), RepairCommand{ProgramID: "program", References: []string{"mapping"}}) + if err != nil { + t.Fatalf("mapping repair: %v", err) + } + if string(result.BeforeDigest) == string(result.AfterDigest) { + t.Fatalf("mapping replacement should report changed digest: %#v", result) + } +} + +func TestProductionMappingAdapterNoChangeWhenBoundMappingMatches(t *testing.T) { + store := newRepairStoreFake() + store.mapping.BeforeDigest = append([]byte(nil), store.mapping.AfterDigest...) + production := &Production{Store: store} + + result, err := production.ReplaceMappingSetAndInvalidate(context.Background(), RepairCommand{ProgramID: "program", References: []string{"mapping"}}) + if err != nil { + t.Fatalf("mapping no-change repair: %v", err) + } + if string(result.BeforeDigest) != string(result.AfterDigest) { + t.Fatalf("mapping no-change result = %#v", result) + } +} + +func TestProductionSourceRetryDispatchesOnlyQuarantinedSourceRecord(t *testing.T) { + store := newRepairStoreFake() + provider := &providerImporterFake{result: billingmigration.ProviderEvidenceResult{Quarantined: 1, EvidenceDigest: digestOf(6)}} + production := &Production{Store: store, Provider: provider} + + result, err := production.RetryQuarantinedRecord(context.Background(), RepairCommand{ProgramID: "program", References: []string{"source-record"}}) + if err != nil { + t.Fatalf("source retry: %v", err) + } + if store.quarantineLookups != 1 || provider.calls != 1 { + t.Fatalf("quarantine lookups=%d provider calls=%d", store.quarantineLookups, provider.calls) + } + if string(result.AfterDigest) != string(digestOf(6)) { + t.Fatalf("source retry result = %#v", result) + } +} + +func TestProductionSourceRetryRejectsUnquarantinedSourceRecord(t *testing.T) { + store := newRepairStoreFake() + store.quarantineErr = billingmigration.ErrRollbackPrerequisite + provider := &providerImporterFake{result: billingmigration.ProviderEvidenceResult{Quarantined: 1, EvidenceDigest: digestOf(6)}} + production := &Production{Store: store, Provider: provider} + + _, err := production.RetryQuarantinedRecord(context.Background(), RepairCommand{ProgramID: "program", References: []string{"source-record"}}) + if !errors.Is(err, billingmigration.ErrRollbackPrerequisite) { + t.Fatalf("source retry error = %v", err) + } + if store.quarantineLookups != 1 || provider.calls != 0 { + t.Fatalf("quarantine lookups=%d provider calls=%d", store.quarantineLookups, provider.calls) + } +} + +func TestProductionSourceRetryReportsPendingTerminalValidation(t *testing.T) { + store := newRepairStoreFake() + provider := &providerImporterFake{result: billingmigration.ProviderEvidenceResult{Accepted: 1, EvidenceDigest: digestOf(6)}, err: billingmigration.ErrValidationPending} + production := &Production{Store: store, Provider: provider} + + result, err := production.RetryQuarantinedRecord(context.Background(), RepairCommand{ProgramID: "program", References: []string{"source-record"}}) + if !errors.Is(err, billingmigration.ErrValidationPending) { + t.Fatalf("source retry pending error = %v", err) + } + if result.ErrorCode != "source_record_validation_pending" || string(result.BeforeDigest) != string(result.AfterDigest) { + t.Fatalf("source retry pending result = %#v", result) + } +} + +type repairStoreFake struct { + provider ProviderReferenceEvidence + mapping MappingReplacementEvidence + alias ProvenAliasEvidence + activeAliasCustomer string + aliasReceipt []byte + aliasAttachAttempts int + aliasInsertions int + quarantineLookups int + quarantineErr error +} + +func newRepairStoreFake() *repairStoreFake { + return &repairStoreFake{ + provider: ProviderReferenceEvidence{ + ProjectID: "project", + Reference: billingmigration.KnownProviderReference{ + Provider: billing.ProviderAppStore, EnvironmentID: "environment", ApplicationID: "application", + Reference: "provider-reference", ReferenceKind: billing.ReferenceAppStoreTransactionID, + SourceProductID: "source-product", TargetProductID: "product", + ExpectedStoreProductID: "store.product", ExpectedStoreEnvironment: billing.StoreProduction, + }, + Digest: digestOf(1), + }, + mapping: MappingReplacementEvidence{ + ProjectID: "project", CurrentMappingID: "mapping", NextMappingID: "mapping", + BeforeDigest: digestOf(2), AfterDigest: digestOf(3), AffectedCount: 2, + }, + alias: ProvenAliasEvidence{ + ProjectID: "project", MappingEntryID: "entry", BillingCustomerID: "bcu_one", + ApplicationUserID: "user-one", Digest: digestOf(4), + }, + } +} + +func (s *repairStoreFake) ProgramProject(context.Context, string) (string, error) { + return "project", nil +} + +func (s *repairStoreFake) ProviderReference(context.Context, string, string) (ProviderReferenceEvidence, error) { + return s.provider, nil +} + +func (s *repairStoreFake) QuarantinedSourceReference(context.Context, string, string) (ProviderReferenceEvidence, error) { + s.quarantineLookups++ + if s.quarantineErr != nil { + return ProviderReferenceEvidence{}, s.quarantineErr + } + return s.provider, nil +} + +func (s *repairStoreFake) MappingReplacement(context.Context, string, string) (MappingReplacementEvidence, error) { + return s.mapping, nil +} + +func (s *repairStoreFake) ProvenAlias(context.Context, string, string) (ProvenAliasEvidence, error) { + return s.alias, nil +} + +func (s *repairStoreFake) ActiveApplicationAliasCustomer(context.Context, string, []byte) (string, error) { + return s.activeAliasCustomer, nil +} + +func (s *repairStoreFake) AttachApplicationAlias(_ context.Context, executionID string, evidence ProvenAliasEvidence, digest []byte) ([]byte, error) { + s.aliasAttachAttempts++ + if s.activeAliasCustomer != "" { + return nil, billingmigration.ErrConflict + } + receipt := hashStrings("fake-alias-receipt", executionID, evidence.MappingEntryID, string(digest)) + if s.aliasReceipt == nil { + s.aliasInsertions++ + s.aliasReceipt = receipt + } + return append([]byte(nil), s.aliasReceipt...), nil +} + +type providerImporterFake struct { + calls int + refs []billingmigration.KnownProviderReference + result billingmigration.ProviderEvidenceResult + err error +} + +func (p *providerImporterFake) RevalidateKnownReferences(_ context.Context, _ string, _ string, refs []billingmigration.KnownProviderReference) (billingmigration.ProviderEvidenceResult, error) { + p.calls++ + p.refs = append([]billingmigration.KnownProviderReference(nil), refs...) + return p.result, p.err +} + +type projectionFake struct { + calls int + scope billingprojection.ReplayScope + results []billingprojection.ReplayResult + err error +} + +func (p *projectionFake) RunReplay(_ context.Context, _ billingprojection.ReplayScopeKeys, _ billingprojection.Replay, scope billingprojection.ReplayScope, _ int) ([]billingprojection.ReplayResult, error) { + p.calls++ + p.scope = scope + return p.results, p.err +} + +type replayKeysFake struct{} + +func (replayKeysFake) ScopesForReplay(context.Context, billingprojection.ReplayScope, int) ([]billingprojection.Scope, error) { + return nil, nil +} diff --git a/apps/api/internal/platform/billingmigrationrepair/postgres.go b/apps/api/internal/platform/billingmigrationrepair/postgres.go new file mode 100644 index 00000000..de122857 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationrepair/postgres.go @@ -0,0 +1,255 @@ +package billingmigrationrepair + +import ( + "context" + "crypto/sha256" + "errors" + "strings" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type PostgresStore struct{ pool *pgxpool.Pool } + +func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { return &PostgresStore{pool: pool} } + +var _ RepairStore = (*PostgresStore)(nil) + +func (s *PostgresStore) ProgramProject(ctx context.Context, programID string) (string, error) { + if s == nil || s.pool == nil { + return "", billingmigration.ErrUnavailable + } + var projectID string + err := s.pool.QueryRow(ctx, `SELECT project_id FROM billing_migration_programs WHERE id=$1`, programID).Scan(&projectID) + if errors.Is(err, pgx.ErrNoRows) { + return "", billingmigration.ErrNotFound + } + return projectID, err +} + +func (s *PostgresStore) ProviderReference(ctx context.Context, programID, sourceRecordID string) (ProviderReferenceEvidence, error) { + if s == nil || s.pool == nil { + return ProviderReferenceEvidence{}, billingmigration.ErrUnavailable + } + rows, err := s.pool.Query(ctx, `SELECT DISTINCT + b.project_id,r.provider,r.environment_id,r.application_id,r.provider_reference,r.reference_kind, + coalesce(r.source_product_identifier,''),coalesce(r.mosaic_product_id,''), + coalesce(r.expected_store_product_identifier,''),r.expected_store_environment,sr.record_digest + FROM billing_migration_import_batch_records r + JOIN billing_migration_import_batches b ON b.id=r.import_batch_id AND b.program_id=r.program_id AND b.project_id=r.project_id + JOIN billing_migration_source_records sr ON sr.id=r.source_record_id AND sr.program_id=r.program_id AND sr.project_id=r.project_id + WHERE r.program_id=$1 AND r.source_record_id=$2`, programID, sourceRecordID) + if err != nil { + return ProviderReferenceEvidence{}, err + } + defer rows.Close() + items := make([]ProviderReferenceEvidence, 0, 2) + for rows.Next() { + var item ProviderReferenceEvidence + err = rows.Scan(&item.ProjectID, &item.Reference.Provider, &item.Reference.EnvironmentID, &item.Reference.ApplicationID, + &item.Reference.Reference, &item.Reference.ReferenceKind, &item.Reference.SourceProductID, + &item.Reference.TargetProductID, &item.Reference.ExpectedStoreProductID, + &item.Reference.ExpectedStoreEnvironment, &item.Digest) + if err != nil { + return ProviderReferenceEvidence{}, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return ProviderReferenceEvidence{}, err + } + if len(items) == 0 { + return ProviderReferenceEvidence{}, billingmigration.ErrNotFound + } + if len(items) != 1 { + return ProviderReferenceEvidence{}, billingmigration.ErrUnavailable + } + if len(items[0].Digest) != sha256DigestBytes { + return ProviderReferenceEvidence{}, billingmigration.ErrInvalid + } + return items[0], nil +} + +func (s *PostgresStore) QuarantinedSourceReference(ctx context.Context, programID, sourceRecordID string) (ProviderReferenceEvidence, error) { + if s == nil || s.pool == nil { + return ProviderReferenceEvidence{}, billingmigration.ErrUnavailable + } + evidence, err := s.ProviderReference(ctx, programID, sourceRecordID) + if err != nil { + return ProviderReferenceEvidence{}, err + } + digest, err := providerReferenceDigest(evidence.Reference) + if err != nil { + return ProviderReferenceEvidence{}, err + } + var terminalDigest []byte + err = s.pool.QueryRow(ctx, `SELECT evidence_digest FROM billing_migration_validation_bindings + WHERE program_id=$1 AND project_id=$2 AND provider=$3 AND reference_kind=$4 + AND reference_digest=$5 AND status='quarantined'`, + programID, evidence.ProjectID, evidence.Reference.Provider, evidence.Reference.ReferenceKind, digest).Scan(&terminalDigest) + if errors.Is(err, pgx.ErrNoRows) { + return ProviderReferenceEvidence{}, billingmigration.ErrRollbackPrerequisite + } + if err != nil { + return ProviderReferenceEvidence{}, err + } + if len(terminalDigest) != sha256DigestBytes { + return ProviderReferenceEvidence{}, billingmigration.ErrInvalid + } + evidence.Digest = terminalDigest + return evidence, nil +} + +func (s *PostgresStore) MappingReplacement(ctx context.Context, programID, mappingSetID string) (MappingReplacementEvidence, error) { + if s == nil || s.pool == nil { + return MappingReplacementEvidence{}, billingmigration.ErrUnavailable + } + var latestID, candidateID, projectID string + var latestDigest, candidateDigest []byte + err := s.pool.QueryRow(ctx, `SELECT id,project_id,mapping_digest FROM billing_migration_mapping_sets + WHERE program_id=$1 AND status='frozen' ORDER BY version DESC LIMIT 1`, programID). + Scan(&latestID, &projectID, &latestDigest) + if errors.Is(err, pgx.ErrNoRows) { + return MappingReplacementEvidence{}, billingmigration.ErrRollbackPrerequisite + } + if err != nil { + return MappingReplacementEvidence{}, err + } + err = s.pool.QueryRow(ctx, `SELECT id,project_id,mapping_digest FROM billing_migration_mapping_sets + WHERE program_id=$1 AND id=$2 AND status='frozen'`, programID, mappingSetID). + Scan(&candidateID, &projectID, &candidateDigest) + if errors.Is(err, pgx.ErrNoRows) { + return MappingReplacementEvidence{}, billingmigration.ErrRollbackPrerequisite + } + if err != nil { + return MappingReplacementEvidence{}, err + } + if candidateID != latestID { + return MappingReplacementEvidence{}, billingmigration.ErrRollbackPrerequisite + } + var affected int + err = s.pool.QueryRow(ctx, `SELECT count(*) FROM ( + SELECT id FROM billing_migration_run_jobs WHERE program_id=$1 + UNION ALL SELECT id FROM billing_migration_readiness_assessments WHERE program_id=$1 + UNION ALL SELECT id FROM billing_migration_checkpoints WHERE program_id=$1 + UNION ALL SELECT id FROM billing_migration_approvals WHERE program_id=$1 + )x`, programID).Scan(&affected) + if err != nil { + return MappingReplacementEvidence{}, err + } + before := candidateDigest + var boundDigest []byte + err = s.pool.QueryRow(ctx, `SELECT mapping_digest FROM ( + SELECT mapping_digest,completed_at AS at,id FROM billing_migration_runs WHERE program_id=$1 + UNION ALL SELECT mapping_digest,completed_at AS at,id FROM billing_migration_final_deltas WHERE program_id=$1 + UNION ALL SELECT mapping_digest,created_at AS at,id FROM billing_migration_run_jobs WHERE program_id=$1 + UNION ALL SELECT mapping_digest,created_at AS at,id FROM billing_migration_final_delta_jobs WHERE program_id=$1 + UNION ALL SELECT mapping.mapping_digest,pull.created_at AS at,pull.id FROM billing_migration_source_pull_jobs pull JOIN billing_migration_mapping_sets mapping ON mapping.id=pull.mapping_set_id AND mapping.program_id=pull.program_id AND mapping.project_id=pull.project_id WHERE pull.program_id=$1 + UNION ALL SELECT mapping.mapping_digest,batch.created_at AS at,batch.id FROM billing_migration_import_batches batch JOIN billing_migration_mapping_sets mapping ON mapping.id=batch.mapping_set_id AND mapping.program_id=batch.program_id AND mapping.project_id=batch.project_id WHERE batch.program_id=$1 + UNION ALL SELECT mapping_digest,proposed_at AS at,id FROM billing_migration_cutover_proposals WHERE program_id=$1 + UNION ALL SELECT mapping_digest,created_at AS at,id FROM billing_migration_checkpoints WHERE program_id=$1 + ) bound ORDER BY at DESC,id DESC LIMIT 1`, programID).Scan(&boundDigest) + if err == nil { + before = boundDigest + } else if !errors.Is(err, pgx.ErrNoRows) { + return MappingReplacementEvidence{}, err + } + return MappingReplacementEvidence{ + ProjectID: projectID, CurrentMappingID: latestID, NextMappingID: candidateID, + BeforeDigest: before, AfterDigest: candidateDigest, AffectedCount: affected, + }, nil +} + +func (s *PostgresStore) ProvenAlias(ctx context.Context, programID, mappingEntryID string) (ProvenAliasEvidence, error) { + if s == nil || s.pool == nil { + return ProvenAliasEvidence{}, billingmigration.ErrUnavailable + } + var evidence ProvenAliasEvidence + var sourceIdentifier string + err := s.pool.QueryRow(ctx, `SELECT e.project_id,e.id,e.target_id,e.source_identifier,m.mapping_digest + FROM billing_migration_mapping_entries e + JOIN billing_migration_mapping_sets m ON m.id=e.mapping_set_id AND m.program_id=e.program_id AND m.project_id=e.project_id + JOIN billing_customers c ON c.id=e.target_id AND c.project_id=e.project_id AND c.status='active' + WHERE e.program_id=$1 AND e.id=$2 AND e.source_kind='audited_alias' + AND e.match_kind='audited_alias' AND m.status='frozen'`, programID, mappingEntryID). + Scan(&evidence.ProjectID, &evidence.MappingEntryID, &evidence.BillingCustomerID, &sourceIdentifier, &evidence.Digest) + if errors.Is(err, pgx.ErrNoRows) { + return ProvenAliasEvidence{}, billingmigration.ErrRollbackPrerequisite + } + if err != nil { + return ProvenAliasEvidence{}, err + } + aliasValue := strings.TrimPrefix(sourceIdentifier, applicationUserAliasPrefix) + if aliasValue == "" || aliasValue == sourceIdentifier { + return ProvenAliasEvidence{}, billingmigration.ErrUnavailable + } + evidence.ApplicationUserID = aliasValue + return evidence, nil +} + +func (s *PostgresStore) ActiveApplicationAliasCustomer(ctx context.Context, projectID string, digest []byte) (string, error) { + if s == nil || s.pool == nil { + return "", billingmigration.ErrUnavailable + } + var customerID string + err := s.pool.QueryRow(ctx, `SELECT billing_customer_id FROM billing_customer_aliases + WHERE project_id=$1 AND alias_type=$2 AND alias_digest=$3 AND effective_end IS NULL`, + projectID, billingcustomer.AliasApplicationUser, digest).Scan(&customerID) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return customerID, err +} + +func (s *PostgresStore) AttachApplicationAlias(ctx context.Context, executionID string, evidence ProvenAliasEvidence, digest []byte) ([]byte, error) { + if s == nil || s.pool == nil { + return nil, billingmigration.ErrUnavailable + } + if executionID == "" || len(digest) != sha256DigestBytes || evidence.ProjectID == "" || evidence.BillingCustomerID == "" || evidence.ApplicationUserID == "" { + return nil, billingmigration.ErrInvalid + } + aliasID := deterministicID("bca", executionID, evidence.MappingEntryID, evidence.ProjectID, evidence.BillingCustomerID) + now := time.Now().UTC() + _, err := s.pool.Exec(ctx, `INSERT INTO billing_customer_aliases( + id,project_id,billing_customer_id,alias_type,alias_digest,source_authority,verification_status,effective_start,created_at) + VALUES($1,$2,$3,$4,$5,'operator','verified',$6,$6) + ON CONFLICT(id) DO NOTHING`, + aliasID, evidence.ProjectID, evidence.BillingCustomerID, billingcustomer.AliasApplicationUser, digest, now) + if err != nil { + return nil, err + } + var storedCustomerID string + var storedDigest []byte + err = s.pool.QueryRow(ctx, `SELECT billing_customer_id,alias_digest FROM billing_customer_aliases + WHERE id=$1 AND project_id=$2 AND alias_type=$3 AND effective_end IS NULL`, + aliasID, evidence.ProjectID, billingcustomer.AliasApplicationUser).Scan(&storedCustomerID, &storedDigest) + if errors.Is(err, pgx.ErrNoRows) { + return nil, billingmigration.ErrConflict + } + if err != nil { + return nil, err + } + if storedCustomerID != evidence.BillingCustomerID || string(storedDigest) != string(digest) { + return nil, billingmigration.ErrConflict + } + return hashStrings("mosaic-migration-repair-alias-attached-v1", aliasID, evidence.MappingEntryID, string(digest)), nil +} + +func providerReferenceDigest(reference billingmigration.KnownProviderReference) ([]byte, error) { + switch { + case reference.Provider == billing.ProviderAppStore && reference.ReferenceKind == billing.ReferenceAppStoreTransactionID: + return billing.AppleTransactionKey(billing.StoreUnclassified, reference.Reference), nil + case reference.Provider == billing.ProviderGooglePlay && reference.ReferenceKind == "google_play_purchase_token": + return billing.TokenDigest(reference.Reference), nil + case reference.Provider == billing.ProviderGooglePlay && reference.ReferenceKind == billing.ReferenceGooglePlayOrderID: + sum := sha256.Sum256([]byte("mosaic-billing-google-order-v1\x00" + reference.Reference)) + return sum[:], nil + default: + return nil, billingmigration.ErrInvalid + } +} diff --git a/apps/api/internal/platform/billingmigrationrepair/seams.go b/apps/api/internal/platform/billingmigrationrepair/seams.go new file mode 100644 index 00000000..fc76ddea --- /dev/null +++ b/apps/api/internal/platform/billingmigrationrepair/seams.go @@ -0,0 +1,205 @@ +// Package billingmigrationrepair exposes only the five Phase 9C repair seams. +// It deliberately has no generic SQL, fact mutation, snapshot mutation, or +// authority-pointer mutation escape hatch. +package billingmigrationrepair + +import ( + "context" + "fmt" + "unicode/utf8" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +const ( + maxRepairScopeReferences = 100 + maxRepairAffectedCount = 1000 + sha256DigestBytes = 32 +) + +// RepairCommand is the fully validated command passed to the concrete repair +// seam. It carries the immutable case/execution context required for audit. +type RepairCommand struct { + ExecutionID string + ProgramID string + CaseID string + References []string +} + +type ProviderRevalidator interface { + RevalidateProviderReference(context.Context, RepairCommand) (billingmigration.RepairResult, error) +} +type FactRangeReplayer interface { + ReplayImmutableFactRange(context.Context, RepairCommand) (billingmigration.RepairResult, error) +} +type ProvenAliasAttacher interface { + AttachProvenAlias(context.Context, RepairCommand) (billingmigration.RepairResult, error) +} +type MappingSetReplacer interface { + ReplaceMappingSetAndInvalidate(context.Context, RepairCommand) (billingmigration.RepairResult, error) +} +type QuarantinedRecordRetrier interface { + RetryQuarantinedRecord(context.Context, RepairCommand) (billingmigration.RepairResult, error) +} +type ImpactPreviewer interface { + PreviewRepair(context.Context, billingmigration.RepairRequest) (billingmigration.RepairImpact, error) +} + +type Executor struct { + Previewer ImpactPreviewer + Provider ProviderRevalidator + Facts FactRangeReplayer + Aliases ProvenAliasAttacher + Mappings MappingSetReplacer + Quarantine QuarantinedRecordRetrier +} + +var _ billingmigration.RepairExecutor = Executor{} + +func (e Executor) Preview(ctx context.Context, request billingmigration.RepairRequest) (billingmigration.RepairImpact, error) { + if _, err := validatedCommand(request, false); err != nil { + return billingmigration.RepairImpact{}, err + } + if e.Previewer == nil { + return billingmigration.RepairImpact{}, billingmigration.ErrUnavailable + } + impact, err := e.Previewer.PreviewRepair(ctx, request) + if err != nil { + return billingmigration.RepairImpact{}, err + } + if err := validateImpact(impact); err != nil { + return billingmigration.RepairImpact{}, err + } + return impact, nil +} + +func (e Executor) Execute(ctx context.Context, request billingmigration.RepairRequest) (billingmigration.RepairResult, error) { + command, err := validatedCommand(request, true) + if err != nil { + return billingmigration.RepairResult{}, err + } + switch request.Kind { + case billingmigration.RepairRevalidateProviderReference: + if err := requireSingleReference(command); err != nil { + return billingmigration.RepairResult{}, err + } + if e.Provider == nil { + return billingmigration.RepairResult{}, billingmigration.ErrUnavailable + } + return validatedResult(e.Provider.RevalidateProviderReference(ctx, command)) + case billingmigration.RepairReplayFactRange: + if e.Facts == nil { + return billingmigration.RepairResult{}, billingmigration.ErrUnavailable + } + return validatedResult(e.Facts.ReplayImmutableFactRange(ctx, command)) + case billingmigration.RepairAttachProvenAlias: + if err := requireSingleReference(command); err != nil { + return billingmigration.RepairResult{}, err + } + if e.Aliases == nil { + return billingmigration.RepairResult{}, billingmigration.ErrUnavailable + } + return validatedResult(e.Aliases.AttachProvenAlias(ctx, command)) + case billingmigration.RepairReplaceMappingSet: + if err := requireSingleReference(command); err != nil { + return billingmigration.RepairResult{}, err + } + if e.Mappings == nil { + return billingmigration.RepairResult{}, billingmigration.ErrUnavailable + } + return validatedResult(e.Mappings.ReplaceMappingSetAndInvalidate(ctx, command)) + case billingmigration.RepairRetryQuarantinedRecord: + if err := requireSingleReference(command); err != nil { + return billingmigration.RepairResult{}, err + } + if e.Quarantine == nil { + return billingmigration.RepairResult{}, billingmigration.ErrUnavailable + } + return validatedResult(e.Quarantine.RetryQuarantinedRecord(ctx, command)) + default: + return billingmigration.RepairResult{}, fmt.Errorf("unsupported repair kind: %w", billingmigration.ErrInvalid) + } +} + +func validatedCommand(request billingmigration.RepairRequest, requireExecutionID bool) (RepairCommand, error) { + if !validIdentifier(request.ProgramID) || !validIdentifier(request.CaseID) { + return RepairCommand{}, billingmigration.ErrInvalid + } + if requireExecutionID && !validIdentifier(request.ExecutionID) { + return RepairCommand{}, billingmigration.ErrInvalid + } + if len(request.ScopeReferences) == 0 || len(request.ScopeReferences) > maxRepairScopeReferences { + return RepairCommand{}, billingmigration.ErrInvalid + } + seen := make(map[string]struct{}, len(request.ScopeReferences)) + references := make([]string, len(request.ScopeReferences)) + for i, reference := range request.ScopeReferences { + if !validReference(reference) { + return RepairCommand{}, billingmigration.ErrInvalid + } + if _, exists := seen[reference]; exists { + return RepairCommand{}, billingmigration.ErrInvalid + } + seen[reference] = struct{}{} + references[i] = reference + } + return RepairCommand{ + ExecutionID: request.ExecutionID, + ProgramID: request.ProgramID, + CaseID: request.CaseID, + References: references, + }, nil +} + +func requireSingleReference(command RepairCommand) error { + if len(command.References) != 1 { + return billingmigration.ErrInvalid + } + return nil +} + +func validatedResult(result billingmigration.RepairResult, err error) (billingmigration.RepairResult, error) { + if err != nil { + return result, err + } + if !validDigest(result.BeforeDigest) || !validDigest(result.AfterDigest) { + return billingmigration.RepairResult{}, billingmigration.ErrInvalid + } + for _, invalidation := range result.Invalidations { + if !validReference(invalidation.Kind) || !validReference(invalidation.ReferenceID) || !validDigest(invalidation.Digest) { + return billingmigration.RepairResult{}, billingmigration.ErrInvalid + } + } + return result, nil +} + +func validateImpact(impact billingmigration.RepairImpact) error { + if impact.AffectedCount < 0 || impact.AffectedCount > maxRepairAffectedCount { + return billingmigration.ErrInvalid + } + if !validDigest(impact.BeforeDigest) || !validDigest(impact.AfterDigest) { + return billingmigration.ErrInvalid + } + return nil +} + +func validDigest(digest []byte) bool { + return len(digest) == sha256DigestBytes +} + +func validIdentifier(value string) bool { + return value != "" && len(value) <= 128 && utf8.ValidString(value) && !containsControl(value) +} + +func validReference(value string) bool { + return value != "" && len(value) <= 512 && utf8.ValidString(value) && !containsControl(value) +} + +func containsControl(value string) bool { + for _, r := range value { + if r < 0x20 || r == 0x7f { + return true + } + } + return false +} diff --git a/apps/api/internal/platform/billingmigrationrepair/seams_test.go b/apps/api/internal/platform/billingmigrationrepair/seams_test.go new file mode 100644 index 00000000..47ddb277 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationrepair/seams_test.go @@ -0,0 +1,211 @@ +package billingmigrationrepair + +import ( + "context" + "errors" + "testing" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +type providerStub struct { + calls int + command RepairCommand + result billingmigration.RepairResult + err error +} + +func (s *providerStub) RevalidateProviderReference(_ context.Context, command RepairCommand) (billingmigration.RepairResult, error) { + s.calls++ + s.command = command + if s.result.BeforeDigest != nil || s.result.AfterDigest != nil || s.err != nil { + return s.result, s.err + } + return successfulResult(), nil +} + +type factStub struct { + calls int + command RepairCommand +} + +func (s *factStub) ReplayImmutableFactRange(_ context.Context, command RepairCommand) (billingmigration.RepairResult, error) { + s.calls++ + s.command = command + return successfulResult(), nil +} + +type previewStub struct { + calls int + impact billingmigration.RepairImpact +} + +func (s *previewStub) PreviewRepair(_ context.Context, _ billingmigration.RepairRequest) (billingmigration.RepairImpact, error) { + s.calls++ + if s.impact.BeforeDigest != nil || s.impact.AfterDigest != nil { + return s.impact, nil + } + return billingmigration.RepairImpact{AffectedCount: 1, BeforeDigest: digestOf(0), AfterDigest: digestOf(1)}, nil +} + +func TestExecutorExposesOnlyAllowlistedTypedSeams(t *testing.T) { + provider := &providerStub{} + executor := Executor{Provider: provider} + + _, err := executor.Execute(context.Background(), repairRequest(billingmigration.RepairRevalidateProviderReference, "reference")) + if err != nil { + t.Fatalf("execute allowlisted repair: %v", err) + } + if provider.calls != 1 { + t.Fatalf("provider calls = %d, want 1", provider.calls) + } + if provider.command.ExecutionID != "mre_stable" || provider.command.ProgramID != "program" || provider.command.CaseID != "case" { + t.Fatalf("provider command = %#v", provider.command) + } + if got := provider.command.References; len(got) != 1 || got[0] != "reference" { + t.Fatalf("provider references = %#v", got) + } + + _, err = executor.Execute(context.Background(), repairRequest("arbitrary_sql", "UPDATE facts")) + if !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("arbitrary repair error = %v, want invalid", err) + } +} + +func TestExecutorRejectsUnboundedSingleReferenceRepairs(t *testing.T) { + executor := Executor{Provider: &providerStub{}} + + _, err := executor.Execute(context.Background(), repairRequest(billingmigration.RepairRevalidateProviderReference, "one", "two")) + if !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("multi-reference provider repair error = %v, want invalid", err) + } +} + +func TestExecutorRequiresImmutableCaseContext(t *testing.T) { + provider := &providerStub{} + request := repairRequest(billingmigration.RepairRevalidateProviderReference, "reference") + request.CaseID = "" + + _, err := (Executor{Provider: provider}).Execute(context.Background(), request) + if !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("missing case context error = %v, want invalid", err) + } + if provider.calls != 0 { + t.Fatalf("provider calls = %d, want 0", provider.calls) + } +} + +func TestExecutorRejectsAmbiguousRepairScopeReferences(t *testing.T) { + executor := Executor{Facts: &factStub{}} + for name, refs := range map[string][]string{ + "empty": {""}, + "control": {"fact\n1"}, + "duplicate": {"fact-1", "fact-1"}, + } { + t.Run(name, func(t *testing.T) { + _, err := executor.Execute(context.Background(), repairRequest(billingmigration.RepairReplayFactRange, refs...)) + if !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("scope error = %v, want invalid", err) + } + }) + } +} + +func TestExecutorAllowsBoundedFactRangeReplay(t *testing.T) { + facts := &factStub{} + executor := Executor{Facts: facts} + + _, err := executor.Execute(context.Background(), repairRequest(billingmigration.RepairReplayFactRange, "fact-1", "fact-2")) + if err != nil { + t.Fatalf("fact replay: %v", err) + } + if facts.calls != 1 { + t.Fatalf("fact calls = %d, want 1", facts.calls) + } + if got := facts.command.References; len(got) != 2 || got[0] != "fact-1" || got[1] != "fact-2" { + t.Fatalf("fact references = %#v", got) + } +} + +func TestExecutorRejectsOversizedFactRangeReplay(t *testing.T) { + refs := make([]string, maxRepairScopeReferences+1) + for i := range refs { + refs[i] = "fact-" + string(rune('a'+i%26)) + string(rune('A'+i/26)) + } + + _, err := (Executor{Facts: &factStub{}}).Execute(context.Background(), repairRequest(billingmigration.RepairReplayFactRange, refs...)) + if !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("oversized fact range error = %v, want invalid", err) + } +} + +func TestExecutorRejectsInvalidSuccessfulResultDigests(t *testing.T) { + provider := &providerStub{result: billingmigration.RepairResult{BeforeDigest: digestOf(0), AfterDigest: []byte("short")}} + + _, err := (Executor{Provider: provider}).Execute(context.Background(), repairRequest(billingmigration.RepairRevalidateProviderReference, "reference")) + if !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("invalid result digest error = %v, want invalid", err) + } +} + +func TestExecutorRejectsInvalidSuccessfulResultInvalidations(t *testing.T) { + provider := &providerStub{result: billingmigration.RepairResult{ + BeforeDigest: digestOf(0), + AfterDigest: digestOf(1), + Invalidations: []billingmigration.RepairInvalidation{{ + Kind: "mapping_set", + ReferenceID: "mapping", + Digest: []byte("short"), + }}, + }} + + _, err := (Executor{Provider: provider}).Execute(context.Background(), repairRequest(billingmigration.RepairRevalidateProviderReference, "reference")) + if !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("invalid invalidation digest error = %v, want invalid", err) + } +} + +func TestExecutorValidatesPreviewBeforePreviewer(t *testing.T) { + previewer := &previewStub{} + request := repairRequest(billingmigration.RepairRevalidateProviderReference, "reference") + request.ProgramID = "" + + _, err := (Executor{Previewer: previewer}).Preview(context.Background(), request) + if !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("invalid preview request error = %v, want invalid", err) + } + if previewer.calls != 0 { + t.Fatalf("preview calls = %d, want 0", previewer.calls) + } +} + +func TestExecutorRejectsInvalidPreviewDigest(t *testing.T) { + previewer := &previewStub{impact: billingmigration.RepairImpact{AffectedCount: 1, BeforeDigest: digestOf(0), AfterDigest: []byte("short")}} + + _, err := (Executor{Previewer: previewer}).Preview(context.Background(), repairRequest(billingmigration.RepairRevalidateProviderReference, "reference")) + if !errors.Is(err, billingmigration.ErrInvalid) { + t.Fatalf("invalid preview digest error = %v, want invalid", err) + } +} + +func repairRequest(kind string, references ...string) billingmigration.RepairRequest { + return billingmigration.RepairRequest{ + Kind: kind, + ProgramID: "program", + CaseID: "case", + ExecutionID: "mre_stable", + ScopeReferences: references, + } +} + +func successfulResult() billingmigration.RepairResult { + return billingmigration.RepairResult{BeforeDigest: digestOf(0), AfterDigest: digestOf(1)} +} + +func digestOf(v byte) []byte { + b := make([]byte, sha256DigestBytes) + for i := range b { + b[i] = v + } + return b +} diff --git a/apps/api/internal/platform/billingmigrationvalidation/adapter.go b/apps/api/internal/platform/billingmigrationvalidation/adapter.go new file mode 100644 index 00000000..82499177 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationvalidation/adapter.go @@ -0,0 +1,81 @@ +// Package billingmigrationvalidation adapts Phase 9C known provider references +// to the existing Phase 9A intake/validation pipeline. +package billingmigrationvalidation + +import ( + "context" + "crypto/sha256" + "sort" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +type ValidationService interface { + AcceptMigrationValidation(context.Context, billing.MigrationValidationRequest) (billing.MigrationValidationAcceptance, error) + MigrationValidationOutcome(context.Context, string, string, string) (billing.MigrationValidationBinding, error) +} + +type Adapter struct{ validation ValidationService } + +func New(validation ValidationService) *Adapter { return &Adapter{validation: validation} } + +var _ billingmigration.ProviderEvidenceImporter = (*Adapter)(nil) + +func (a *Adapter) RevalidateKnownReferences(ctx context.Context, projectID, programID string, references []billingmigration.KnownProviderReference) (billingmigration.ProviderEvidenceResult, error) { + result := billingmigration.ProviderEvidenceResult{} + digests := make([][]byte, 0, len(references)) + for _, ref := range references { + acceptance, err := a.validation.AcceptMigrationValidation(ctx, billing.MigrationValidationRequest{ + ProgramID: programID, ProjectID: projectID, EnvironmentID: ref.EnvironmentID, ApplicationID: ref.ApplicationID, + Provider: ref.Provider, ReferenceKind: ref.ReferenceKind, Reference: ref.Reference, + ExpectedStoreProductIdentifier: ref.ExpectedStoreProductID, ExpectedMosaicProductID: ref.TargetProductID, + ExpectedStoreEnvironment: ref.ExpectedStoreEnvironment, + }) + if err != nil { + return result, err + } + outcome, err := a.validation.MigrationValidationOutcome(ctx, projectID, programID, acceptance.BindingID) + if err != nil { + return result, err + } + switch outcome.Status { + case billing.MigrationValidationAccepted: + result.Accepted++ + case billing.MigrationValidationValidated: + result.Validated++ + digests = append(digests, outcome.EvidenceDigest) + if outcome.ProviderWatermark.After(result.ProviderWatermark) { + result.ProviderWatermark = outcome.ProviderWatermark + } + case billing.MigrationValidationQuarantined: + result.Quarantined++ + digests = append(digests, outcome.EvidenceDigest) + if outcome.ProviderWatermark.After(result.ProviderWatermark) { + result.ProviderWatermark = outcome.ProviderWatermark + } + default: + return result, billingmigration.ErrInvalid + } + } + result.EvidenceDigest = evidenceDigest(digests) + if result.Accepted > 0 { + return result, billingmigration.ErrValidationPending + } + return result, nil +} + +func evidenceDigest(digests [][]byte) []byte { + if len(digests) == 0 { + sum := sha256.Sum256([]byte("mosaic-billing-migration-validation-pending-v1")) + return sum[:] + } + sort.Slice(digests, func(i, j int) bool { return string(digests[i]) < string(digests[j]) }) + h := sha256.New() + _, _ = h.Write([]byte("mosaic-billing-migration-validation-result-v1")) + for _, digest := range digests { + _, _ = h.Write([]byte{0}) + _, _ = h.Write(digest) + } + return h.Sum(nil) +} diff --git a/apps/api/internal/platform/billingmigrationvalidation/adapter_test.go b/apps/api/internal/platform/billingmigrationvalidation/adapter_test.go new file mode 100644 index 00000000..2fb45dd3 --- /dev/null +++ b/apps/api/internal/platform/billingmigrationvalidation/adapter_test.go @@ -0,0 +1,74 @@ +package billingmigrationvalidation + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +func TestAdapterDistinguishesAcceptedFromAuthoritativelyValidated(t *testing.T) { + store := &validationFake{outcome: billing.MigrationValidationBinding{ID: "binding", Status: billing.MigrationValidationAccepted}} + adapter := New(store) + refs := []billingmigration.KnownProviderReference{{Provider: billing.ProviderAppStore, EnvironmentID: "env", ApplicationID: "app", Reference: "2000001", ReferenceKind: billing.ReferenceAppStoreTransactionID, TargetProductID: "product", ExpectedStoreProductID: "store.product", ExpectedStoreEnvironment: billing.StoreProduction}} + + first, err := adapter.RevalidateKnownReferences(context.Background(), "project", "program", refs) + if !errors.Is(err, billingmigration.ErrValidationPending) || first.Accepted != 1 || first.Validated != 0 { + t.Fatalf("accepted result=%+v err=%v", first, err) + } + store.outcome.Status = billing.MigrationValidationValidated + store.outcome.EvidenceDigest = bytesOf(7) + store.outcome.ProviderWatermark = time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + second, err := New(store).RevalidateKnownReferences(context.Background(), "project", "program", refs) + if err != nil || second.Validated != 1 || second.Accepted != 0 || len(second.EvidenceDigest) != 32 { + t.Fatalf("validated result=%+v err=%v", second, err) + } + if store.created != 1 { + t.Fatalf("idempotent replay created %d bindings, want 1", store.created) + } +} + +func TestAdapterCrashRetryReadsExistingQuarantine(t *testing.T) { + store := &validationFake{outcome: billing.MigrationValidationBinding{ID: "binding", Status: billing.MigrationValidationAccepted}} + ref := billingmigration.KnownProviderReference{Provider: billing.ProviderGooglePlay, EnvironmentID: "env", ApplicationID: "app", Reference: "purchase-token", ReferenceKind: "google_play_purchase_token", TargetProductID: "product", ExpectedStoreProductID: "sub.monthly", ExpectedStoreEnvironment: billing.StoreSandbox} + _, _ = New(store).RevalidateKnownReferences(context.Background(), "project", "program", []billingmigration.KnownProviderReference{ref}) + // Simulate a worker commit after the importing process exited. A fresh + // adapter must reuse and read the durable terminal row, not enqueue another. + store.outcome.Status = billing.MigrationValidationQuarantined + store.outcome.EvidenceDigest = bytesOf(9) + store.outcome.ProviderWatermark = time.Now().UTC() + result, err := New(store).RevalidateKnownReferences(context.Background(), "project", "program", []billingmigration.KnownProviderReference{ref}) + if err != nil || result.Quarantined != 1 || result.Validated != 0 { + t.Fatalf("retry result=%+v err=%v", result, err) + } + if store.created != 1 { + t.Fatalf("crash retry created %d bindings, want 1", store.created) + } +} + +type validationFake struct { + created int + accepted bool + outcome billing.MigrationValidationBinding +} + +func (f *validationFake) AcceptMigrationValidation(_ context.Context, _ billing.MigrationValidationRequest) (billing.MigrationValidationAcceptance, error) { + if !f.accepted { + f.accepted = true + f.created++ + } + return billing.MigrationValidationAcceptance{BindingID: f.outcome.ID, RawInputID: "raw", Status: f.outcome.Status}, nil +} +func (f *validationFake) MigrationValidationOutcome(context.Context, string, string, string) (billing.MigrationValidationBinding, error) { + return f.outcome, nil +} +func bytesOf(v byte) []byte { + b := make([]byte, 32) + for i := range b { + b[i] = v + } + return b +} diff --git a/apps/api/internal/platform/billingpostgres/billing_integration_test.go b/apps/api/internal/platform/billingpostgres/billing_integration_test.go index 3245cd6d..cd10ed71 100644 --- a/apps/api/internal/platform/billingpostgres/billing_integration_test.go +++ b/apps/api/internal/platform/billingpostgres/billing_integration_test.go @@ -116,6 +116,7 @@ func cleanup(t *testing.T, ctx context.Context, pool *pgxpool.Pool, projectID st `DELETE FROM billing_quarantine_records WHERE project_id=$1`, `DELETE FROM billing_transaction_facts WHERE project_id=$1`, `DELETE FROM billing_product_resolutions WHERE project_id=$1`, + `DELETE FROM billing_identity_binding_jobs WHERE project_id=$1`, `DELETE FROM billing_validation_jobs WHERE project_id=$1`, `DELETE FROM billing_validation_attempts WHERE project_id=$1`, `DELETE FROM billing_raw_inputs WHERE project_id=$1`, diff --git a/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go b/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go index 7bbd2231..6ee82605 100644 --- a/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go +++ b/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go @@ -57,6 +57,7 @@ func TestKeyringRotationCoversEveryEnvelopeTable(t *testing.T) { "store_server_credentials": true, "billing_raw_inputs": true, "webhook_signing_secrets": true, + "billing_migration_credentials": true, } for table := range found { if !rotatable[table] { @@ -89,12 +90,12 @@ func TestKeyringRotationResealsBothBillingTables(t *testing.T) { keyA := "3q2-7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" keyB := "7v7-3QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" underA, err := providercredential.NewAESGCMCipher( - `{"version":1,"activeKeyId":"key-a","keys":{"key-a":"`+keyA+`","key-b":"`+keyB+`"}}`, randomReader{}) + `{"version":1,"activeKeyId":"key-a","keys":{"key-a":"`+keyA+`","key-b":"`+keyB+`","key_one":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}`, randomReader{}) if err != nil { t.Fatal(err) } underB, err := providercredential.NewAESGCMCipher( - `{"version":1,"activeKeyId":"key-b","keys":{"key-a":"`+keyA+`","key-b":"`+keyB+`"}}`, randomReader{}) + `{"version":1,"activeKeyId":"key-b","keys":{"key-a":"`+keyA+`","key-b":"`+keyB+`","key_one":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}`, randomReader{}) if err != nil { t.Fatal(err) } @@ -124,6 +125,23 @@ func TestKeyringRotationResealsBothBillingTables(t *testing.T) { t.Fatal(err) } + migrationCredentialID := "bmc_rotate_fixture" + if _, err := pool.Exec(ctx, `DELETE FROM billing_migration_credentials WHERE id IN ($1,'bmc_removed_fixture')`, migrationCredentialID); err != nil { + t.Fatal(err) + } + migrationSecret := []byte("revenuecat-migration-secret") + migrationScope := providercredential.SubjectScope{OrganizationID: organizationID, ProjectID: projectID, SubjectKind: providercredential.SubjectBillingMigrationCredential, SubjectID: migrationCredentialID, CredentialClass: "revenuecat_migration_api_key"} + migrationEnvelope, err := underA.EncryptSubject(migrationSecret, migrationScope) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) VALUES($1,$2,'revenuecat','rc-active','active',$3,$4,$5,$6,$7,$8,'actor',$9)`, migrationCredentialID, projectID, migrationEnvelope.Version, migrationEnvelope.Algorithm, migrationEnvelope.KeyID, migrationEnvelope.Nonce, migrationEnvelope.Ciphertext, migrationEnvelope.Fingerprint, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at,removed_at,removed_by_actor_id,removal_digest) VALUES('bmc_removed_fixture',$1,'revenuecat','rc-removed','active',1,'AES-256-GCM','key-a',NULL,NULL,decode(repeat('90',32),'hex'),'actor',$2,$2,'actor',decode(repeat('91',32),'hex'))`, projectID, now); err != nil { + t.Fatal(err) + } + // A raw input with a retained, sealed body. input := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-rotate") body := []byte(`{"signedPayload":"fixture-rotate-body"}`) @@ -151,9 +169,9 @@ func TestKeyringRotationResealsBothBillingTables(t *testing.T) { if err != nil { t.Fatal(err) } - if counts["key-a"] < 2 { - t.Fatalf("keyring inspect reports %d envelope(s) under the retired key, want at least 2 "+ - "(a credential and a raw body); an under-report is what makes a documented rotation destructive", counts["key-a"]) + if counts["key-a"] < 3 { + t.Fatalf("keyring inspect reports %d envelope(s) under the retired key, want at least 3 "+ + "(store credential, migration credential, and raw body); an under-report is what makes a documented rotation destructive", counts["key-a"]) } // rotate: page, reseal under key B, write back. @@ -162,9 +180,11 @@ func TestKeyringRotationResealsBothBillingTables(t *testing.T) { t.Fatal(err) } tables := map[string]bool{} + removedMigrationPaged := false resealed := make([]BillingEnvelope, 0, len(envelopes)) for _, envelope := range envelopes { tables[envelope.Table] = true + removedMigrationPaged = removedMigrationPaged || envelope.RowID == "bmc_removed_fixture" plaintext, err := underB.DecryptSubject(providercredential.Envelope{ Version: envelope.Version, Algorithm: envelope.Algorithm, KeyID: envelope.KeyID, Nonce: envelope.Nonce, Ciphertext: envelope.Ciphertext, @@ -181,8 +201,8 @@ func TestKeyringRotationResealsBothBillingTables(t *testing.T) { envelope.Nonce, envelope.Ciphertext, envelope.Fingerprint = sealed.Nonce, sealed.Ciphertext, sealed.Fingerprint resealed = append(resealed, envelope) } - if !tables["store_server_credentials"] || !tables["billing_raw_inputs"] { - t.Fatalf("rotation paged over %v; both billing envelope tables must appear", tables) + if !tables["store_server_credentials"] || !tables["billing_migration_credentials"] || !tables["billing_raw_inputs"] { + t.Fatalf("rotation paged over %v; every active billing envelope table must appear", tables) } if err := repository.ReplaceEnvelopes(ctx, resealed, now); err != nil { t.Fatal(err) @@ -208,6 +228,26 @@ func TestKeyringRotationResealsBothBillingTables(t *testing.T) { if err != nil || string(opened) != string(credentialSecret) { t.Fatalf("credential did not survive rotation: %v", err) } + var migrationKeyID string + var migrationNonce, migrationCiphertext, migrationFingerprint []byte + if err := pool.QueryRow(ctx, `SELECT key_id,nonce,ciphertext,fingerprint FROM billing_migration_credentials WHERE id=$1`, migrationCredentialID).Scan(&migrationKeyID, &migrationNonce, &migrationCiphertext, &migrationFingerprint); err != nil { + t.Fatal(err) + } + if migrationKeyID != "key-b" { + t.Fatalf("migration credential still sealed under %q", migrationKeyID) + } + openedMigration, err := underB.DecryptSubject(providercredential.Envelope{Version: migrationEnvelope.Version, Algorithm: migrationEnvelope.Algorithm, KeyID: migrationKeyID, Nonce: migrationNonce, Ciphertext: migrationCiphertext, CredentialClass: "revenuecat_migration_api_key", Fingerprint: migrationFingerprint}, migrationScope) + if err != nil || string(openedMigration) != string(migrationSecret) { + t.Fatalf("migration credential did not survive rotation: %v", err) + } + var removedKeyID string + var removedNonce, removedCiphertext []byte + if err := pool.QueryRow(ctx, `SELECT key_id,nonce,ciphertext FROM billing_migration_credentials WHERE id='bmc_removed_fixture'`).Scan(&removedKeyID, &removedNonce, &removedCiphertext); err != nil { + t.Fatal(err) + } + if removedKeyID != "key-a" || removedNonce != nil || removedCiphertext != nil || removedMigrationPaged { + t.Fatal("cryptographically removed migration credential was included in rotation") + } stored, err := repository.RawInput(ctx, projectID, input.ID) if err != nil { diff --git a/apps/api/internal/platform/billingpostgres/identity_binding_integration_test.go b/apps/api/internal/platform/billingpostgres/identity_binding_integration_test.go new file mode 100644 index 00000000..07d2b791 --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/identity_binding_integration_test.go @@ -0,0 +1,237 @@ +package billingpostgres + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" +) + +type failOnceLineageBinder struct { + calls int + bindings []billing.FactBinding +} + +func (b *failOnceLineageBinder) BindFact(_ context.Context, binding billing.FactBinding) error { + b.calls++ + b.bindings = append(b.bindings, binding) + if b.calls == 1 { + return errors.New("fixture identity store unavailable") + } + return nil +} + +func bindingOutcome(projectID, environmentID, applicationID, rawInputID, attemptID, factID string, + factDigest, chainDigest, referenceDigest, correlatorDigest []byte, now time.Time) billing.AttemptOutcome { + + periodStart := now.Add(-24 * time.Hour) + periodEnd := now.Add(30 * 24 * time.Hour) + fact := billing.TransactionFact{ + ID: factID, ProjectID: projectID, EnvironmentID: environmentID, + EnvironmentMode: "production", ApplicationID: applicationID, + Provider: billing.ProviderAppStore, StoreEnvironment: billing.StoreProduction, + ProviderTransactionID: "3000000000000051", PurchaseChainDigest: chainDigest, + TransactionType: billing.TypeAutoRenewableSubscription, FactKind: billing.KindInitialPurchase, + OccurredAt: periodStart, PeriodStartAt: &periodStart, PeriodEndAt: &periodEnd, + ProviderProductIdentifier: "fixture.monthly", ResolutionState: billing.StateUnresolved, + ValidatorVersion: billing.ValidatorVersion, FactVersion: 1, + SourceRawInputID: rawInputID, ValidationAttemptID: attemptID, + FactDigest: factDigest, RecordedAt: now, + } + return billing.AttemptOutcome{ + Attempt: billing.ValidationAttempt{ + ID: attemptID, ProjectID: projectID, EnvironmentID: environmentID, + RawInputID: rawInputID, AttemptNumber: 1, ValidatorVersion: billing.ValidatorVersion, + StartedAt: now, CompletedAt: now, Outcome: billing.OutcomeValidated, + StoreEnvironment: billing.StoreProduction, CorrelationID: "identity-binding-fixture", + }, + Fact: &fact, + ReferenceDigests: [][]byte{referenceDigest}, + Correlators: []billing.AssociationCorrelator{{ + EvidenceType: "app_account_token", AliasType: "app_account_token", Digest: correlatorDigest, + }}, + } +} + +func persistBindingInput(t *testing.T, ctx context.Context, repository *Repository, + projectID, environmentID, applicationID, key string, now time.Time) (billing.PersistResult, billing.ValidationJob) { + + t.Helper() + result, err := repository.PersistRawInput(ctx, + sampleInput(projectID, environmentID, applicationID, key), true, now) + if err != nil { + t.Fatalf("persist raw input: %v", err) + } + job, leased, err := repository.LeaseValidationJob(ctx, "validation-fixture", now, now.Add(2*time.Minute)) + if err != nil || !leased { + t.Fatalf("lease validation job: leased=%v err=%v", leased, err) + } + return result, job +} + +// This protects the crash/failure window between a committed fact and its +// identity decision. A transient binder failure must retry only the binding; +// the provider-facing validation attempt and append-only fact stay singular. +func TestIdentityBindingRetriesWithoutRepeatingValidationOrFact(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "identity_retry") + now := time.Now().UTC().Truncate(time.Millisecond) + input, validationJob := persistBindingInput(t, ctx, repository, + projectID, environmentID, applicationID, "identity-retry", now) + outcome := bindingOutcome(projectID, environmentID, applicationID, input.RawInputID, + "bva_identity_retry", "btf_identity_retry", billing.ContentDigest([]byte("fact")), + billing.ContentDigest([]byte("chain")), billing.ContentDigest([]byte("reference")), + billing.ContentDigest([]byte("correlator")), now) + if err := repository.CompleteAttempt(ctx, validationJob, outcome, now); err != nil { + t.Fatalf("complete validation attempt: %v", err) + } + + clock := now + binder := &failOnceLineageBinder{} + service := billing.NewService(repository, nil, nil, + billing.WithClock(func() time.Time { return clock }), billing.WithSeam(binder, nil)) + processed, err := service.ProcessNextIdentityBinding(ctx, "identity-worker") + if !processed || err == nil { + t.Fatalf("first binding: processed=%v err=%v, want processed failure", processed, err) + } + clock = clock.Add(2 * time.Second) + processed, err = service.ProcessNextIdentityBinding(ctx, "identity-worker") + if !processed || err != nil { + t.Fatalf("retried binding: processed=%v err=%v", processed, err) + } + + var attempts, facts, bindingAttempts int + var status string + if err := pool.QueryRow(ctx, `SELECT + (SELECT count(*) FROM billing_validation_attempts WHERE project_id=$1), + (SELECT count(*) FROM billing_transaction_facts WHERE project_id=$1), + status, attempt_count + FROM billing_identity_binding_jobs WHERE validation_attempt_id=$2`, projectID, outcome.Attempt.ID). + Scan(&attempts, &facts, &status, &bindingAttempts); err != nil { + t.Fatal(err) + } + if attempts != 1 || facts != 1 || binder.calls != 2 || status != "completed" || bindingAttempts != 2 { + t.Fatalf("attempts=%d facts=%d binderCalls=%d job=%s/%d", attempts, facts, binder.calls, status, bindingAttempts) + } + bound := binder.bindings[1] + if len(bound.ReferenceDigests) != 1 || + !bytes.Equal(bound.ReferenceDigests[0], outcome.ReferenceDigests[0]) || + len(bound.Correlators) != 1 || !bytes.Equal(bound.Correlators[0].Digest, outcome.Correlators[0].Digest) { + t.Fatalf("digest-only binding payload did not round-trip: %+v", bound) + } +} + +// This protects evidence freshness on revalidation. Fact deduplication must not +// absorb a later attempt's identity work, because that attempt can carry a new +// digest-only correlator or submission reference that resolves ownership. +func TestDeduplicatedRevalidationCreatesNewEvidenceBindingJob(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "identity_dedup") + now := time.Now().UTC().Truncate(time.Millisecond) + input, firstJob := persistBindingInput(t, ctx, repository, + projectID, environmentID, applicationID, "identity-dedup", now) + factDigest := billing.ContentDigest([]byte("stable-fact")) + chainDigest := billing.ContentDigest([]byte("stable-chain")) + first := bindingOutcome(projectID, environmentID, applicationID, input.RawInputID, + "bva_identity_dedup_1", "btf_identity_dedup_1", factDigest, chainDigest, + billing.ContentDigest([]byte("reference-1")), billing.ContentDigest([]byte("correlator-1")), now) + if err := repository.CompleteAttempt(ctx, firstJob, first, now); err != nil { + t.Fatalf("complete first attempt: %v", err) + } + + raw, err := repository.RawInput(ctx, projectID, input.RawInputID) + if err != nil { + t.Fatal(err) + } + secondJob, err := repository.LeaseValidationJobFor(ctx, "revalidation-fixture", raw, + now.Add(time.Second), now.Add(2*time.Minute)) + if err != nil { + t.Fatalf("lease revalidation: %v", err) + } + second := bindingOutcome(projectID, environmentID, applicationID, input.RawInputID, + "bva_identity_dedup_2", "btf_identity_dedup_2", factDigest, chainDigest, + billing.ContentDigest([]byte("reference-2")), billing.ContentDigest([]byte("correlator-2")), now.Add(time.Second)) + second.Attempt.AttemptNumber = 2 + if err := repository.CompleteAttempt(ctx, secondJob, second, now.Add(time.Second)); err != nil { + t.Fatalf("complete deduplicated attempt: %v", err) + } + + var facts, attempts, jobs, distinctEvidence int + if err := pool.QueryRow(ctx, `SELECT + (SELECT count(*) FROM billing_transaction_facts WHERE project_id=$1), + (SELECT count(*) FROM billing_validation_attempts WHERE project_id=$1), + count(*), count(DISTINCT correlators) + FROM billing_identity_binding_jobs WHERE project_id=$1`, projectID). + Scan(&facts, &attempts, &jobs, &distinctEvidence); err != nil { + t.Fatal(err) + } + if facts != 1 || attempts != 2 || jobs != 2 || distinctEvidence != 2 { + t.Fatalf("facts=%d attempts=%d jobs=%d evidenceDocuments=%d", facts, attempts, jobs, distinctEvidence) + } + + // Reclaim must prefer the expired lease for this lineage even when its + // queued sibling sorts first. Otherwise the sibling's transition to leased + // collides with the partial unique leased-lineage index, and neither item of + // durable work can advance. + leaseTime := now.Add(2 * time.Second) + leased, ok, err := repository.LeaseIdentityBindingJob(ctx, "expired-owner", + leaseTime, leaseTime.Add(time.Second)) + if err != nil || !ok { + t.Fatalf("lease first identity job: leased=%v err=%v", ok, err) + } + if leased.ValidationAttemptID != first.Attempt.ID { + t.Fatalf("leased attempt %q, want first attempt %q", leased.ValidationAttemptID, first.Attempt.ID) + } + if _, err := pool.Exec(ctx, + `UPDATE billing_identity_binding_jobs SET available_at=$2 + WHERE project_id=$1 AND validation_attempt_id=$3`, + projectID, now.Add(-time.Hour), second.Attempt.ID); err != nil { + t.Fatal(err) + } + reclaimed, ok, err := repository.LeaseIdentityBindingJob(ctx, "recovery-owner", + leaseTime.Add(2*time.Second), leaseTime.Add(time.Minute)) + if err != nil || !ok { + t.Fatalf("reclaim expired identity job: leased=%v err=%v", ok, err) + } + if reclaimed.ID != leased.ID || reclaimed.ValidationAttemptID != first.Attempt.ID { + t.Fatalf("reclaimed job %q/%q, want expired job %q/%q", + reclaimed.ID, reclaimed.ValidationAttemptID, leased.ID, first.Attempt.ID) + } + + // If that reclaimed lease consumed its final attempt and the worker exited, + // it must relinquish the unique lineage slot as failed. The queued sibling + // then becomes leaseable in the very same repository call. + exhaustedAt := leaseTime.Add(3 * time.Second) + if _, err := pool.Exec(ctx, + `UPDATE billing_identity_binding_jobs + SET attempt_count=max_attempts, lease_expires_at=$2 + WHERE id=$1`, reclaimed.ID, exhaustedAt.Add(-time.Second)); err != nil { + t.Fatal(err) + } + sibling, ok, err := repository.LeaseIdentityBindingJob(ctx, "sibling-owner", + exhaustedAt, exhaustedAt.Add(time.Minute)) + if err != nil || !ok { + t.Fatalf("lease sibling after exhausted expiry: leased=%v err=%v", ok, err) + } + if sibling.ValidationAttemptID != second.Attempt.ID { + t.Fatalf("leased attempt %q, want queued sibling %q", sibling.ValidationAttemptID, second.Attempt.ID) + } + var exhaustedStatus string + var exhaustedOwner *string + var exhaustedExpiry *time.Time + if err := pool.QueryRow(ctx, + `SELECT status, lease_owner, lease_expires_at + FROM billing_identity_binding_jobs WHERE id=$1`, reclaimed.ID). + Scan(&exhaustedStatus, &exhaustedOwner, &exhaustedExpiry); err != nil { + t.Fatal(err) + } + if exhaustedStatus != "failed" || exhaustedOwner != nil || exhaustedExpiry != nil { + t.Fatalf("exhausted job state=%q owner=%v expiry=%v, want failed with lease cleared", + exhaustedStatus, exhaustedOwner, exhaustedExpiry) + } +} diff --git a/apps/api/internal/platform/billingpostgres/jobs.go b/apps/api/internal/platform/billingpostgres/jobs.go index 7ff9012b..fca72d5c 100644 --- a/apps/api/internal/platform/billingpostgres/jobs.go +++ b/apps/api/internal/platform/billingpostgres/jobs.go @@ -156,6 +156,130 @@ func (r *Repository) ParkValidationJob(ctx context.Context, job billing.Validati return nil } +// LeaseIdentityBindingJob claims one digest-only identity decision. Expired +// leases are recoverable, so a worker exit after BindFact but before completion +// safely repeats the idempotent identity operation. +func (r *Repository) LeaseIdentityBindingJob(ctx context.Context, workerID string, now, leaseUntil time.Time) (billing.IdentityBindingJob, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billing.IdentityBindingJob{}, false, fmt.Errorf("begin identity-binding lease: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // A worker can exit while holding its final permitted attempt. Such a row is + // no longer claimable, but while it remains `leased` it still occupies the + // partial unique lineage slot and would block every queued sibling forever. + // Terminalize a bounded batch in this transaction before selecting work, so + // clearing the slot and claiming the next eligible job are one atomic action. + if _, err := tx.Exec(ctx, + `WITH exhausted AS ( + SELECT id FROM billing_identity_binding_jobs + WHERE status='leased' AND lease_expires_at <= $1 AND attempt_count >= max_attempts + ORDER BY lease_expires_at, id + FOR UPDATE SKIP LOCKED LIMIT 100 + ) + UPDATE billing_identity_binding_jobs jobs + SET status='failed', lease_owner=NULL, lease_expires_at=NULL, + last_error_code=COALESCE(last_error_code,'identity_binding_lease_expired_exhausted'), + updated_at=$1 + FROM exhausted WHERE jobs.id=exhausted.id`, now); err != nil { + return billing.IdentityBindingJob{}, false, fmt.Errorf("terminalize exhausted identity-binding leases: %w", err) + } + + var job billing.IdentityBindingJob + var correlators []byte + err = tx.QueryRow(ctx, + `SELECT id, project_id, environment_id, validation_attempt_id, raw_input_id, provider, + lineage_key_digest, fact_chain_digest, reference_digests, correlators, + acquired_at, attempt_count, max_attempts + FROM billing_identity_binding_jobs candidate + WHERE (candidate.status='queued' OR (candidate.status='leased' AND candidate.lease_expires_at <= $1)) + AND candidate.available_at <= $1 AND candidate.attempt_count < candidate.max_attempts + AND NOT EXISTS ( + SELECT 1 FROM billing_identity_binding_jobs leased + WHERE leased.status='leased' AND leased.id <> candidate.id + AND leased.environment_id=candidate.environment_id AND leased.provider=candidate.provider + AND leased.lineage_key_digest=candidate.lineage_key_digest + ) + ORDER BY candidate.available_at, candidate.created_at, candidate.id + FOR UPDATE SKIP LOCKED LIMIT 1`, now).Scan( + &job.ID, &job.ProjectID, &job.EnvironmentID, &job.ValidationAttemptID, + &job.Binding.RawInputID, &job.Binding.Provider, &job.Binding.LineageKeyDigest, + &job.Binding.FactChainDigest, &job.Binding.ReferenceDigests, &correlators, + &job.Binding.AcquiredAt, &job.AttemptCount, &job.MaxAttempts) + if errors.Is(err, pgx.ErrNoRows) { + return billing.IdentityBindingJob{}, false, nil + } + if err != nil { + return billing.IdentityBindingJob{}, false, fmt.Errorf("select identity-binding job: %w", err) + } + if err := json.Unmarshal(correlators, &job.Binding.Correlators); err != nil { + return billing.IdentityBindingJob{}, false, fmt.Errorf("decode identity-binding correlators: %w", err) + } + job.Binding.ProjectID = job.ProjectID + job.Binding.EnvironmentID = job.EnvironmentID + job.LeaseOwner = workerID + if _, err := tx.Exec(ctx, + `UPDATE billing_identity_binding_jobs + SET status='leased', lease_owner=$2, lease_expires_at=$3, + attempt_count=attempt_count+1, updated_at=$4 + WHERE id=$1`, job.ID, workerID, leaseUntil, now); err != nil { + return billing.IdentityBindingJob{}, false, fmt.Errorf("lease identity-binding job: %w", err) + } + job.AttemptCount++ + if err := tx.Commit(ctx); err != nil { + return billing.IdentityBindingJob{}, false, fmt.Errorf("commit identity-binding lease: %w", err) + } + return job, true, nil +} + +func (r *Repository) CompleteIdentityBindingJob(ctx context.Context, job billing.IdentityBindingJob, now time.Time) error { + tag, err := r.pool.Exec(ctx, + `UPDATE billing_identity_binding_jobs + SET status='completed', lease_owner=NULL, lease_expires_at=NULL, + last_error_code=NULL, available_at=$2, updated_at=$2 + WHERE id=$1 AND status='leased' AND lease_owner=$3`, job.ID, now, job.LeaseOwner) + if err != nil { + return fmt.Errorf("complete identity-binding job: %w", err) + } + if tag.RowsAffected() != 1 { + return fmt.Errorf("complete identity-binding job: lease lost") + } + return nil +} + +func (r *Repository) ParkIdentityBindingJob(ctx context.Context, job billing.IdentityBindingJob, reason string, now time.Time) error { + tag, err := r.pool.Exec(ctx, + `UPDATE billing_identity_binding_jobs + SET status='queued', attempt_count=GREATEST(attempt_count-1,0), available_at=$2, + lease_owner=NULL, lease_expires_at=NULL, last_error_code=NULLIF($3,''), updated_at=$4 + WHERE id=$1 AND status='leased' AND lease_owner=$5`, + job.ID, now.Add(parkedRetryDelay), reason, now, job.LeaseOwner) + if err != nil { + return fmt.Errorf("park identity-binding job: %w", err) + } + if tag.RowsAffected() != 1 { + return fmt.Errorf("park identity-binding job: lease lost") + } + return nil +} + +func (r *Repository) RetryIdentityBindingJob(ctx context.Context, job billing.IdentityBindingJob, reason string, availableAt, now time.Time) error { + tag, err := r.pool.Exec(ctx, + `UPDATE billing_identity_binding_jobs + SET status=CASE WHEN attempt_count >= max_attempts THEN 'failed' ELSE 'queued' END, + available_at=$2, lease_owner=NULL, lease_expires_at=NULL, + last_error_code=NULLIF($3,''), updated_at=$4 + WHERE id=$1 AND status='leased' AND lease_owner=$5`, job.ID, availableAt, reason, now, job.LeaseOwner) + if err != nil { + return fmt.Errorf("retry identity-binding job: %w", err) + } + if tag.RowsAffected() != 1 { + return fmt.Errorf("retry identity-binding job: lease lost") + } + return nil +} + // parkedRetryDelay keeps a parked job from spinning the worker loop. const parkedRetryDelay = 5 * time.Minute @@ -275,6 +399,33 @@ func (r *Repository) CompleteAttempt(ctx context.Context, job billing.Validation } } + // Completing the ordinary Phase 9A attempt is also the only operation that + // may complete a Phase 9C validation binding. Retryable attempts deliberately + // leave it accepted; pending is never represented as validated evidence. + var migrationBinding billing.MigrationValidationBinding + err = tx.QueryRow(ctx, `SELECT id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind, + expected_application_id,expected_store_product_identifier,expected_mosaic_product_id,expected_store_environment + FROM billing_migration_validation_bindings WHERE raw_input_id=$1 FOR UPDATE`, attempt.RawInputID). + Scan(&migrationBinding.ID, &migrationBinding.ProgramID, &migrationBinding.ProjectID, &migrationBinding.EnvironmentID, + &migrationBinding.RawInputID, &migrationBinding.Provider, &migrationBinding.ReferenceKind, + &migrationBinding.ExpectedApplicationID, &migrationBinding.ExpectedStoreProductIdentifier, &migrationBinding.ExpectedMosaicProductID, &migrationBinding.ExpectedStoreEnvironment) + + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("lock migration validation binding: %w", err) + } + if err == nil && attempt.Outcome != billing.OutcomeRetryableFailure { + status := billing.MigrationValidationQuarantined + if attempt.Outcome == billing.OutcomeValidated && outcome.Fact != nil { + status = billing.MigrationValidationValidated + } + evidenceDigest := billing.MigrationValidationEvidenceDigest(migrationBinding, attempt) + if _, err := tx.Exec(ctx, `UPDATE billing_migration_validation_bindings SET status=$2,diagnostic_code=NULLIF($3,''), + validation_attempt_id=$4,evidence_digest=$5,provider_watermark=$6,completed_at=$6 WHERE id=$1 AND status='accepted'`, + migrationBinding.ID, status, attempt.DiagnosticCode, attempt.ID, evidenceDigest, attempt.CompletedAt); err != nil { + return fmt.Errorf("complete migration validation binding: %w", err) + } + } + if write := outcome.Quarantine; write != nil { if err := upsertQuarantine(ctx, tx, attempt.ProjectID, attempt.EnvironmentID, *write); err != nil { return err @@ -310,8 +461,9 @@ func (r *Repository) CompleteAttempt(ctx context.Context, job billing.Validation // purchase (defect D-1). Both writes are deterministic functions of the // fact's own chain digest, decide nothing, and must be exactly as durable as // the fact, because the trigger they enable is written here too. + var lineage materializedLineage if factRecorded && outcome.Fact != nil { - lineage, err := materializeLineage(ctx, tx, *outcome.Fact, now) + lineage, err = materializeLineage(ctx, tx, *outcome.Fact, now) if err != nil { return err } @@ -320,6 +472,22 @@ func (r *Repository) CompleteAttempt(ctx context.Context, job billing.Validation } } + // Every fact-producing attempt gets its own durable identity job, including + // a revalidation whose fact was deduplicated. The latter can carry new + // correlator or submission evidence even though fact identity is unchanged. + if outcome.Fact != nil && len(outcome.Fact.PurchaseChainDigest) > 0 { + if len(lineage.RootDigest) == 0 { + lineage.RootDigest, err = chainRootDigest(ctx, tx, *outcome.Fact) + if err != nil { + return err + } + } + if err := enqueueIdentityBinding(ctx, tx, attempt.ID, *outcome.Fact, outcome, + lineage.RootDigest, now); err != nil { + return err + } + } + status := outcome.JobStatus if status == "" { status = "completed" @@ -342,6 +510,40 @@ func (r *Repository) CompleteAttempt(ctx context.Context, job billing.Validation return nil } +func enqueueIdentityBinding(ctx context.Context, tx pgx.Tx, attemptID string, + fact billing.TransactionFact, outcome billing.AttemptOutcome, rootDigest []byte, now time.Time) error { + + correlatorInput := outcome.Correlators + if correlatorInput == nil { + correlatorInput = []billing.AssociationCorrelator{} + } + referenceDigests := outcome.ReferenceDigests + if referenceDigests == nil { + referenceDigests = [][]byte{} + } + correlators, err := json.Marshal(correlatorInput) + if err != nil { + return fmt.Errorf("encode identity-binding correlators: %w", err) + } + acquiredAt := fact.OccurredAt + if fact.PeriodStartAt != nil && fact.PeriodStartAt.Before(acquiredAt) { + acquiredAt = *fact.PeriodStartAt + } + if _, err := tx.Exec(ctx, + `INSERT INTO billing_identity_binding_jobs( + id, project_id, environment_id, validation_attempt_id, raw_input_id, provider, + lineage_key_digest, fact_chain_digest, reference_digests, correlators, acquired_at, + status, attempt_count, max_attempts, available_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'queued',0,8,$12,$12,$12) + ON CONFLICT (validation_attempt_id) DO NOTHING`, + "bib_"+hashID(attemptID, "identity_binding", ""), fact.ProjectID, fact.EnvironmentID, + attemptID, fact.SourceRawInputID, fact.Provider, rootDigest, fact.PurchaseChainDigest, + referenceDigests, correlators, acquiredAt, now); err != nil { + return fmt.Errorf("enqueue identity binding: %w", err) + } + return nil +} + // materializeLineage creates the Purchase Lineage a newly recorded fact belongs // to, and the projection instance that lineage owns. // @@ -691,24 +893,3 @@ func (r *Repository) ExpireRawInputBodies(ctx context.Context, now time.Time, li } return tag.RowsAffected(), nil } - -// ChainRootDigest resolves the root of the purchase chain a fact belongs to. -// -// It is the same walk the fact-commit transaction performs, exposed as a read so -// the seam can name the lineage that transaction created without the transaction -// having to hand its identifiers back through the CompleteAttempt contract. -func (r *Repository) ChainRootDigest(ctx context.Context, fact billing.TransactionFact) ([]byte, error) { - if len(fact.PurchaseChainDigest) == 0 { - return nil, nil - } - tx, err := r.pool.Begin(ctx) - if err != nil { - return nil, fmt.Errorf("begin chain root read: %w", err) - } - defer func() { _ = tx.Rollback(ctx) }() - root, err := chainRootDigest(ctx, tx, fact) - if err != nil { - return nil, err - } - return root, tx.Commit(ctx) -} diff --git a/apps/api/internal/platform/billingpostgres/keyring.go b/apps/api/internal/platform/billingpostgres/keyring.go index 7934db8f..7ead3bb1 100644 --- a/apps/api/internal/platform/billingpostgres/keyring.go +++ b/apps/api/internal/platform/billingpostgres/keyring.go @@ -46,12 +46,14 @@ func (e BillingEnvelope) Scope() providercredential.SubjectScope { } } -// EnvelopeCountsByKeyID reports how many Phase 9A envelopes each key seals, +// EnvelopeCountsByKeyID reports how many billing envelopes each key seals, // across both tables. It never returns key material or ciphertext. func (r *Repository) EnvelopeCountsByKeyID(ctx context.Context) (map[string]int64, error) { counts := make(map[string]int64) rows, err := r.pool.Query(ctx, `SELECT key_id, count(*) FROM store_server_credentials WHERE revoked_at IS NULL GROUP BY key_id + UNION ALL + SELECT key_id, count(*) FROM billing_migration_credentials WHERE status='active' AND removed_at IS NULL AND nonce IS NOT NULL AND ciphertext IS NOT NULL GROUP BY key_id UNION ALL SELECT key_id, count(*) FROM billing_raw_inputs WHERE body_state = 'stored' AND key_id IS NOT NULL GROUP BY key_id UNION ALL @@ -110,6 +112,30 @@ func (r *Repository) EnvelopesNotUnderKey(ctx context.Context, keyID string, lim return envelopes, nil } + migrationRows, err := r.pool.Query(ctx, + `SELECT c.id,p.organization_id,c.project_id,c.envelope_version,c.algorithm,c.key_id,c.nonce,c.ciphertext,c.fingerprint + FROM billing_migration_credentials c JOIN projects p ON p.id=c.project_id + WHERE c.key_id<>$1 AND c.status='active' AND c.removed_at IS NULL AND c.nonce IS NOT NULL AND c.ciphertext IS NOT NULL + ORDER BY c.id LIMIT $2`, keyID, limit-len(envelopes)) + if err != nil { + return nil, fmt.Errorf("read billing migration credential envelopes: %w", err) + } + for migrationRows.Next() { + envelope := BillingEnvelope{Table: "billing_migration_credentials", SubjectKind: providercredential.SubjectBillingMigrationCredential, CredentialClass: "revenuecat_migration_api_key"} + if err := migrationRows.Scan(&envelope.RowID, &envelope.OrganizationID, &envelope.ProjectID, &envelope.Version, &envelope.Algorithm, &envelope.KeyID, &envelope.Nonce, &envelope.Ciphertext, &envelope.Fingerprint); err != nil { + migrationRows.Close() + return nil, fmt.Errorf("scan billing migration credential envelope: %w", err) + } + envelopes = append(envelopes, envelope) + } + migrationRows.Close() + if err := migrationRows.Err(); err != nil { + return nil, fmt.Errorf("read billing migration credential envelopes: %w", err) + } + if len(envelopes) >= limit { + return envelopes, nil + } + // Webhook signing secrets rank with credentials rather than with bodies: an // unrotatable signing secret means a destination's deliveries can no longer // be signed, which breaks every consumer of that tenant. @@ -204,7 +230,11 @@ func (r *Repository) ReplaceEnvelopes(ctx context.Context, envelopes []BillingEn statement = `UPDATE webhook_signing_secrets SET envelope_version=$2, algorithm=$3, key_id=$4, nonce=$5, ciphertext=$6, fingerprint=$7 - WHERE id=$1 AND $8 IS NOT NULL` + WHERE id=$1 AND $8::timestamptz IS NOT NULL` + case "billing_migration_credentials": + statement = `UPDATE billing_migration_credentials + SET envelope_version=$2,algorithm=$3,key_id=$4,nonce=$5,ciphertext=$6,fingerprint=$7 + WHERE id=$1 AND removed_at IS NULL AND nonce IS NOT NULL AND ciphertext IS NOT NULL AND $8::timestamptz IS NOT NULL` default: return fmt.Errorf("unsupported billing envelope table %q", envelope.Table) } diff --git a/apps/api/internal/platform/billingpostgres/migration_import.go b/apps/api/internal/platform/billingpostgres/migration_import.go new file mode 100644 index 00000000..dc86aa79 --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/migration_import.go @@ -0,0 +1,126 @@ +package billingpostgres + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" +) + +// PersistMigrationInput atomically creates a raw input, its immutable migration +// expectation, and the ordinary validation job. A crash can therefore leave +// all three durable or none of them. +func (r *Repository) PersistMigrationInput(ctx context.Context, input billing.RawInput, binding billing.MigrationValidationBinding, now time.Time) (billing.MigrationValidationAcceptance, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billing.MigrationValidationAcceptance{}, fmt.Errorf("begin migration validation intake: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if input.ID == "" { + input.ID = "bri_" + hashID(input.ProjectID, string(input.IdempotencyKey), "migration") + } + binding.RawInputID = input.ID + + var existing billing.MigrationValidationBinding + err = tx.QueryRow(ctx, `SELECT id,raw_input_id,status,reference_digest,expected_application_id,expected_store_product_identifier,expected_mosaic_product_id,expected_store_environment + FROM billing_migration_validation_bindings WHERE program_id=$1 AND provider=$2 AND reference_kind=$3 AND reference_digest=$4`, + binding.ProgramID, binding.Provider, binding.ReferenceKind, binding.ReferenceDigest). + Scan(&existing.ID, &existing.RawInputID, &existing.Status, &existing.ReferenceDigest, &existing.ExpectedApplicationID, + &existing.ExpectedStoreProductIdentifier, &existing.ExpectedMosaicProductID, &existing.ExpectedStoreEnvironment) + if err == nil { + if subtle.ConstantTimeCompare(existing.ReferenceDigest, binding.ReferenceDigest) != 1 || + existing.ExpectedApplicationID != binding.ExpectedApplicationID || + existing.ExpectedStoreProductIdentifier != binding.ExpectedStoreProductIdentifier || + existing.ExpectedMosaicProductID != binding.ExpectedMosaicProductID || existing.ExpectedStoreEnvironment != binding.ExpectedStoreEnvironment { + return billing.MigrationValidationAcceptance{}, billing.ErrConflict + } + return billing.MigrationValidationAcceptance{BindingID: existing.ID, RawInputID: existing.RawInputID, Status: existing.Status}, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return billing.MigrationValidationAcceptance{}, fmt.Errorf("read migration validation binding: %w", err) + } + + var envelopeVersion *int + var algorithm, keyID *string + var nonce, ciphertext, fingerprint []byte + if input.Envelope != nil && input.BodyState == "stored" { + envelopeVersion = &input.Envelope.Version + algorithm = &input.Envelope.Algorithm + keyID = &input.Envelope.KeyID + nonce, ciphertext, fingerprint = input.Envelope.Nonce, input.Envelope.Ciphertext, input.Envelope.Fingerprint + } + if input.BodyState == "" { + input.BodyState = "not_retained" + } + _, err = tx.Exec(ctx, `INSERT INTO billing_raw_inputs( + id,project_id,organization_id,environment_id,environment_mode,application_id,credential_id, + provider,source,source_authority,provider_event_id,idempotency_key,content_digest,transaction_reference_digest, + body_state,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,authentication_result,store_environment, + notification_kind,notification_subtype,ingestion_status,correlation_id,provider_occurred_at,received_at,expires_at) + VALUES($1,$2,$3,$4,$5,$6,NULL,$7,$8,$9,NULL,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,NULL,NULL,$22,$23,NULL,$24,$25)`, + input.ID, input.ProjectID, input.OrganizationID, input.EnvironmentID, input.EnvironmentMode, input.ApplicationID, + input.Provider, input.Source, input.SourceAuthority, input.IdempotencyKey, input.ContentDigest, input.TransactionReferenceDigest, + input.BodyState, envelopeVersion, algorithm, keyID, nonce, ciphertext, fingerprint, input.AuthenticationResult, + input.StoreEnvironment, input.IngestionStatus, input.CorrelationID, input.ReceivedAt, input.ExpiresAt) + if err != nil { + return billing.MigrationValidationAcceptance{}, fmt.Errorf("insert migration raw input: %w", err) + } + if err := insertLedger(ctx, tx, billing.LedgerEntry{ID: "ble_" + hashID(input.ID, "received", now), ProjectID: input.ProjectID, + EnvironmentID: input.EnvironmentID, EntryType: billing.LedgerInputReceived, RawInputID: input.ID, + CorrelationID: input.CorrelationID, OccurredAt: now}); err != nil { + return billing.MigrationValidationAcceptance{}, err + } + _, err = tx.Exec(ctx, `INSERT INTO billing_validation_jobs(id,project_id,environment_id,raw_input_id,provider,status,attempt_count,max_attempts,available_at,created_at,updated_at) + VALUES($1,$2,$3,$4,$5,'queued',0,$6,$7,$7,$7)`, "bvj_"+hashID(input.ID, "job", now), input.ProjectID, input.EnvironmentID, input.ID, input.Provider, billing.MaxValidationAttempts, now) + if err != nil { + return billing.MigrationValidationAcceptance{}, fmt.Errorf("enqueue migration validation: %w", err) + } + _, err = tx.Exec(ctx, `INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_mosaic_product_id,expected_store_environment,status,accepted_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'accepted',$13)`, binding.ID, binding.ProgramID, binding.ProjectID, binding.EnvironmentID, input.ID, binding.Provider, binding.ReferenceKind, binding.ReferenceDigest, binding.ExpectedApplicationID, binding.ExpectedStoreProductIdentifier, binding.ExpectedMosaicProductID, binding.ExpectedStoreEnvironment, now) + if err != nil { + return billing.MigrationValidationAcceptance{}, fmt.Errorf("insert migration validation binding: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return billing.MigrationValidationAcceptance{}, fmt.Errorf("commit migration validation intake: %w", err) + } + return billing.MigrationValidationAcceptance{BindingID: binding.ID, RawInputID: input.ID, Status: billing.MigrationValidationAccepted}, nil +} + +func (r *Repository) MigrationValidationOutcome(ctx context.Context, projectID, programID, bindingID string) (billing.MigrationValidationBinding, error) { + var value billing.MigrationValidationBinding + var diagnostic, attempt *string + var evidence []byte + var watermark, completed *time.Time + err := r.pool.QueryRow(ctx, `SELECT id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest, + expected_application_id,expected_store_product_identifier,expected_mosaic_product_id,expected_store_environment,status,diagnostic_code, + validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at + FROM billing_migration_validation_bindings WHERE id=$1 AND project_id=$2 AND program_id=$3`, bindingID, projectID, programID). + Scan(&value.ID, &value.ProgramID, &value.ProjectID, &value.EnvironmentID, &value.RawInputID, &value.Provider, &value.ReferenceKind, &value.ReferenceDigest, + &value.ExpectedApplicationID, &value.ExpectedStoreProductIdentifier, &value.ExpectedMosaicProductID, &value.ExpectedStoreEnvironment, &value.Status, &diagnostic, + &attempt, &evidence, &watermark, &value.AcceptedAt, &completed) + if errors.Is(err, pgx.ErrNoRows) { + return billing.MigrationValidationBinding{}, billing.ErrNotFound + } + if err != nil { + return billing.MigrationValidationBinding{}, fmt.Errorf("read migration validation outcome: %w", err) + } + if diagnostic != nil { + value.DiagnosticCode = *diagnostic + } + if attempt != nil { + value.ValidationAttemptID = *attempt + } + value.EvidenceDigest = evidence + if watermark != nil { + value.ProviderWatermark = *watermark + } + if completed != nil { + value.CompletedAt = *completed + } + return value, nil +} diff --git a/apps/api/internal/platform/billingpostgres/migration_import_integration_test.go b/apps/api/internal/platform/billingpostgres/migration_import_integration_test.go new file mode 100644 index 00000000..ae1b29eb --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/migration_import_integration_test.go @@ -0,0 +1,162 @@ +package billingpostgres + +import ( + "database/sql" + "encoding/json" + "os" + "strings" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +// This integration test protects the crash boundary: acceptance must persist +// the Raw Input, immutable expectation, and validation job atomically, and a +// replay after process loss must reuse them without claiming a Fact exists. +func TestMigrationValidationAcceptanceIsAtomicAndIdempotent(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + suffix := "migration_binding" + projectID, environmentID, applicationID := seed(t, ctx, pool, suffix) + now := time.Now().UTC() + productID, credentialID, programID := "product_"+suffix, "migration_credential_"+suffix, "migration_program_"+suffix + if _, err := pool.Exec(ctx, `INSERT INTO products(id,project_id,key,internal_name,type,status,metadata_source,readiness_ready,created_at,updated_at) + VALUES($1,$2,$3,'Migration Product','subscription','connected','provider',true,$4,$4)`, productID, projectID, "migration-product", now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) + VALUES($1,$2,'revenuecat','rc-project','active',1,'AES-256-GCM','test-key',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),'actor',$3)`, credentialID, projectID, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) + VALUES($1,$2,$3,'revenuecat','test',$4,'importing',1,decode(repeat('11',32),'hex'),decode(repeat('12',32),'hex'),'program-key',decode(repeat('13',32),'hex'),'actor',$5,$5)`, programID, projectID, environmentID, credentialID, now); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `ALTER TABLE billing_migration_validation_bindings DISABLE TRIGGER billing_migration_validation_bindings_protected`) + _, _ = pool.Exec(ctx, `DELETE FROM billing_migration_validation_bindings WHERE project_id=$1`, projectID) + _, _ = pool.Exec(ctx, `ALTER TABLE billing_migration_validation_bindings ENABLE TRIGGER billing_migration_validation_bindings_protected`) + _, _ = pool.Exec(ctx, `DELETE FROM billing_migration_programs WHERE id=$1`, programID) + _, _ = pool.Exec(ctx, `DELETE FROM billing_migration_credentials WHERE id=$1`, credentialID) + _, _ = pool.Exec(ctx, `DELETE FROM products WHERE id=$1`, productID) + }) + + referenceDigest := billing.AppleTransactionKey(billing.StoreUnclassified, "2000000001") + input := billing.RawInput{ID: "raw_" + suffix, ProjectID: projectID, EnvironmentID: environmentID, EnvironmentMode: "production", OrganizationID: "org_billing_" + suffix, + ApplicationID: applicationID, Provider: billing.ProviderAppStore, Source: billing.SourceMigrationKnownReference, SourceAuthority: billing.AuthorityStoreReconciliation, + IdempotencyKey: bytes32ForMigrationTest(1), ContentDigest: bytes32ForMigrationTest(2), TransactionReferenceDigest: referenceDigest, BodyState: "not_retained", + AuthenticationResult: billing.AuthVerifiedTransport, StoreEnvironment: billing.StoreUnclassified, IngestionStatus: billing.IngestAccepted, CorrelationID: "binding_" + suffix, ReceivedAt: now, ExpiresAt: now.Add(24 * time.Hour)} + binding := billing.MigrationValidationBinding{ID: "binding_" + suffix, ProgramID: programID, ProjectID: projectID, EnvironmentID: environmentID, RawInputID: input.ID, + Provider: billing.ProviderAppStore, ReferenceKind: billing.ReferenceAppStoreTransactionID, ReferenceDigest: referenceDigest, ExpectedApplicationID: applicationID, + ExpectedStoreProductIdentifier: "com.example.monthly", ExpectedMosaicProductID: productID, ExpectedStoreEnvironment: billing.StoreProduction, Status: billing.MigrationValidationAccepted, AcceptedAt: now} + first, err := repository.PersistMigrationInput(ctx, input, binding, now) + if err != nil { + t.Fatal(err) + } + binding.ID = "binding_replayed_should_not_be_used" + second, err := repository.PersistMigrationInput(ctx, input, binding, now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if first.BindingID != second.BindingID || first.RawInputID != second.RawInputID || second.Status != billing.MigrationValidationAccepted { + t.Fatalf("first=%+v second=%+v", first, second) + } + var rawCount, jobCount, bindingCount, factCount int + if err = pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM billing_raw_inputs WHERE id=$1),(SELECT count(*) FROM billing_validation_jobs WHERE raw_input_id=$1),(SELECT count(*) FROM billing_migration_validation_bindings WHERE raw_input_id=$1),(SELECT count(*) FROM billing_transaction_facts WHERE source_raw_input_id=$1)`, input.ID).Scan(&rawCount, &jobCount, &bindingCount, &factCount); err != nil { + t.Fatal(err) + } + if rawCount != 1 || jobCount != 1 || bindingCount != 1 || factCount != 0 { + t.Fatalf("raw=%d job=%d binding=%d facts=%d", rawCount, jobCount, bindingCount, factCount) + } + db, err := sql.Open("pgx", os.Getenv("DATABASE_TEST_URL")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + err = goose.DownToContext(ctx, db, ".", 58) + if err == nil || !strings.Contains(err.Error(), "immutable migration validation evidence exists") { + t.Fatalf("populated migration59 down guard error=%v", err) + } + if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_validation_bindings WHERE raw_input_id=$1`, input.ID).Scan(&bindingCount); err != nil || bindingCount != 1 { + t.Fatalf("down guard lost binding count=%d err=%v", bindingCount, err) + } +} + +// This protects the full Package E boundary rather than either half in +// isolation: an ordinary worker must terminally quarantine provider evidence +// that disagrees with the immutable migration expectation, and its transaction +// must not append a Fact while completing the binding. +func TestMigrationValidationMismatchQuarantinesBindingWithoutFact(t *testing.T) { + pool, ctx := testPool(t) + fixture := newRevalidationFixture(t, ctx, pool, "migration_mismatch") + now := time.Now().UTC() + credentialID, programID := "migration_credential_mismatch", "migration_program_mismatch" + var productID string + if err := pool.QueryRow(ctx, `SELECT product_id FROM provider_product_mappings WHERE project_id=$1 AND application_id=$2 AND provider='google_play'`, fixture.projectID, fixture.applicationID).Scan(&productID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) VALUES($1,$2,'revenuecat','rc-project','active',1,'AES-256-GCM','test-key',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),'actor',$3)`, credentialID, fixture.projectID, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) VALUES($1,$2,$3,'revenuecat','test',$4,'importing',1,decode(repeat('11',32),'hex'),decode(repeat('12',32),'hex'),'program-key',decode(repeat('13',32),'hex'),'actor',$5,$5)`, programID, fixture.projectID, fixture.environmentID, credentialID, now); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `ALTER TABLE billing_migration_validation_bindings DISABLE TRIGGER billing_migration_validation_bindings_protected`) + _, _ = pool.Exec(ctx, `DELETE FROM billing_migration_validation_bindings WHERE project_id=$1`, fixture.projectID) + _, _ = pool.Exec(ctx, `ALTER TABLE billing_migration_validation_bindings ENABLE TRIGGER billing_migration_validation_bindings_protected`) + _, _ = pool.Exec(ctx, `DELETE FROM billing_migration_programs WHERE id=$1`, programID) + _, _ = pool.Exec(ctx, `DELETE FROM billing_migration_credentials WHERE id=$1`, credentialID) + }) + + const orderID = "GPA.MIGRATION-MISMATCH" + const purchaseToken = "provider-returned-token-never-persisted-in-import-batch" + var order googleplay.Order + if err := json.Unmarshal([]byte(`{"orderId":"`+orderID+`","purchaseToken":"`+purchaseToken+`","state":"PROCESSED","lineItems":[{"productId":"`+fixtureProviderProduct+`"}]}`), &order); err != nil { + t.Fatal(err) + } + fixture.google.orders[orderID] = order + fixture.google.subscriptions[purchaseToken] = subscriptionPurchase( + fixtureProviderProduct, orderID, time.UnixMilli(1767225600000).UTC(), time.UnixMilli(1769904000000).UTC()) + + accepted, err := fixture.service.AcceptMigrationValidation(ctx, billing.MigrationValidationRequest{ + ProgramID: programID, ProjectID: fixture.projectID, EnvironmentID: fixture.environmentID, + ApplicationID: fixture.applicationID, Provider: billing.ProviderGooglePlay, + ReferenceKind: billing.ReferenceGooglePlayOrderID, Reference: orderID, + ExpectedStoreProductIdentifier: "different.store.product", ExpectedMosaicProductID: productID, + ExpectedStoreEnvironment: billing.StoreProduction, + }) + if err != nil { + t.Fatalf("accept migration validation: %v", err) + } + processed, err := fixture.service.ProcessNextValidation(ctx, "migration-worker") + if err != nil || !processed { + t.Fatalf("processed=%v err=%v", processed, err) + } + var status, diagnostic string + var factCount int + if err = pool.QueryRow(ctx, `SELECT b.status,coalesce(b.diagnostic_code,''),(SELECT count(*) FROM billing_transaction_facts f WHERE f.source_raw_input_id=b.raw_input_id) FROM billing_migration_validation_bindings b WHERE b.id=$1`, accepted.BindingID).Scan(&status, &diagnostic, &factCount); err != nil { + t.Fatal(err) + } + if status != billing.MigrationValidationQuarantined || diagnostic != billing.DiagnosticMigrationProviderProductMismatch || factCount != 0 { + t.Fatalf("status=%q diagnostic=%q facts=%d", status, diagnostic, factCount) + } +} + +func bytes32ForMigrationTest(value byte) []byte { + result := make([]byte, 32) + for i := range result { + result[i] = value + } + return result +} diff --git a/apps/api/internal/platform/billingpostgres/queue_metrics.go b/apps/api/internal/platform/billingpostgres/queue_metrics.go index bfa833c4..dfc4c5ff 100644 --- a/apps/api/internal/platform/billingpostgres/queue_metrics.go +++ b/apps/api/internal/platform/billingpostgres/queue_metrics.go @@ -12,12 +12,13 @@ import ( const queueMetricTimeout = 5 * time.Second -// billingQueues maps a metric queue label to its backing table. All three share +// billingQueues maps a metric queue label to its backing table. All four share // the status/available_at/created_at job shape the rest of Mosaic uses. var billingQueues = map[string]string{ - "validation": "billing_validation_jobs", - "reconciliation": "billing_reconciliation_runs", - "replay": "billing_replay_jobs", + "validation": "billing_validation_jobs", + "identity_binding": "billing_identity_binding_jobs", + "reconciliation": "billing_reconciliation_runs", + "replay": "billing_replay_jobs", } // RegisterQueueMetrics publishes backlog depth, oldest-job age, and dead-letter diff --git a/apps/api/internal/platform/billingpostgres/repository.go b/apps/api/internal/platform/billingpostgres/repository.go index 92365257..9011df37 100644 --- a/apps/api/internal/platform/billingpostgres/repository.go +++ b/apps/api/internal/platform/billingpostgres/repository.go @@ -747,22 +747,28 @@ func quarantineReasonForIntake(input billing.RawInput) string { func (r *Repository) RawInput(ctx context.Context, projectID, rawInputID string) (billing.RawInput, error) { var input billing.RawInput var applicationID, credentialID, providerEventID, notificationKind, notificationSubtype *string + var migrationID, migrationProgramID, migrationReferenceKind, expectedApplicationID, expectedStoreProductID, expectedMosaicProductID, expectedStoreEnvironment *string var envelopeVersion *int var algorithm, keyID *string var nonce, ciphertext, fingerprint, referenceDigest []byte err := r.pool.QueryRow(ctx, - `SELECT id, project_id, organization_id, environment_id, environment_mode, application_id, credential_id, - provider, source, source_authority, provider_event_id, idempotency_key, content_digest, - transaction_reference_digest, body_state, envelope_version, algorithm, key_id, nonce, ciphertext, - fingerprint, authentication_result, store_environment, notification_kind, notification_subtype, - ingestion_status, correlation_id, provider_occurred_at, received_at, expires_at - FROM billing_raw_inputs WHERE id=$1 AND project_id=$2`, rawInputID, projectID). + `SELECT i.id, i.project_id, i.organization_id, i.environment_id, i.environment_mode, i.application_id, i.credential_id, + i.provider, i.source, i.source_authority, i.provider_event_id, i.idempotency_key, i.content_digest, + i.transaction_reference_digest, i.body_state, i.envelope_version, i.algorithm, i.key_id, i.nonce, i.ciphertext, + i.fingerprint, i.authentication_result, i.store_environment, i.notification_kind, i.notification_subtype, + i.ingestion_status, i.correlation_id, i.provider_occurred_at, i.received_at, i.expires_at, + mv.id,mv.program_id,mv.reference_kind,mv.expected_application_id, + mv.expected_store_product_identifier,mv.expected_mosaic_product_id + ,mv.expected_store_environment + FROM billing_raw_inputs i LEFT JOIN billing_migration_validation_bindings mv ON mv.raw_input_id=i.id + WHERE i.id=$1 AND i.project_id=$2`, rawInputID, projectID). Scan(&input.ID, &input.ProjectID, &input.OrganizationID, &input.EnvironmentID, &input.EnvironmentMode, &applicationID, &credentialID, &input.Provider, &input.Source, &input.SourceAuthority, &providerEventID, &input.IdempotencyKey, &input.ContentDigest, &referenceDigest, &input.BodyState, &envelopeVersion, &algorithm, &keyID, &nonce, &ciphertext, &fingerprint, &input.AuthenticationResult, &input.StoreEnvironment, ¬ificationKind, ¬ificationSubtype, - &input.IngestionStatus, &input.CorrelationID, &input.ProviderOccurredAt, &input.ReceivedAt, &input.ExpiresAt) + &input.IngestionStatus, &input.CorrelationID, &input.ProviderOccurredAt, &input.ReceivedAt, &input.ExpiresAt, + &migrationID, &migrationProgramID, &migrationReferenceKind, &expectedApplicationID, &expectedStoreProductID, &expectedMosaicProductID, &expectedStoreEnvironment) if errors.Is(err, pgx.ErrNoRows) { return billing.RawInput{}, billing.ErrNotFound } @@ -775,6 +781,12 @@ func (r *Repository) RawInput(ctx context.Context, projectID, rawInputID string) input.NotificationKind = deref(notificationKind) input.NotificationSubtype = deref(notificationSubtype) input.TransactionReferenceDigest = referenceDigest + if migrationID != nil { + input.MigrationValidation = &billing.MigrationValidationBinding{ID: *migrationID, ProgramID: deref(migrationProgramID), + ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, RawInputID: input.ID, Provider: input.Provider, + ReferenceKind: deref(migrationReferenceKind), ExpectedApplicationID: deref(expectedApplicationID), + ExpectedStoreProductIdentifier: deref(expectedStoreProductID), ExpectedMosaicProductID: deref(expectedMosaicProductID), ExpectedStoreEnvironment: deref(expectedStoreEnvironment)} + } if envelopeVersion != nil && algorithm != nil && keyID != nil { input.Envelope = &billing.Envelope{ Version: *envelopeVersion, Algorithm: *algorithm, KeyID: *keyID, diff --git a/apps/api/internal/platform/billingprojectionpostgres/projection_integration_test.go b/apps/api/internal/platform/billingprojectionpostgres/projection_integration_test.go index abd4fe8c..919ce376 100644 --- a/apps/api/internal/platform/billingprojectionpostgres/projection_integration_test.go +++ b/apps/api/internal/platform/billingprojectionpostgres/projection_integration_test.go @@ -1,9 +1,11 @@ package billingprojectionpostgres import ( + "bytes" "context" "database/sql" "os" + "strconv" "testing" "time" @@ -183,6 +185,40 @@ func TestCustomerSnapshotsAreAppendOnly(t *testing.T) { } } +// Migration candidates consume the monotonic snapshot sequence so a future +// live projection cannot collide with them, but current/prior selection remains +// exclusively pointer-addressed. A highest-version candidate must never become +// the live projection's prior snapshot merely because it is newest. +func TestCandidateSnapshotReservesVersionWithoutBecomingCurrentOrPrior(t *testing.T) { + pool, ctx := testPool(t) + projectID, environmentID, customerID := seed(t, ctx, pool, "candidate_sequence") + now := time.Now().UTC() + liveChecksum := bytes.Repeat([]byte{0x41}, 32) + candidateChecksum := bytes.Repeat([]byte{0x51}, 32) + if _, err := pool.Exec(ctx, `INSERT INTO customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id,snapshot_version,rule_version,computed_at,as_of,checksum,change_reason,created_at) VALUES + ('ces_live',$1,$2,$3,1,1,$4,$4,$5,'entitlements_changed',$4), + ('ces_candidate',$1,$2,$3,5,1,$4,$4,$6,'migration_candidate',$4)`, projectID, environmentID, customerID, now, liveChecksum, candidateChecksum); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO customer_entitlement_pointers(project_id,environment_id,billing_customer_id,current_snapshot_id,snapshot_version,updated_at) VALUES($1,$2,$3,'ces_live',1,$4)`, projectID, environmentID, customerID, now); err != nil { + t.Fatal(err) + } + input, err := New(pool).LoadInput(ctx, scopeFor(projectID, environmentID, customerID)) + if err != nil { + t.Fatal(err) + } + if input.CurrentSnapshotVersion != 5 { + t.Fatalf("next live allocation did not reserve past candidate: %d", input.CurrentSnapshotVersion) + } + if input.PriorCustomerSnapshot == nil || !bytes.Equal(input.PriorCustomerSnapshot.Checksum, liveChecksum) { + t.Fatal("newest candidate was selected instead of pointer-addressed live prior") + } + var current string + if err = pool.QueryRow(ctx, `SELECT current_snapshot_id FROM customer_entitlement_pointers WHERE billing_customer_id=$1 AND environment_id=$2`, customerID, environmentID).Scan(¤t); err != nil || current != "ces_live" { + t.Fatalf("candidate changed live pointer current=%q err=%v", current, err) + } +} + // The current pointer is one per (customer, environment) — OD-3(b). A second // pointer for the same pair would mean two answers to "what does this customer // have right now", and an SDK would see whichever it read first. @@ -235,6 +271,80 @@ func TestOnePointerPerCustomerPerEnvironment(t *testing.T) { } } +// A committed projection advances every exact Mosaic-authority Application +// scope and no source-owned scope. The authority epoch is copied from the row +// while it is locked, preventing a projection racing cutover/rollback from +// publishing a pointer under the wrong epoch. +func TestScopedPointersAdvanceOnlyForMosaicAuthority(t *testing.T) { + pool, ctx := testPool(t) + projectID, environmentID, customerID := seed(t, ctx, pool, "scoped_pointer") + now := time.Now().UTC() + applications := []struct { + id, platform, authority string + epoch int64 + }{ + {"app_scope_mosaic_ios", "ios", "mosaic", 5}, + {"app_scope_source_android", "android", "source", 4}, + {"app_scope_rollback_ios", "ios", "source_rollback", 6}, + } + for _, app := range applications { + if _, err := pool.Exec(ctx, `INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES($1,$2,$1,$3,$1,$4,$4)`, app.id, projectID, app.platform, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_authority_scopes( + id,project_id,environment_id,application_id,platform,current_authority,current_epoch,authority_digest,updated_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, "mas_"+app.id, projectID, environmentID, + app.id, app.platform, app.authority, app.epoch, make([]byte, 32), now); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM billing_migration_scope_current_pointers WHERE project_id=$1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM billing_migration_authority_scopes WHERE project_id=$1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM applications WHERE project_id=$1`, projectID) + }) + + checksum := make([]byte, 32) + if _, err := pool.Exec(ctx, `INSERT INTO customer_entitlement_snapshots( + id,project_id,environment_id,billing_customer_id,snapshot_version,rule_version,computed_at,as_of,checksum,change_reason,created_at) + VALUES('ces_scoped_pointer',$1,$2,$3,1,1,$4,$4,$5,'entitlements_changed',$4)`, + projectID, environmentID, customerID, now, checksum); err != nil { + t.Fatal(err) + } + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + if err := advanceScopedMosaicPointers(ctx, tx, scopeFor(projectID, environmentID, customerID), "ces_scoped_pointer", now); err != nil { + _ = tx.Rollback(ctx) + t.Fatal(err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + + rows, err := pool.Query(ctx, `SELECT application_id,authority_epoch FROM billing_migration_scope_current_pointers + WHERE project_id=$1 AND billing_customer_id=$2 ORDER BY application_id`, projectID, customerID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var got []string + for rows.Next() { + var app string + var epoch int64 + if err := rows.Scan(&app, &epoch); err != nil { + t.Fatal(err) + } + got = append(got, app+":"+strconv.FormatInt(epoch, 10)) + } + if len(got) != 1 || got[0] != "app_scope_mosaic_ios:5" { + t.Fatalf("scoped pointers %v, want only Mosaic scope at epoch 5", got) + } +} + // Projection jobs coalesce onto the scope key. Without the partial unique // index, a burst of validated facts for one customer would queue one job per // fact and the same projection would run dozens of times. diff --git a/apps/api/internal/platform/billingprojectionpostgres/repository.go b/apps/api/internal/platform/billingprojectionpostgres/repository.go index ad72faa1..d1bae3cc 100644 --- a/apps/api/internal/platform/billingprojectionpostgres/repository.go +++ b/apps/api/internal/platform/billingprojectionpostgres/repository.go @@ -108,11 +108,22 @@ func loadCustomerSnapshot(ctx context.Context, tx pgx.Tx, scope billingprojectio WHERE billing_customer_id=$1 AND environment_id=$2`, scope.CustomerID, scope.EnvironmentID).Scan(&snapshotID, &input.CurrentSnapshotVersion) if errors.Is(err, pgx.ErrNoRows) { - return nil + // Migration evaluation may have appended immutable, non-live candidates. + // They share the snapshot sequence because prepared cutover pointers must + // satisfy the ordinary snapshot FK. Reserve past them so a later live + // projection cannot collide with a candidate version. + return tx.QueryRow(ctx, `SELECT COALESCE(max(snapshot_version),0) FROM customer_entitlement_snapshots + WHERE billing_customer_id=$1 AND environment_id=$2`, scope.CustomerID, scope.EnvironmentID). + Scan(&input.CurrentSnapshotVersion) } if err != nil { return fmt.Errorf("read customer entitlement pointer: %w", err) } + if err := tx.QueryRow(ctx, `SELECT COALESCE(max(snapshot_version),0) FROM customer_entitlement_snapshots + WHERE billing_customer_id=$1 AND environment_id=$2`, scope.CustomerID, scope.EnvironmentID). + Scan(&input.CurrentSnapshotVersion); err != nil { + return fmt.Errorf("read customer snapshot sequence: %w", err) + } snapshot := billingprojection.CustomerSnapshot{} rows, err := tx.Query(ctx, @@ -668,6 +679,15 @@ func writeCustomerSnapshot(ctx context.Context, tx pgx.Tx, scope billingprojecti return fmt.Errorf("update customer entitlement pointer: %w", err) } + // Authority rows are the sole selector for scoped serving. Lock every exact + // Mosaic-authority scope in canonical order, then advance its pointer in the + // same transaction as the immutable snapshot and legacy pointer. Source and + // source_rollback scopes are deliberately absent from both the lock set and + // the writes; projection can never switch or union authority. + if err := advanceScopedMosaicPointers(ctx, tx, scope, snapshotID, now); err != nil { + return err + } + // The webhook event is created here, inside the same transaction as the // state it announces, so an event can never exist for state that was not // committed. Delivery happens elsewhere, outside this transaction. @@ -696,6 +716,52 @@ func writeCustomerSnapshot(ctx context.Context, tx pgx.Tx, scope billingprojecti return nil } +func advanceScopedMosaicPointers(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, + snapshotID string, now time.Time) error { + rows, err := tx.Query(ctx, + `SELECT application_id, platform, current_epoch + FROM billing_migration_authority_scopes + WHERE project_id=$1 AND environment_id=$2 AND current_authority='mosaic' + ORDER BY application_id, platform + FOR UPDATE`, scope.ProjectID, scope.EnvironmentID) + if err != nil { + return fmt.Errorf("lock Mosaic authority scopes for projection: %w", err) + } + type pointer struct { + applicationID, platform string + epoch int64 + } + pointers := make([]pointer, 0, 2) + for rows.Next() { + var item pointer + if err := rows.Scan(&item.applicationID, &item.platform, &item.epoch); err != nil { + rows.Close() + return fmt.Errorf("scan Mosaic authority scope: %w", err) + } + pointers = append(pointers, item) + } + if err := rows.Err(); err != nil { + rows.Close() + return fmt.Errorf("read Mosaic authority scopes: %w", err) + } + rows.Close() + for _, item := range pointers { + if _, err := tx.Exec(ctx, + `INSERT INTO billing_migration_scope_current_pointers( + project_id,environment_id,application_id,platform,billing_customer_id, + current_snapshot_id,authority_epoch,updated_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT(project_id,environment_id,application_id,platform,billing_customer_id) + DO UPDATE SET current_snapshot_id=EXCLUDED.current_snapshot_id, + authority_epoch=EXCLUDED.authority_epoch,updated_at=EXCLUDED.updated_at`, + scope.ProjectID, scope.EnvironmentID, item.applicationID, item.platform, + scope.CustomerID, snapshotID, item.epoch, now); err != nil { + return fmt.Errorf("advance Mosaic scoped entitlement pointer: %w", err) + } + } + return nil +} + // billingStateEventEnvelope renders the complete Billing State Webhook Contract // v1 event record. // diff --git a/apps/api/internal/platform/billingwebhookpostgres/delivery_integration_test.go b/apps/api/internal/platform/billingwebhookpostgres/delivery_integration_test.go index 786ff8a1..8fa84989 100644 --- a/apps/api/internal/platform/billingwebhookpostgres/delivery_integration_test.go +++ b/apps/api/internal/platform/billingwebhookpostgres/delivery_integration_test.go @@ -4,16 +4,23 @@ import ( "bytes" "context" "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" "os" + "strings" "sync" "testing" "time" + "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" _ "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + billingwebhookhttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingwebhook" "github.com/Mujhtech/mosaic/apps/api/migrations" ) @@ -65,6 +72,9 @@ type fixture struct { destinationID string eventID string payload string + ownerActor string + adminActor string + memberActor string } func seedWebhookFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) fixture { @@ -76,6 +86,9 @@ func seedWebhookFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool, s destinationID: "whd_" + suffix, eventID: "whe_" + suffix, payload: `{"eventId": "whe_` + suffix + `", "eventType": "customer.entitlements.changed"}`, + ownerActor: "actor_whd_owner_" + suffix, + adminActor: "actor_whd_admin_" + suffix, + memberActor: "actor_whd_member_" + suffix, } organizationID := "org_whd_" + suffix customerID := "bcus_whd_" + suffix @@ -91,6 +104,9 @@ func seedWebhookFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool, s {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) VALUES ($1,$2,$3,'Webhook','active',$4,$4) ON CONFLICT (id) DO NOTHING`, []any{f.projectID, organizationID, "webhook-" + suffix, now}}, + {`INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) + VALUES ($1,$2,'owner',$5,$5),($1,$3,'admin',$5,$5),($1,$4,'member',$5,$5)`, + []any{organizationID, f.ownerActor, f.adminActor, f.memberActor, now}}, {`INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) VALUES ($1,$2,'production','Production','production',$3,$3) ON CONFLICT (id) DO NOTHING`, []any{f.environmentID, f.projectID, now}}, @@ -153,6 +169,7 @@ func cleanupWebhookFixture(ctx context.Context, pool *pgxpool.Pool, f fixture, o } { _, _ = pool.Exec(ctx, statement, f.projectID) } + _, _ = pool.Exec(ctx, `DELETE FROM organization_members WHERE organization_id=$1`, organizationID) _, _ = pool.Exec(ctx, `DELETE FROM organizations WHERE id=$1`, organizationID) for _, statement := range []string{ `ALTER TABLE webhook_events ENABLE TRIGGER webhook_events_append_only`, @@ -163,6 +180,143 @@ func cleanupWebhookFixture(ctx context.Context, pool *pgxpool.Pool, f fixture, o } } +type authorizationSurface struct { + handler http.Handler + actorID *string +} + +func newAuthorizationSurface(pool *pgxpool.Pool) *authorizationSurface { + service := billingwebhook.NewService(New(pool), nil, nil) + actorID := "" + router := chi.NewRouter() + router.Use(authn.Middleware(authn.ResolverFunc(func(*http.Request) (authn.Principal, error) { + if actorID == "" { + return authn.Principal{}, authn.ErrUnauthenticated + } + return authn.Principal{ActorID: actorID, Method: "browser_session"}, nil + }))) + router.Route("/v1/projects/{projectId}", func(project chi.Router) { + billingwebhookhttp.RegisterProjectRoutes(project, service) + project.Route("/environments/{environmentId}/billing", func(environment chi.Router) { + billingwebhookhttp.RegisterEnvironmentRoutes(environment, service) + }) + }) + return &authorizationSurface{handler: router, actorID: &actorID} +} + +func (s *authorizationSurface) as(actorID string) *authorizationSurface { + *s.actorID = actorID + return s +} + +func (s *authorizationSurface) do(t *testing.T, method, path, body string) (int, map[string]any) { + t.Helper() + request := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + request.Header.Set("Content-Type", "application/json") + } + recorder := httptest.NewRecorder() + s.handler.ServeHTTP(recorder, request) + payload := map[string]any{} + if recorder.Body.Len() > 0 { + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode %s %s response: %v (%s)", method, path, err, recorder.Body.String()) + } + } + return recorder.Code, payload +} + +// Billing webhook destinations and delivery history carry authoritative +// entitlement state. This integration test proves the shipped HTTP surface +// reaches the PostgreSQL membership boundary on every management family: an +// authenticated member cannot read or mutate them, a missing session is 401, +// and owner/admin sessions still reach the data. +func TestManagementSurfaceRequiresOwnerOrAdmin(t *testing.T) { + pool, ctx := testPool(t) + f := seedWebhookFixture(t, ctx, pool, "authz") + repository := New(pool) + if _, err := repository.FanOut(ctx, time.Now().UTC(), 10); err != nil { + t.Fatal(err) + } + var deliveryID string + if err := pool.QueryRow(ctx, + `SELECT id FROM webhook_deliveries WHERE project_id=$1 AND webhook_destination_id=$2`, + f.projectID, f.destinationID).Scan(&deliveryID); err != nil { + t.Fatal(err) + } + + surface := newAuthorizationSurface(pool) + base := "/v1/projects/" + f.projectID + environment := base + "/environments/" + f.environmentID + "/billing" + routes := []struct{ method, path, body string }{ + {http.MethodGet, environment + "/webhook-destinations/", ""}, + {http.MethodPost, environment + "/webhook-destinations/", `{"url":"https://receiver.example.com/second"}`}, + {http.MethodGet, base + "/billing/webhook-destinations/" + f.destinationID + "/", ""}, + {http.MethodPatch, base + "/billing/webhook-destinations/" + f.destinationID + "/", `{"description":"changed"}`}, + {http.MethodPost, base + "/billing/webhook-destinations/" + f.destinationID + "/status", `{"status":"paused"}`}, + {http.MethodDelete, base + "/billing/webhook-destinations/" + f.destinationID + "/", ""}, + {http.MethodGet, base + "/billing/webhook-destinations/" + f.destinationID + "/secrets", ""}, + {http.MethodPost, base + "/billing/webhook-destinations/" + f.destinationID + "/secrets/rotate", ""}, + {http.MethodPost, base + "/billing/webhook-destinations/" + f.destinationID + "/secrets/secret/retire", ""}, + {http.MethodGet, base + "/billing/webhook-deliveries/", ""}, + {http.MethodGet, base + "/billing/webhook-deliveries/" + deliveryID, ""}, + {http.MethodGet, base + "/billing/webhook-deliveries/" + deliveryID + "/attempts", ""}, + {http.MethodPost, base + "/billing/webhook-deliveries/" + deliveryID + "/replay", ""}, + } + for _, route := range routes { + status, payload := surface.as(f.memberActor).do(t, route.method, route.path, route.body) + if status != http.StatusForbidden { + t.Fatalf("%s %s as member: status %d payload %v, want 403", route.method, route.path, status, payload) + } + status, payload = surface.as("").do(t, route.method, route.path, route.body) + if status != http.StatusUnauthorized { + t.Fatalf("%s %s unauthenticated: status %d payload %v, want 401", route.method, route.path, status, payload) + } + } + + if status, _ := surface.as(f.ownerActor).do(t, http.MethodGet, + environment+"/webhook-destinations/", ""); status != http.StatusOK { + t.Fatalf("owner destination list: status %d, want 200", status) + } + if status, _ := surface.as(f.adminActor).do(t, http.MethodGet, + base+"/billing/webhook-deliveries/"+deliveryID, ""); status != http.StatusOK { + t.Fatalf("admin delivery read: status %d, want 200", status) + } +} + +// Cross-tenant failures are deliberately 404, not 403, and an Environment +// cannot be paired with a different Project. These checks catch both the +// existence-oracle regression and the environment-filter bug where a foreign +// Environment previously produced a misleading successful empty list. +func TestManagementSurfaceDoesNotCrossProjectOrEnvironment(t *testing.T) { + pool, ctx := testPool(t) + first := seedWebhookFixture(t, ctx, pool, "scope_a") + second := seedWebhookFixture(t, ctx, pool, "scope_b") + repository := New(pool) + if _, err := repository.FanOut(ctx, time.Now().UTC(), 20); err != nil { + t.Fatal(err) + } + var secondDeliveryID string + if err := pool.QueryRow(ctx, `SELECT id FROM webhook_deliveries WHERE project_id=$1`, second.projectID). + Scan(&secondDeliveryID); err != nil { + t.Fatal(err) + } + + surface := newAuthorizationSurface(pool).as(first.ownerActor) + checks := []string{ + "/v1/projects/" + second.projectID + "/billing/webhook-destinations/" + second.destinationID + "/", + "/v1/projects/" + second.projectID + "/billing/webhook-deliveries/" + secondDeliveryID, + "/v1/projects/" + first.projectID + "/environments/" + second.environmentID + "/billing/webhook-destinations/", + "/v1/projects/" + first.projectID + "/billing/webhook-deliveries/?environmentId=" + second.environmentID, + } + for _, path := range checks { + status, payload := surface.do(t, http.MethodGet, path, "") + if status != http.StatusNotFound { + t.Fatalf("cross-scope GET %s: status %d payload %v, want 404", path, status, payload) + } + } +} + func countRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool, query string, args ...any) int { t.Helper() var count int diff --git a/apps/api/internal/platform/billingwebhookpostgres/repository.go b/apps/api/internal/platform/billingwebhookpostgres/repository.go index 908cc5e4..2a647d17 100644 --- a/apps/api/internal/platform/billingwebhookpostgres/repository.go +++ b/apps/api/internal/platform/billingwebhookpostgres/repository.go @@ -32,17 +32,62 @@ func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } var _ billingwebhook.Repository = (*Repository)(nil) +// AuthorizeProject is the single operator authorization boundary for billing +// webhook management. Billing webhooks carry authoritative entitlement state, +// so they use the same owner/admin role bar as the billing ledger and customer +// operator surfaces. +func (r *Repository) AuthorizeProject(ctx context.Context, actor billingwebhook.Actor, projectID string) error { + actorID := strings.TrimSpace(actor.ID) + if actorID == "" || strings.TrimSpace(projectID) == "" { + return billingwebhook.ErrUnauthenticated + } + var role string + err := r.pool.QueryRow(ctx, + `SELECT m.role FROM projects p + JOIN organization_members m ON m.organization_id = p.organization_id + WHERE p.id = $1 AND m.actor_id = $2`, projectID, actorID).Scan(&role) + if errors.Is(err, pgx.ErrNoRows) { + return billingwebhook.ErrNotFound + } + if err != nil { + return fmt.Errorf("resolve billing webhook operator role: %w", err) + } + switch role { + case "owner", "admin": + return nil + default: + return billingwebhook.ErrForbidden + } +} + +func (r *Repository) AuthorizeEnvironment(ctx context.Context, actor billingwebhook.Actor, projectID, environmentID string) error { + if err := r.AuthorizeProject(ctx, actor, projectID); err != nil { + return err + } + var exists bool + err := r.pool.QueryRow(ctx, + `SELECT true FROM environments WHERE id = $1 AND project_id = $2`, environmentID, projectID). + Scan(&exists) + if errors.Is(err, pgx.ErrNoRows) { + return billingwebhook.ErrNotFound + } + if err != nil { + return fmt.Errorf("resolve billing webhook environment: %w", err) + } + return nil +} + // destinationColumns is the single projection every destination read uses, so // a column added to one read cannot be forgotten by another. const destinationColumns = `id, project_id, environment_id, url, status, event_types, description, - created_at, updated_at, secret_last_rotated_at, coalesce(disabled_reason, ''), + contract_version, created_at, updated_at, secret_last_rotated_at, coalesce(disabled_reason, ''), consecutive_failure_count, auto_disabled_at, coalesce(auto_disable_reason, '')` func scanDestination(row pgx.Row) (billingwebhook.Destination, error) { var destination billingwebhook.Destination err := row.Scan(&destination.ID, &destination.ProjectID, &destination.EnvironmentID, &destination.URL, &destination.Status, &destination.EventTypes, &destination.Description, - &destination.CreatedAt, &destination.UpdatedAt, &destination.SecretLastRotatedAt, + &destination.ContractVersion, &destination.CreatedAt, &destination.UpdatedAt, &destination.SecretLastRotatedAt, &destination.DisabledReason, &destination.ConsecutiveFailureCount, &destination.AutoDisabledAt, &destination.AutoDisableReason) return destination, err @@ -86,11 +131,11 @@ func (r *Repository) CreateDestination(ctx context.Context, destination billingw if _, err := tx.Exec(ctx, `INSERT INTO webhook_destinations( - id, project_id, environment_id, url, status, event_types, description, + id, project_id, environment_id, url, status, event_types, description, contract_version, created_at, updated_at, created_by_actor_id, secret_last_rotated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8,$9,$8)`, + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9,$10,$9)`, destination.ID, destination.ProjectID, destination.EnvironmentID, destination.URL, - destination.Status, destination.EventTypes, destination.Description, now, actorID); err != nil { + destination.Status, destination.EventTypes, destination.Description, destination.ContractVersion, now, actorID); err != nil { return billingwebhook.Destination{}, translate(err, "insert webhook destination") } if err := insertSecret(ctx, tx, destination.ProjectID, destination.ID, secret, now); err != nil { @@ -178,9 +223,15 @@ func (r *Repository) UpdateDestination(ctx context.Context, projectID, destinati SET url = coalesce($3, url), event_types = coalesce($4, event_types), description = coalesce($5, description), - updated_at = $6 + contract_version = coalesce($6, contract_version), + last_successful_test_at = CASE + WHEN ($3::text IS NOT NULL AND url IS DISTINCT FROM $3::text) + OR ($4::text[] IS NOT NULL AND event_types IS DISTINCT FROM $4::text[]) + OR ($6::integer IS NOT NULL AND contract_version IS DISTINCT FROM $6::integer) + THEN NULL ELSE last_successful_test_at END, + updated_at = $7 WHERE id = $1 AND project_id = $2`, - destinationID, projectID, update.URL, nullableArray(update.EventTypes), update.Description, now) + destinationID, projectID, update.URL, nullableArray(update.EventTypes), update.Description, update.ContractVersion, now) if err != nil { return billingwebhook.Destination{}, translate(err, "update webhook destination") } @@ -535,6 +586,7 @@ func (r *Repository) fanOutEvent(ctx context.Context, eventID, projectID string, FROM webhook_events e JOIN webhook_destinations d ON d.project_id = e.project_id AND d.environment_id = e.environment_id + AND d.contract_version = e.contract_version WHERE e.id = $1 ON CONFLICT (webhook_event_id, webhook_destination_id) DO NOTHING`, eventID, now, billingwebhook.DefaultMaxAttempts); err != nil { @@ -614,7 +666,8 @@ func (r *Repository) LeaseDelivery(ctx context.Context, workerID string, now, le // destination that reliably kills workers from being retried forever. if _, err := tx.Exec(ctx, `UPDATE webhook_deliveries - SET leased_by = $2, leased_until = $3, attempt_count = attempt_count + 1, updated_at = $4 + SET leased_by = $2, leased_until = $3, attempt_count = attempt_count + 1, updated_at = $4, + leased_destination_config_digest = webhook_destination_config_digest(webhook_destination_id,project_id) WHERE id = $1`, delivery.ID, workerID, leaseUntil, now); err != nil { return billingwebhook.LeasedDelivery{}, false, fmt.Errorf("lease webhook delivery: %w", err) } @@ -626,7 +679,7 @@ func (r *Repository) LeaseDelivery(ctx context.Context, workerID string, now, le leased := billingwebhook.LeasedDelivery{Delivery: delivery} if err := r.pool.QueryRow(ctx, - `SELECT e.event_type, e.payload::text, p.organization_id + `SELECT e.event_type, e.payload_bytes, p.organization_id FROM webhook_events e JOIN projects p ON p.id = e.project_id WHERE e.id = $1 AND e.project_id = $2`, delivery.EventID, delivery.ProjectID). Scan(&leased.EventType, &leased.Body, &leased.OrganizationID); err != nil { @@ -719,24 +772,57 @@ func (r *Repository) CompleteAttempt(ctx context.Context, result billingwebhook. switch { case result.ResetDestinationFailures: if err := tx.QueryRow(ctx, - `UPDATE webhook_destinations SET consecutive_failure_count = 0, updated_at = $3 + `UPDATE webhook_destinations SET consecutive_failure_count = 0 WHERE id = $1 AND project_id = $2 RETURNING consecutive_failure_count`, - result.Delivery.DestinationID, result.Delivery.ProjectID, settled). + result.Delivery.DestinationID, result.Delivery.ProjectID). Scan(&failures); err != nil && !errors.Is(err, pgx.ErrNoRows) { return 0, translate(err, "reset webhook destination failures") } case result.IncrementDestinationFailures: if err := tx.QueryRow(ctx, `UPDATE webhook_destinations - SET consecutive_failure_count = consecutive_failure_count + 1, updated_at = $3 + SET consecutive_failure_count = consecutive_failure_count + 1 WHERE id = $1 AND project_id = $2 RETURNING consecutive_failure_count`, - result.Delivery.DestinationID, result.Delivery.ProjectID, settled). + result.Delivery.DestinationID, result.Delivery.ProjectID). Scan(&failures); err != nil && !errors.Is(err, pgx.ErrNoRows) { return 0, translate(err, "increment webhook destination failures") } } + + // Promote receiver freshness only from the immutable attempt/event evidence + // written in this transaction and the exact destination configuration that + // was captured by PostgreSQL when the delivery was leased. AttemptedAt and + // RespondedAt remain audit fields; neither controls readiness time or config + // freshness. The proof timestamp comes from PostgreSQL's statement clock. + if _, err := tx.Exec(ctx, `UPDATE webhook_destinations destination + SET last_successful_test_at = GREATEST( + COALESCE(destination.last_successful_test_at, '-infinity'::timestamptz), + statement_timestamp()) + FROM webhook_delivery_attempts attempt + JOIN webhook_events event + ON event.id=attempt.webhook_event_id AND event.project_id=attempt.project_id + JOIN webhook_deliveries delivery + ON delivery.id=attempt.webhook_delivery_id AND delivery.project_id=attempt.project_id + WHERE attempt.id=$1 + AND destination.id=attempt.webhook_destination_id + AND destination.project_id=attempt.project_id + AND destination.status='active' + AND destination.contract_version=2 + AND event.contract_version=2 + AND event.event_type LIKE 'authority.%' + AND event.event_type=ANY(destination.event_types) + AND EXISTS(SELECT 1 FROM webhook_signing_secrets secret + WHERE secret.webhook_destination_id=destination.id + AND secret.project_id=destination.project_id + AND secret.status='active') + AND attempt.outcome='delivered' + AND delivery.status='succeeded' + AND delivery.leased_destination_config_digest IS NOT NULL + AND delivery.leased_destination_config_digest=webhook_destination_config_digest(destination.id,destination.project_id)`, attemptID); err != nil { + return 0, translate(err, "promote webhook destination freshness evidence") + } if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("commit webhook attempt completion: %w", err) } diff --git a/apps/api/internal/platform/billingwebhookpostgres/transition_delivery.go b/apps/api/internal/platform/billingwebhookpostgres/transition_delivery.go new file mode 100644 index 00000000..805626fe --- /dev/null +++ b/apps/api/internal/platform/billingwebhookpostgres/transition_delivery.go @@ -0,0 +1,34 @@ +package billingwebhookpostgres + +import ( + "context" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" +) + +var _ billingwebhook.DestinationReadinessReader = (*Repository)(nil) + +// DestinationReadiness is a narrow, read-only seam for migration readiness. +// A recent successful v2 authority-event delivery proves both that signing +// material opened and that this receiver configuration accepted the signed +// authority-aware bytes. Entitlement-only and v1 deliveries are deliberately +// insufficient. No destination is a valid configuration and therefore does +// not block readiness. +func (r *Repository) DestinationReadiness(ctx context.Context, projectID, environmentID string, recentAfter time.Time) (billingwebhook.DestinationReadiness, error) { + var result billingwebhook.DestinationReadiness + err := r.pool.QueryRow(ctx, `SELECT count(*),count(*) FILTER (WHERE d.contract_version=2 + AND EXISTS(SELECT 1 FROM webhook_signing_secrets secret WHERE secret.webhook_destination_id=d.id + AND secret.project_id=d.project_id AND secret.status='active') + AND EXISTS(SELECT 1 FROM webhook_delivery_attempts attempt + JOIN webhook_events event ON event.id=attempt.webhook_event_id AND event.project_id=attempt.project_id + WHERE attempt.webhook_destination_id=d.id AND attempt.project_id=d.project_id + AND attempt.outcome='delivered' AND attempt.attempted_at >= $3 + AND attempt.attempted_at >= d.updated_at + AND event.contract_version=2 AND event.event_type LIKE 'authority.%' + AND event.event_type=ANY(d.event_types))) + FROM webhook_destinations d JOIN environments environment ON environment.id=d.environment_id AND environment.project_id=d.project_id + WHERE d.project_id=$1 AND d.environment_id=$2 AND d.status='active' AND environment.mode='production'`, + projectID, environmentID, recentAfter).Scan(&result.ActiveDestinationCount, &result.HealthyV2Count) + return result, err +} diff --git a/apps/api/internal/platform/config/config.go b/apps/api/internal/platform/config/config.go index c0ab5886..2cd15248 100644 --- a/apps/api/internal/platform/config/config.go +++ b/apps/api/internal/platform/config/config.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "errors" "fmt" "io/fs" @@ -12,6 +13,8 @@ import ( "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingmigrationobject" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/telemetry" "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" ) @@ -48,9 +51,22 @@ type Config struct { Analytics AnalyticsConfig Providers ProviderConfig Billing BillingConfig + Migration BillingMigrationConfig Worker WorkerConfig } +// BillingMigrationConfig controls the opt-in execution plane for Phase 9C. +// The separately encrypted source-object bucket/keyring are never reused for +// public Assets or provider credentials. +type BillingMigrationConfig struct { + Enabled bool `envconfig:"MOSAIC_BILLING_MIGRATION_ENABLED" default:"false"` + SourceObjectKeyring string `envconfig:"MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING"` + SourceObjectBucket string `envconfig:"MOSAIC_BILLING_MIGRATION_SOURCE_BUCKET" default:"mosaic-migration-private"` + SourceObjectChunkBytes int `envconfig:"MOSAIC_BILLING_MIGRATION_SOURCE_CHUNK_BYTES" default:"262144"` + SourceObjectOperationTimeout time.Duration `envconfig:"MOSAIC_BILLING_MIGRATION_SOURCE_OPERATION_TIMEOUT" default:"5m"` + WorkerPollInterval time.Duration `envconfig:"MOSAIC_BILLING_MIGRATION_WORKER_POLL_INTERVAL" default:"1s"` +} + // BillingConfig holds Phase 9A's deployment-level settings. Mosaic Billing is // additionally per-Project opt-in and off by default, so enabling it here only // makes it available, never active. @@ -260,6 +276,31 @@ type LogConfig struct { type TelemetryConfig struct { ServiceName string `envconfig:"OTEL_SERVICE_NAME" default:"mosaic-api"` OTLPEndpoint string `envconfig:"OTEL_EXPORTER_OTLP_ENDPOINT"` + // OTLPProtocol selects the OTLP transport. Empty means the telemetry + // package default (http/protobuf); "grpc" switches both the trace and the + // metric exporter to OTLP/gRPC. + OTLPProtocol string `envconfig:"OTEL_EXPORTER_OTLP_PROTOCOL"` + // OTLPHeaders holds export headers as "key1=value1,key2=value2". Collector + // authentication lives here, so it is a secret: it is never logged and + // never echoed in a validation problem. + OTLPHeaders string `envconfig:"OTEL_EXPORTER_OTLP_HEADERS"` + // TLSSkipVerify disables collector certificate verification. It only + // applies to an https:// endpoint. + TLSSkipVerify bool `envconfig:"MOSAIC_OTEL_EXPORTER_TLS_SKIP_VERIFY" default:"false"` + // AllowInsecure acknowledges an unauthenticated-collector connection — + // plaintext export or unverified TLS — outside development and test. + AllowInsecure bool `envconfig:"MOSAIC_OTEL_EXPORTER_ALLOW_INSECURE" default:"false"` + // LogsEnabled ships log records to the collector alongside traces and + // metrics. It defaults on so logs are readable next to the traces they + // belong to; turn it off when log volume is the cost that matters. Local + // stdout logging is never affected. + LogsEnabled bool `envconfig:"MOSAIC_OTEL_LOGS_ENABLED" default:"true"` +} + +// ExportLogs reports whether log records should be shipped to the collector, +// which needs both a collector to ship to and the signal left enabled. +func (t TelemetryConfig) ExportLogs() bool { + return t.OTLPEndpoint != "" && t.LogsEnabled } // ValidationError aggregates every configuration problem found at startup so an @@ -293,6 +334,8 @@ func load() (Config, error) { cfg.Log.Format = strings.ToLower(strings.TrimSpace(cfg.Log.Format)) cfg.Telemetry.ServiceName = strings.TrimSpace(cfg.Telemetry.ServiceName) cfg.Telemetry.OTLPEndpoint = strings.TrimSpace(cfg.Telemetry.OTLPEndpoint) + cfg.Telemetry.OTLPProtocol = strings.ToLower(strings.TrimSpace(cfg.Telemetry.OTLPProtocol)) + cfg.Telemetry.OTLPHeaders = strings.TrimSpace(cfg.Telemetry.OTLPHeaders) cfg.BrowserAuth.CookieDomain = strings.TrimSpace(cfg.BrowserAuth.CookieDomain) cfg.Protocol.V02SchemaPath = strings.TrimSpace(cfg.Protocol.V02SchemaPath) cfg.Protocol.CommerceProviderSchemaPath = strings.TrimSpace(cfg.Protocol.CommerceProviderSchemaPath) @@ -302,6 +345,8 @@ func load() (Config, error) { cfg.Analytics.EventSchemaPath = strings.TrimSpace(cfg.Analytics.EventSchemaPath) cfg.Analytics.EventV2SchemaPath = strings.TrimSpace(cfg.Analytics.EventV2SchemaPath) cfg.Providers.CredentialKeyring = strings.TrimSpace(cfg.Providers.CredentialKeyring) + cfg.Migration.SourceObjectKeyring = strings.TrimSpace(cfg.Migration.SourceObjectKeyring) + cfg.Migration.SourceObjectBucket = strings.TrimSpace(cfg.Migration.SourceObjectBucket) cfg.Billing.NotificationBaseURL = strings.TrimSpace(cfg.Billing.NotificationBaseURL) cfg.Billing.AppleProductionBaseURL = strings.TrimSpace(cfg.Billing.AppleProductionBaseURL) cfg.Billing.AppleSandboxBaseURL = strings.TrimSpace(cfg.Billing.AppleSandboxBaseURL) @@ -397,11 +442,10 @@ func (cfg Config) validate() error { cfg.validateProviders(report, productionLike) cfg.validateAnalytics(report) cfg.validateBilling(report, productionLike) + cfg.validateBillingMigration(report) cfg.validateWorker(report) - if strings.TrimSpace(cfg.Telemetry.ServiceName) == "" { - report.add("OTEL_SERVICE_NAME must not be empty") - } + cfg.validateTelemetry(report, productionLike) if cfg.BrowserAuth.SessionLifetime <= 0 { report.add("MOSAIC_SESSION_LIFETIME must be greater than zero") } @@ -431,6 +475,61 @@ func (cfg Config) validate() error { return nil } +func (cfg Config) validateBillingMigration(report *problems) { + if cfg.Migration.Enabled && !cfg.Billing.Enabled { + report.add("MOSAIC_BILLING_ENABLED must be true when MOSAIC_BILLING_MIGRATION_ENABLED is true") + } + switch { + case cfg.Migration.Enabled && cfg.Migration.SourceObjectKeyring == "": + report.add("MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING is required when billing migration execution is enabled") + case cfg.Migration.SourceObjectKeyring != "": + if err := billingmigrationobject.ValidateKeyring(cfg.Migration.SourceObjectKeyring); err != nil { + report.add("MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING is not a valid version 1 AES-256 keyring") + } + } + if keyringMaterialOverlaps(cfg.Migration.SourceObjectKeyring, cfg.Providers.CredentialKeyring) { + report.add("MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING must not reuse key material from MOSAIC_PROVIDER_CREDENTIAL_KEYRING") + } + if cfg.Migration.SourceObjectBucket == "" { + report.add("MOSAIC_BILLING_MIGRATION_SOURCE_BUCKET must not be empty") + } else if cfg.Migration.SourceObjectBucket == cfg.ObjectStore.Bucket { + report.add("MOSAIC_BILLING_MIGRATION_SOURCE_BUCKET must be distinct from MOSAIC_OBJECT_STORAGE_BUCKET") + } + if cfg.Migration.SourceObjectChunkBytes < 16*1024 || cfg.Migration.SourceObjectChunkBytes > 4*1024*1024 { + report.add("MOSAIC_BILLING_MIGRATION_SOURCE_CHUNK_BYTES must be between 16384 and 4194304") + } + report.requirePositive(map[string]time.Duration{ + "MOSAIC_BILLING_MIGRATION_SOURCE_OPERATION_TIMEOUT": cfg.Migration.SourceObjectOperationTimeout, + "MOSAIC_BILLING_MIGRATION_WORKER_POLL_INTERVAL": cfg.Migration.WorkerPollInterval, + }) + if cfg.Migration.SourceObjectOperationTimeout > 15*time.Minute { + report.add("MOSAIC_BILLING_MIGRATION_SOURCE_OPERATION_TIMEOUT must not exceed 15m") + } +} + +func keyringMaterialOverlaps(left, right string) bool { + if left == "" || right == "" { + return false + } + type keyring struct { + Keys map[string]string `json:"keys"` + } + var first, second keyring + if json.Unmarshal([]byte(left), &first) != nil || json.Unmarshal([]byte(right), &second) != nil { + return false + } + values := make(map[string]struct{}, len(first.Keys)) + for _, value := range first.Keys { + values[value] = struct{}{} + } + for _, value := range second.Keys { + if _, exists := values[value]; exists { + return true + } + } + return false +} + func (cfg Config) validateHTTP(report *problems, productionLike bool) { if _, _, err := net.SplitHostPort(cfg.HTTP.Address); err != nil { report.add("MOSAIC_HTTP_ADDRESS must be a host:port address") @@ -544,6 +643,61 @@ func databaseSSLMode(raw string) string { return "" } +// validateTelemetry rejects an export setup that cannot work, and one that +// would put collector credentials on a connection nothing authenticates. No +// problem it reports contains a header value. +func (cfg Config) validateTelemetry(report *problems, productionLike bool) { + if strings.TrimSpace(cfg.Telemetry.ServiceName) == "" { + report.add("OTEL_SERVICE_NAME must not be empty") + } + if _, err := telemetry.NormalizeProtocol(cfg.Telemetry.OTLPProtocol); err != nil { + report.add(`OTEL_EXPORTER_OTLP_PROTOCOL must be "http/protobuf" or "grpc"`) + } + if _, err := telemetry.ParseHeaders(cfg.Telemetry.OTLPHeaders); err != nil { + report.add( + "OTEL_EXPORTER_OTLP_HEADERS must be a comma-separated list of key=value pairs " + + "with percent-encoded values", + ) + } + + // Everything below describes how Mosaic reaches the collector, which only + // matters once there is one to reach. + if cfg.Telemetry.OTLPEndpoint == "" { + return + } + endpoint, err := url.Parse(cfg.Telemetry.OTLPEndpoint) + if err != nil || endpoint.Host == "" || (endpoint.Scheme != "http" && endpoint.Scheme != "https") { + report.add("OTEL_EXPORTER_OTLP_ENDPOINT must be an absolute http:// or https:// URL") + return + } + if endpoint.User != nil { + report.add("OTEL_EXPORTER_OTLP_ENDPOINT must not embed credentials; use OTEL_EXPORTER_OTLP_HEADERS") + } + if cfg.Telemetry.TLSSkipVerify && endpoint.Scheme != "https" { + report.add( + "MOSAIC_OTEL_EXPORTER_TLS_SKIP_VERIFY has no effect on a plaintext OTEL_EXPORTER_OTLP_ENDPOINT; " + + "use an https:// endpoint or unset it", + ) + } + if !productionLike || cfg.Telemetry.AllowInsecure { + return + } + // Outside development, an export connection that is neither encrypted nor + // verified is a credential-disclosure risk whenever headers are set, and a + // telemetry-tampering risk even when they are not. + if endpoint.Scheme != "https" { + report.add( + "OTEL_EXPORTER_OTLP_ENDPOINT must use https outside development and test; set " + + "MOSAIC_OTEL_EXPORTER_ALLOW_INSECURE=true only when the collector is reached over a trusted private network", + ) + } else if cfg.Telemetry.TLSSkipVerify { + report.add( + "MOSAIC_OTEL_EXPORTER_TLS_SKIP_VERIFY must be false outside development and test; set " + + "MOSAIC_OTEL_EXPORTER_ALLOW_INSECURE=true to accept an unverified collector certificate", + ) + } +} + func (cfg Config) validateObjectStore(report *problems, productionLike bool) { if cfg.ObjectStore.Endpoint == "" || cfg.ObjectStore.AccessKey == "" || cfg.ObjectStore.SecretKey == "" || cfg.ObjectStore.Bucket == "" { diff --git a/apps/api/internal/platform/config/config_test.go b/apps/api/internal/platform/config/config_test.go index 95b517e6..35e3fb64 100644 --- a/apps/api/internal/platform/config/config_test.go +++ b/apps/api/internal/platform/config/config_test.go @@ -44,6 +44,84 @@ func TestLoadUsesFoundationDefaults(t *testing.T) { if cfg.Providers.OperationTimeout != 60*time.Second { t.Fatalf("provider operation timeout = %s, want 60s", cfg.Providers.OperationTimeout) } + if cfg.Migration.Enabled { + t.Fatal("billing migration execution must default off") + } + if cfg.Migration.SourceObjectBucket != "mosaic-migration-private" || cfg.Migration.SourceObjectChunkBytes != 256*1024 { + t.Fatalf("migration source-object defaults = %#v", cfg.Migration) + } + if cfg.Migration.SourceObjectOperationTimeout != 5*time.Minute || cfg.Migration.WorkerPollInterval != time.Second { + t.Fatalf("migration worker defaults = %#v", cfg.Migration) + } +} + +func TestBillingMigrationExecutionRequiresBillingAndSeparateEncryption(t *testing.T) { + validKeyring := `{"version":1,"activeKeyId":"migration-1","keys":{"migration-1":"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI"}}` + providerKeyring := `{"version":1,"activeKeyId":"provider-1","keys":{"provider-1":"QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"}}` + + for name, test := range map[string]struct { + values map[string]string + want string + }{ + "billing is enabled": { + values: map[string]string{"MOSAIC_BILLING_MIGRATION_ENABLED": "true", "MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING": validKeyring}, + want: "MOSAIC_BILLING_ENABLED", + }, + "source keyring is configured": { + values: map[string]string{"MOSAIC_BILLING_MIGRATION_ENABLED": "true", "MOSAIC_BILLING_ENABLED": "true"}, + want: "MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING", + }, + "source keyring is valid": { + values: map[string]string{"MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING": `{"version":1,"activeKeyId":"missing","keys":{}}`}, + want: "MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING", + }, + "source keyring is not the provider credential keyring": { + values: map[string]string{"MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING": validKeyring, "MOSAIC_PROVIDER_CREDENTIAL_KEYRING": validKeyring}, + want: "MOSAIC_PROVIDER_CREDENTIAL_KEYRING", + }, + "source keyring does not reuse provider key material under another id": { + values: map[string]string{ + "MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING": validKeyring, + "MOSAIC_PROVIDER_CREDENTIAL_KEYRING": `{"version":1,"activeKeyId":"provider-elsewhere","keys":{"provider-elsewhere":"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI"}}`, + }, + want: "MOSAIC_PROVIDER_CREDENTIAL_KEYRING", + }, + "source objects use a private bucket": { + values: map[string]string{"MOSAIC_BILLING_MIGRATION_SOURCE_BUCKET": "mosaic-assets"}, + want: "MOSAIC_BILLING_MIGRATION_SOURCE_BUCKET", + }, + "chunk size remains bounded": { + values: map[string]string{"MOSAIC_BILLING_MIGRATION_SOURCE_CHUNK_BYTES": "8192"}, + want: "MOSAIC_BILLING_MIGRATION_SOURCE_CHUNK_BYTES", + }, + "source operation remains bounded": { + values: map[string]string{"MOSAIC_BILLING_MIGRATION_SOURCE_OPERATION_TIMEOUT": "16m"}, + want: "MOSAIC_BILLING_MIGRATION_SOURCE_OPERATION_TIMEOUT", + }, + } { + t.Run(name, func(t *testing.T) { + _, err := loadTestConfig(t, test.values) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %s", err, test.want) + } + }) + } + + cfg, err := loadTestConfig(t, map[string]string{ + "MOSAIC_BILLING_ENABLED": "true", + "MOSAIC_BILLING_NOTIFICATION_BASE_URL": "http://localhost:8080", + "MOSAIC_PROVIDER_CREDENTIAL_KEYRING": providerKeyring, + "MOSAIC_BILLING_MIGRATION_ENABLED": "true", + "MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING": validKeyring, + "MOSAIC_BILLING_MIGRATION_SOURCE_BUCKET": "migration-evidence-private", + "MOSAIC_BILLING_MIGRATION_SOURCE_CHUNK_BYTES": "32768", + }) + if err != nil { + t.Fatalf("valid migration configuration rejected: %v", err) + } + if !cfg.Migration.Enabled || cfg.Migration.SourceObjectChunkBytes != 32768 { + t.Fatalf("migration configuration = %#v", cfg.Migration) + } } func TestLoadRejectsInvalidAuthenticationRateLimits(t *testing.T) { @@ -169,6 +247,216 @@ func TestLoadRejectsInsecureHostedAuthenticationAndAssetDefaults(t *testing.T) { } } +func TestTelemetryProtocolAcceptsSupportedTransports(t *testing.T) { + for name, test := range map[string]struct { + value string + wantError bool + }{ + "unset defaults to http": {value: ""}, + "http/protobuf": {value: "http/protobuf"}, + "grpc": {value: "grpc"}, + "mixed case is tolerated": {value: "GRPC"}, + "unsupported transport": {value: "thrift", wantError: true}, + } { + t.Run(name, func(t *testing.T) { + values := map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.internal:4317"} + if test.value != "" { + values["OTEL_EXPORTER_OTLP_PROTOCOL"] = test.value + } + cfg, err := loadTestConfig(t, values) + if test.wantError { + if err == nil { + t.Fatal("load succeeded with unsupported OTLP protocol") + } + if !strings.Contains(err.Error(), "OTEL_EXPORTER_OTLP_PROTOCOL") { + t.Fatalf("error = %v, want it to name the offending variable", err) + } + return + } + if err != nil { + t.Fatalf("load telemetry protocol %q: %v", test.value, err) + } + if want := strings.ToLower(test.value); cfg.Telemetry.OTLPProtocol != want { + t.Fatalf("protocol = %q, want %q", cfg.Telemetry.OTLPProtocol, want) + } + }) + } +} + +// Log export follows the collector: on by default so logs sit beside the traces +// they belong to, off when there is nowhere to send them or an operator says so. +func TestLogExportFollowsCollectorAndOptOut(t *testing.T) { + for name, test := range map[string]struct { + values map[string]string + want bool + }{ + "no collector configured": {values: map[string]string{}}, + "collector configured": { + values: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318"}, + want: true, + }, + "logs disabled with a collector": { + values: map[string]string{ + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318", + "MOSAIC_OTEL_LOGS_ENABLED": "false", + }, + }, + "logs enabled without a collector": { + values: map[string]string{"MOSAIC_OTEL_LOGS_ENABLED": "true"}, + }, + } { + t.Run(name, func(t *testing.T) { + cfg, err := loadTestConfig(t, test.values) + if err != nil { + t.Fatalf("load configuration: %v", err) + } + if cfg.Telemetry.ExportLogs() != test.want { + t.Fatalf("export logs = %t, want %t", cfg.Telemetry.ExportLogs(), test.want) + } + }) + } +} + +func TestTelemetryExportSecurityIsValidated(t *testing.T) { + secretHeader := "authorization=Bearer%20collector-super-secret" + for name, test := range map[string]struct { + values map[string]string + wantError bool + }{ + "plaintext collector in development": { + values: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318"}, + }, + "authenticated https collector": { + values: map[string]string{ + "OTEL_EXPORTER_OTLP_ENDPOINT": "https://collector.example:4318", + "OTEL_EXPORTER_OTLP_HEADERS": secretHeader, + }, + }, + "malformed headers": { + values: map[string]string{ + "OTEL_EXPORTER_OTLP_ENDPOINT": "https://collector.example:4318", + "OTEL_EXPORTER_OTLP_HEADERS": "authorization Bearer collector-super-secret", + }, + wantError: true, + }, + "endpoint embedding credentials": { + values: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "https://user:collector-super-secret@collector.example:4318"}, + wantError: true, + }, + "skip verify on a plaintext endpoint": { + values: map[string]string{ + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318", + "MOSAIC_OTEL_EXPORTER_TLS_SKIP_VERIFY": "true", + }, + wantError: true, + }, + } { + t.Run(name, func(t *testing.T) { + _, err := loadTestConfig(t, test.values) + assertTelemetryValidation(t, err, test.wantError) + }) + } +} + +// The gRPC transport is configured through the same endpoint variable as HTTP, +// and the OpenTelemetry specification defines that variable as a URL for both. +// A bare host:port is the form a gRPC operator reaches for out of habit, and it +// is the one form that must be rejected: the SDK would silently fall back to its +// own default collector rather than the address that was written down. +func TestTelemetryGRPCEndpointsAreValidatedAsURLs(t *testing.T) { + for name, test := range map[string]struct { + endpoint string + wantError bool + }{ + "plaintext grpc url": {endpoint: "http://localhost:4317"}, + "tls grpc url": {endpoint: "https://collector.example:4317"}, + "grpc url behind a gateway prefix": {endpoint: "https://gateway.example/otlp"}, + "bare host and port": {endpoint: "localhost:4317", wantError: true}, + "scheme-relative host and port": {endpoint: "//localhost:4317", wantError: true}, + } { + t.Run(name, func(t *testing.T) { + _, err := loadTestConfig(t, map[string]string{ + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", + "OTEL_EXPORTER_OTLP_ENDPOINT": test.endpoint, + }) + assertTelemetryValidation(t, err, test.wantError) + }) + } +} + +func TestProductionTelemetryRequiresVerifiedCollectorOrAcknowledgement(t *testing.T) { + for name, test := range map[string]struct { + overrides map[string]string + wantError bool + }{ + "verified https collector": { + overrides: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "https://collector.example:4318"}, + }, + "plaintext collector": { + overrides: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example:4318"}, + wantError: true, + }, + "plaintext collector on a trusted network": { + overrides: map[string]string{ + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.internal:4318", + "MOSAIC_OTEL_EXPORTER_ALLOW_INSECURE": "true", + }, + }, + "unverified certificate": { + overrides: map[string]string{ + "OTEL_EXPORTER_OTLP_ENDPOINT": "https://collector.example:4318", + "MOSAIC_OTEL_EXPORTER_TLS_SKIP_VERIFY": "true", + }, + wantError: true, + }, + "unverified certificate acknowledged": { + overrides: map[string]string{ + "OTEL_EXPORTER_OTLP_ENDPOINT": "https://collector.example:4318", + "MOSAIC_OTEL_EXPORTER_TLS_SKIP_VERIFY": "true", + "MOSAIC_OTEL_EXPORTER_ALLOW_INSECURE": "true", + }, + }, + } { + t.Run(name, func(t *testing.T) { + values := productionConfigValues() + values["OTEL_EXPORTER_OTLP_HEADERS"] = "authorization=Bearer%20collector-super-secret" + for key, value := range test.overrides { + values[key] = value + } + _, err := loadTestConfig(t, values) + assertTelemetryValidation(t, err, test.wantError) + }) + } +} + +// productionConfigValues is a deployment that passes every production guard, so +// a test that overrides one variable is asserting about that variable alone. +func productionConfigValues() map[string]string { + return map[string]string{ + "MOSAIC_ENVIRONMENT": "production", + "MOSAIC_CORS_ALLOWED_ORIGINS": "https://studio.example", + "MOSAIC_SESSION_COOKIE_SECURE": "true", + "MOSAIC_PUBLIC_ASSET_BASE_URL": "https://assets.example/v1/sdk/assets", + "MOSAIC_OBJECT_STORAGE_ACCESS_KEY": "production-access", + "MOSAIC_OBJECT_STORAGE_SECRET_KEY": "production-secret", + "MOSAIC_OBJECT_STORAGE_TLS": "true", + "DATABASE_URL": "postgres://mosaic:secret-password@db.example:5432/mosaic?sslmode=verify-full", + } +} + +func assertTelemetryValidation(t *testing.T, err error, wantError bool) { + t.Helper() + if err != nil && strings.Contains(err.Error(), "collector-super-secret") { + t.Fatalf("startup error leaked a collector credential: %v", err) + } + switch { + case wantError && err == nil: + t.Fatal("load succeeded with an unsafe telemetry export configuration") + case !wantError && err != nil: + t.Fatalf("load telemetry export configuration: %v", err) + } +} + func TestLoadReadsDotEnvBeforeDecoding(t *testing.T) { clearConfigEnvironment(t) temporaryDirectory := t.TempDir() @@ -213,6 +501,9 @@ func clearConfigEnvironment(t *testing.T) { "MOSAIC_HTTP_READ_TIMEOUT", "MOSAIC_HTTP_WRITE_TIMEOUT", "MOSAIC_HTTP_IDLE_TIMEOUT", "MOSAIC_HTTP_HANDLER_TIMEOUT", "MOSAIC_HTTP_SHUTDOWN_TIMEOUT", "MOSAIC_CORS_ALLOWED_ORIGINS", "MOSAIC_LOG_LEVEL", "MOSAIC_LOG_FORMAT", "OTEL_SERVICE_NAME", "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_EXPORTER_OTLP_HEADERS", + "MOSAIC_OTEL_EXPORTER_TLS_SKIP_VERIFY", "MOSAIC_OTEL_EXPORTER_ALLOW_INSECURE", + "MOSAIC_OTEL_LOGS_ENABLED", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS", "DATABASE_MIN_CONNECTIONS", "DATABASE_CONNECT_TIMEOUT", "MOSAIC_SESSION_LIFETIME", "MOSAIC_SESSION_COOKIE_SECURE", "MOSAIC_SESSION_COOKIE_DOMAIN", "MOSAIC_AUTH_REQUESTS_PER_MINUTE", "MOSAIC_AUTH_BURST", "MOSAIC_AUTH_LIMITER_ENTRIES", @@ -227,6 +518,9 @@ func clearConfigEnvironment(t *testing.T) { "MOSAIC_PROVIDER_OPERATION_TIMEOUT", "MOSAIC_PROVIDER_CONNECT_TIMEOUT", "MOSAIC_PROVIDER_MAX_RESPONSE_BYTES", "MOSAIC_PROVIDER_MAX_ATTEMPTS", "MOSAIC_PROVIDER_SNAPSHOT_TTL", "MOSAIC_PROVIDER_WORKER_POLL_INTERVAL", + "MOSAIC_BILLING_MIGRATION_ENABLED", "MOSAIC_BILLING_MIGRATION_SOURCE_KEYRING", + "MOSAIC_BILLING_MIGRATION_SOURCE_BUCKET", "MOSAIC_BILLING_MIGRATION_SOURCE_CHUNK_BYTES", + "MOSAIC_BILLING_MIGRATION_SOURCE_OPERATION_TIMEOUT", "MOSAIC_BILLING_MIGRATION_WORKER_POLL_INTERVAL", "MOSAIC_ANALYTICS_EVENT_SCHEMA_PATH", "MOSAIC_ANALYTICS_EVENT_V2_SCHEMA_PATH", "MOSAIC_ANALYTICS_IP_REQUESTS_PER_MINUTE", "MOSAIC_ANALYTICS_IP_BURST", "MOSAIC_ANALYTICS_KEY_BATCHES_PER_MINUTE", "MOSAIC_ANALYTICS_KEY_BATCH_BURST", @@ -263,16 +557,7 @@ func clearConfigEnvironment(t *testing.T) { // requests from any origin. Each case asserts one guard fires and that the // error names the variable without echoing its value. func TestProductionConfigurationGuards(t *testing.T) { - secureProduction := map[string]string{ - "MOSAIC_ENVIRONMENT": "production", - "MOSAIC_CORS_ALLOWED_ORIGINS": "https://studio.example", - "MOSAIC_SESSION_COOKIE_SECURE": "true", - "MOSAIC_PUBLIC_ASSET_BASE_URL": "https://assets.example/v1/sdk/assets", - "MOSAIC_OBJECT_STORAGE_ACCESS_KEY": "production-access", - "MOSAIC_OBJECT_STORAGE_SECRET_KEY": "production-secret", - "MOSAIC_OBJECT_STORAGE_TLS": "true", - "DATABASE_URL": "postgres://mosaic:secret-password@db.example:5432/mosaic?sslmode=verify-full", - } + secureProduction := productionConfigValues() for name, test := range map[string]struct { overrides map[string]string diff --git a/apps/api/internal/platform/httpserver/router.go b/apps/api/internal/platform/httpserver/router.go index 1c5a0af3..654b4a94 100644 --- a/apps/api/internal/platform/httpserver/router.go +++ b/apps/api/internal/platform/httpserver/router.go @@ -18,6 +18,7 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" "github.com/Mujhtech/mosaic/apps/api/internal/billingdiagnostics" "github.com/Mujhtech/mosaic/apps/api/internal/billinggrant" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" "github.com/Mujhtech/mosaic/apps/api/internal/billingoperator" "github.com/Mujhtech/mosaic/apps/api/internal/billingrestore" "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" @@ -35,6 +36,7 @@ import ( billingcustomerhttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingcustomer" billingdiagnosticshttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingdiagnostics" billinggranthttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billinggrant" + billingmigrationhttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingmigration" billingoperatorhttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingoperator" billingrestorehttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingrestore" billingwebhookhttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingwebhook" @@ -115,6 +117,16 @@ type Dependencies struct { // management surface: history, impact preview, and publish. It is nil // whenever Billing is. BillingGrant *billinggrant.Service + // BillingMigration owns the Phase 9C evidence/control-plane foundation. It + // cannot switch authority or write Transaction Facts. + BillingMigration *billingmigration.Service + BillingMigrationSourcePull *billingmigration.SourcePullService + BillingMigrationOperations *billingmigration.OperationsService + BillingMigrationRedelivery *billingmigration.RedeliveryService + BillingMigrationReads *billingmigration.OperationalReadService + BillingMigrationStabilization *billingmigration.StabilizationService + BillingMigrationRollbackReadiness *billingmigration.RollbackReadinessService + BillingMigrationRepairOnline bool // BillingRestore owns the Phase 9B restore and sync chain: the SDK and // trusted request surfaces and the status read. It is nil whenever Billing // is. @@ -246,6 +258,19 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende billinggranthttp.RegisterProjectRoutes(project, dependencies.BillingGrant, httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) } + if dependencies.BillingMigration != nil { + billingmigrationhttp.RegisterProjectRoutes(project, billingmigrationhttp.Services{ + Program: dependencies.BillingMigration, + SourcePull: dependencies.BillingMigrationSourcePull, + Operations: dependencies.BillingMigrationOperations, + Redelivery: dependencies.BillingMigrationRedelivery, + Reads: dependencies.BillingMigrationReads, + Stabilization: dependencies.BillingMigrationStabilization, + RollbackReadiness: dependencies.BillingMigrationRollbackReadiness, + RepairOnline: dependencies.BillingMigrationRepairOnline, + }, + httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) + } if dependencies.BillingOperator != nil { // The customer lookup and the manual sync share the // export-class bucket: the lookup is the one surface @@ -373,7 +398,7 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende func hasBillingSurface(dependencies Dependencies) bool { return dependencies.Billing != nil || dependencies.BillingOperator != nil || dependencies.BillingGrant != nil || dependencies.BillingDiagnostics != nil || - dependencies.BillingWebhook != nil + dependencies.BillingWebhook != nil || dependencies.BillingMigration != nil } // hasEnvironmentBillingSurface reports whether any module publishes routes diff --git a/apps/api/internal/platform/logging/logging.go b/apps/api/internal/platform/logging/logging.go index c2318733..3637237d 100644 --- a/apps/api/internal/platform/logging/logging.go +++ b/apps/api/internal/platform/logging/logging.go @@ -8,7 +8,27 @@ import ( "github.com/rs/zerolog" ) +// New builds a logger that writes only to output. Telemetry's own SDK errors go +// to a logger built this way: a logger that exported would feed export failures +// back into the exporter that produced them. func New(level string, format string, output io.Writer) (zerolog.Logger, error) { + return build(level, format, output) +} + +// NewExporting builds a logger that writes to output and also emits every +// record to the OpenTelemetry Logs pipeline under the given scope name. The +// local stream is never given up: it stays the record of truth when the +// collector is unreachable, and it is what `docker logs` and `kubectl logs` +// show. +// +// Records are exported through the global logger provider, which telemetry.New +// installs. Building this logger before that call is safe — records emitted in +// between go to the no-op provider and appear locally only. +func NewExporting(level string, format string, output io.Writer, scope string) (zerolog.Logger, error) { + return build(level, format, output, otlpWriter{scope: scope}) +} + +func build(level string, format string, output io.Writer, extra ...io.Writer) (zerolog.Logger, error) { parsedLevel, err := zerolog.ParseLevel(level) if err != nil { return zerolog.Logger{}, fmt.Errorf("parse log level: %w", err) @@ -21,6 +41,12 @@ func New(level string, format string, output io.Writer) (zerolog.Logger, error) TimeFormat: time.RFC3339, } } + if len(extra) > 0 { + // Every writer in the tee receives the same JSON object; console + // rendering happens inside ConsoleWriter, so the OTLP writer still sees + // structured fields even in development format. + writer = zerolog.MultiLevelWriter(append([]io.Writer{writer}, extra...)...) + } return zerolog.New(writer). Level(parsedLevel). diff --git a/apps/api/internal/platform/logging/otlp.go b/apps/api/internal/platform/logging/otlp.go new file mode 100644 index 00000000..e7455efe --- /dev/null +++ b/apps/api/internal/platform/logging/otlp.go @@ -0,0 +1,150 @@ +package logging + +import ( + "context" + "encoding/json" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/global" +) + +// otlpWriter forwards every emitted log line to the OpenTelemetry Logs +// pipeline, in addition to the local stream it is teed with. +// +// It sits at the writer end of zerolog rather than at a hook because a zerolog +// hook cannot read the fields already attached to an event. The writer receives +// the finished JSON object, so the exported record keeps every structured field +// the local line has — which is the whole point of shipping logs rather than +// reading them out of a terminal. +type otlpWriter struct { + // scope names the instrumentation scope on every exported record. It is + // resolved to a logger per write because the global provider is installed + // by telemetry.New, which runs after the logger exists; the global package + // delegates to a no-op until then. + scope string +} + +// Fields the OTLP record models directly rather than as attributes, so a +// backend can filter on severity, time, and body without knowing zerolog's +// field names. +var structuralFields = map[string]struct{}{ + zerolog.LevelFieldName: {}, + zerolog.TimestampFieldName: {}, + zerolog.MessageFieldName: {}, +} + +func (w otlpWriter) Write(line []byte) (int, error) { + return w.WriteLevel(zerolog.NoLevel, line) +} + +func (w otlpWriter) WriteLevel(level zerolog.Level, line []byte) (int, error) { + // A malformed line still reached the local stream, which is the record of + // truth. Dropping it here keeps a logging problem from becoming a request + // failure, and reporting an error would make zerolog complain on stderr for + // every line. + var fields map[string]json.RawMessage + if err := json.Unmarshal(line, &fields); err != nil { + return len(line), nil + } + + var record log.Record + record.SetObservedTimestamp(time.Now()) + record.SetSeverity(severityOf(level)) + record.SetSeverityText(level.String()) + record.SetBody(log.StringValue(decodeString(fields[zerolog.MessageFieldName]))) + if timestamp, ok := decodeTimestamp(fields[zerolog.TimestampFieldName]); ok { + record.SetTimestamp(timestamp) + } + + attributes := make([]log.KeyValue, 0, len(fields)) + for key, raw := range fields { + if _, structural := structuralFields[key]; structural { + continue + } + attributes = append(attributes, attributeOf(key, raw)) + } + if len(attributes) > 0 { + record.AddAttributes(attributes...) + } + + // The batch processor owns delivery from here: Emit hands the record off + // without blocking on the collector, so an export stall cannot slow down + // the request that produced the line. + global.Logger(w.scope).Emit(context.Background(), record) + return len(line), nil +} + +// attributeOf preserves the JSON type of a field so a backend can range over +// numbers and filter on booleans. Objects and arrays keep their JSON encoding, +// which stays readable and avoids flattening nested context into new keys. +func attributeOf(key string, raw json.RawMessage) log.KeyValue { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return log.String(key, string(raw)) + } + switch typed := value.(type) { + case string: + return log.String(key, typed) + case bool: + return log.Bool(key, typed) + case float64: + // JSON has one number type; keeping integers integral matters for IDs + // and counts, which are most of Mosaic's numeric fields. + if typed == float64(int64(typed)) { + return log.Int64(key, int64(typed)) + } + return log.Float64(key, typed) + case nil: + return log.String(key, "") + default: + return log.String(key, string(raw)) + } +} + +func decodeString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return string(raw) + } + return value +} + +func decodeTimestamp(raw json.RawMessage) (time.Time, bool) { + if len(raw) == 0 { + return time.Time{}, false + } + timestamp, err := time.Parse(time.RFC3339, decodeString(raw)) + if err != nil { + return time.Time{}, false + } + return timestamp, true +} + +// severityOf maps zerolog levels onto the OpenTelemetry severity range so +// backends that alert on severity see the same picture as a reader of the local +// stream. Panic sits above fatal because it carries a stack unwind with it. +func severityOf(level zerolog.Level) log.Severity { + switch level { + case zerolog.TraceLevel: + return log.SeverityTrace1 + case zerolog.DebugLevel: + return log.SeverityDebug1 + case zerolog.InfoLevel: + return log.SeverityInfo1 + case zerolog.WarnLevel: + return log.SeverityWarn1 + case zerolog.ErrorLevel: + return log.SeverityError1 + case zerolog.FatalLevel: + return log.SeverityFatal1 + case zerolog.PanicLevel: + return log.SeverityFatal2 + default: + return log.SeverityUndefined + } +} diff --git a/apps/api/internal/platform/logging/otlp_test.go b/apps/api/internal/platform/logging/otlp_test.go new file mode 100644 index 00000000..dc907f13 --- /dev/null +++ b/apps/api/internal/platform/logging/otlp_test.go @@ -0,0 +1,193 @@ +package logging + +import ( + "bytes" + "context" + "strings" + "sync" + "testing" + + "github.com/rs/zerolog" + otellog "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/global" + sdklog "go.opentelemetry.io/otel/sdk/log" +) + +// recordingExporter stands in for a collector so a test can assert on what +// would go over the wire. +type recordingExporter struct { + mutex sync.Mutex + records []sdklog.Record +} + +func (e *recordingExporter) Export(_ context.Context, records []sdklog.Record) error { + e.mutex.Lock() + defer e.mutex.Unlock() + for _, record := range records { + e.records = append(e.records, record.Clone()) + } + return nil +} + +func (e *recordingExporter) Shutdown(context.Context) error { return nil } +func (e *recordingExporter) ForceFlush(context.Context) error { return nil } + +func (e *recordingExporter) collected() []sdklog.Record { + e.mutex.Lock() + defer e.mutex.Unlock() + return append([]sdklog.Record(nil), e.records...) +} + +// installRecordingProvider points the global logger provider at an exporter for +// the duration of a test, restoring a no-op provider afterwards so a test that +// asserts nothing is exported cannot be polluted by an earlier one. +func installRecordingProvider(t *testing.T) *recordingExporter { + t.Helper() + exporter := &recordingExporter{} + provider := sdklog.NewLoggerProvider(sdklog.WithProcessor(sdklog.NewSimpleProcessor(exporter))) + global.SetLoggerProvider(provider) + t.Cleanup(func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Fatalf("shutdown recording provider: %v", err) + } + global.SetLoggerProvider(sdklog.NewLoggerProvider()) + }) + return exporter +} + +func attributesOf(t *testing.T, record sdklog.Record) map[string]string { + t.Helper() + attributes := make(map[string]string) + record.WalkAttributes(func(attribute otellog.KeyValue) bool { + attributes[attribute.Key] = attribute.Value.String() + return true + }) + return attributes +} + +// The exported record must carry the same structured fields as the local line. +// Shipping level and message alone would leave an operator back at the terminal +// they were trying not to open. +func TestExportingLoggerKeepsLocalStreamAndStructuredFields(t *testing.T) { + exporter := installRecordingProvider(t) + var local bytes.Buffer + + logger, err := NewExporting("info", "json", &local, "mosaic-api-test") + if err != nil { + t.Fatalf("build exporting logger: %v", err) + } + logger.Info(). + Str("project_id", "prj_123"). + Int("attempt", 3). + Bool("retryable", true). + Msg("published release") + + if !strings.Contains(local.String(), `"message":"published release"`) { + t.Fatalf("local stream = %q, want the log line", local.String()) + } + + records := exporter.collected() + if len(records) != 1 { + t.Fatalf("exported %d records, want 1", len(records)) + } + record := records[0] + if body := record.Body().AsString(); body != "published release" { + t.Fatalf("body = %q, want the message", body) + } + if record.Severity() != otellog.SeverityInfo1 { + t.Fatalf("severity = %v, want info", record.Severity()) + } + if record.Timestamp().IsZero() { + t.Fatal("record has no timestamp") + } + + attributes := attributesOf(t, record) + for key, want := range map[string]string{ + "project_id": "prj_123", + "attempt": "3", + "retryable": "true", + } { + if attributes[key] != want { + t.Fatalf("attribute %s = %q, want %q", key, attributes[key], want) + } + } + // Level, time, and message are modelled as record structure, so repeating + // them as attributes would duplicate them in every backend. + for _, key := range []string{zerolog.LevelFieldName, zerolog.TimestampFieldName, zerolog.MessageFieldName} { + if _, duplicated := attributes[key]; duplicated { + t.Fatalf("structural field %s was also exported as an attribute", key) + } + } +} + +func TestNonExportingLoggerStaysLocal(t *testing.T) { + exporter := installRecordingProvider(t) + var local bytes.Buffer + + logger, err := New("info", "json", &local) + if err != nil { + t.Fatalf("build logger: %v", err) + } + logger.Info().Msg("published release") + + if local.Len() == 0 { + t.Fatal("local stream is empty") + } + if records := exporter.collected(); len(records) != 0 { + t.Fatalf("exported %d records, want none from a local-only logger", len(records)) + } +} + +// Console format renders for humans at the local writer, but the exported +// record must still be built from the structured JSON. +func TestExportingLoggerKeepsFieldsInConsoleFormat(t *testing.T) { + exporter := installRecordingProvider(t) + var local bytes.Buffer + + logger, err := NewExporting("debug", "console", &local, "mosaic-api-test") + if err != nil { + t.Fatalf("build exporting logger: %v", err) + } + logger.Warn().Str("project_id", "prj_123").Msg("slow publish") + + records := exporter.collected() + if len(records) != 1 { + t.Fatalf("exported %d records, want 1", len(records)) + } + if records[0].Severity() != otellog.SeverityWarn1 { + t.Fatalf("severity = %v, want warn", records[0].Severity()) + } + if attributes := attributesOf(t, records[0]); attributes["project_id"] != "prj_123" { + t.Fatalf("attributes = %#v, want the project id", attributes) + } +} + +func TestSeverityMapsAcrossLevels(t *testing.T) { + for level, want := range map[zerolog.Level]otellog.Severity{ + zerolog.TraceLevel: otellog.SeverityTrace1, + zerolog.DebugLevel: otellog.SeverityDebug1, + zerolog.InfoLevel: otellog.SeverityInfo1, + zerolog.WarnLevel: otellog.SeverityWarn1, + zerolog.ErrorLevel: otellog.SeverityError1, + zerolog.FatalLevel: otellog.SeverityFatal1, + zerolog.PanicLevel: otellog.SeverityFatal2, + zerolog.NoLevel: otellog.SeverityUndefined, + } { + if got := severityOf(level); got != want { + t.Fatalf("severity of %s = %v, want %v", level, got, want) + } + } +} + +// A line the writer cannot parse has already reached the local stream. It must +// not surface as a write error, which zerolog would report on stderr for every +// subsequent line. +func TestWriterDropsUnparsableLinesWithoutError(t *testing.T) { + installRecordingProvider(t) + writer := otlpWriter{scope: "mosaic-api-test"} + line := []byte("not json\n") + written, err := writer.WriteLevel(zerolog.InfoLevel, line) + if err != nil || written != len(line) { + t.Fatalf("write = %d, %v; want %d and no error", written, err, len(line)) + } +} diff --git a/apps/api/internal/platform/revenuecat/client.go b/apps/api/internal/platform/revenuecat/client.go index b35de1ca..12367303 100644 --- a/apps/api/internal/platform/revenuecat/client.go +++ b/apps/api/internal/platform/revenuecat/client.go @@ -20,6 +20,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" "github.com/Mujhtech/mosaic/apps/api/internal/platform/ratelimit" "github.com/Mujhtech/mosaic/apps/api/internal/providercatalog" ) @@ -125,6 +126,86 @@ type listResponse[T any] struct { NextPage string `json:"next_page"` } +// AssessMigration proves the separately consented key can perform the read-only +// RevenueCat v2 operations migration needs. RevenueCat has no permission- +// introspection endpoint, so capabilities are earned by successful +// representative reads rather than by trusting a caller-supplied list. +func (c *Client) AssessMigration(ctx context.Context, externalProjectID string, secret []byte) (billingmigration.CapabilityResult, error) { + if externalProjectID == "" || len(secret) == 0 { + return billingmigration.CapabilityResult{}, billingmigration.ErrInvalid + } + ctx, cancel := context.WithTimeout(ctx, c.operationTimeout) + defer cancel() + type assessmentCustomer struct { + ID string `json:"id"` + } + var response listResponse[assessmentCustomer] + budget := operationBudget{remainingPages: 4, remainingRetries: c.maxRetries} + projectID := url.PathEscape(externalProjectID) + customersPath := "/projects/" + projectID + "/customers" + query := url.Values{"limit": {"1"}} + err := c.get(ctx, &budget, secret, customersPath, query, &response) + if err != nil { + var providerError *providercatalog.Error + if errors.As(err, &providerError) && (providerError.Code == providercatalog.ErrorCredentialInvalid || providerError.Code == providercatalog.ErrorPermissionDenied) { + return billingmigration.CapabilityResult{}, billingmigration.ErrInvalid + } + return billingmigration.CapabilityResult{}, billingmigration.ErrUnavailable + } + capabilities := []string{"read_customers"} + if len(response.Items) > 0 { + customerID := response.Items[0].ID + if customerID == "" || invalidSourceID(customerID) { + return billingmigration.CapabilityResult{}, billingmigration.ErrUnavailable + } + if ok, err := c.assessReadProbe(ctx, &budget, secret, "/projects/"+projectID+"/customers/"+url.PathEscape(customerID)+"/subscriptions", nil); err != nil { + return billingmigration.CapabilityResult{}, err + } else if ok { + capabilities = append(capabilities, "read_subscriptions") + } + if ok, err := c.assessReadProbe(ctx, &budget, secret, "/projects/"+projectID+"/customers/"+url.PathEscape(customerID)+"/aliases", nil); err != nil { + return billingmigration.CapabilityResult{}, err + } else if ok { + capabilities = append(capabilities, "read_aliases") + } + cursor := customerID + if response.NextPage != "" { + next, err := opaqueCursor(response.NextPage) + if err != nil || next == "" { + return billingmigration.CapabilityResult{}, billingmigration.ErrUnavailable + } + cursor = next + } + deltaQuery := url.Values{"limit": {"1"}, "starting_after": {cursor}} + if ok, err := c.assessReadProbe(ctx, &budget, secret, customersPath, deltaQuery); err != nil { + return billingmigration.CapabilityResult{}, err + } else if ok { + capabilities = append(capabilities, "incremental_delta") + } + } + return billingmigration.CapabilityResult{ + ProviderAPIVersion: billingmigration.ProviderAPIV2, + Capabilities: capabilities, + AssessedAt: c.now(), + }, nil +} + +func (c *Client) assessReadProbe(ctx context.Context, budget *operationBudget, secret []byte, path string, query url.Values) (bool, error) { + var response listResponse[json.RawMessage] + probeQuery := cloneValues(query) + if probeQuery.Get("limit") == "" { + probeQuery.Set("limit", "1") + } + if err := c.get(ctx, budget, secret, path, probeQuery, &response); err != nil { + var providerError *providercatalog.Error + if errors.As(err, &providerError) && (providerError.Code == providercatalog.ErrorCredentialInvalid || providerError.Code == providercatalog.ErrorPermissionDenied) { + return false, nil + } + return false, billingmigration.ErrUnavailable + } + return true, nil +} + type appResponse struct { ID string `json:"id"` Name string `json:"name"` diff --git a/apps/api/internal/platform/revenuecat/migration_import.go b/apps/api/internal/platform/revenuecat/migration_import.go new file mode 100644 index 00000000..5aa270e4 --- /dev/null +++ b/apps/api/internal/platform/revenuecat/migration_import.go @@ -0,0 +1,424 @@ +package revenuecat + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/providercatalog" +) + +const MigrationNormalizationSchema = "revenuecat-migration-source-v2" + +// MigrationPullRecord is an append-only normalized projection. ProviderEvidence +// contains provenance only; provider validation remains a separate operation. +type MigrationPullRecord struct { + Kind, SourceIdentifier, SourceRevision, Cursor string + Digest []byte + CurrentAccess bool + ObservedAt time.Time + CustomerID, ProductID, ExternalAppID string + Store, Environment, Provider, Platform, ReferenceKind string + ProviderReference string + StoreIdentifier string + EntitlementIDs []string + Ownership json.RawMessage + QuarantineReason string +} + +type MigrationEvidencePage struct { + Endpoint, Resource, Cursor string + Body []byte +} + +type MigrationPullResult struct { + Records []MigrationPullRecord + Pages []MigrationEvidencePage + ProvenCapabilities []string + ResumeCursor, FinalWatermark string + RecordCount, CurrentAccessCount int64 + EvidenceDigest []byte +} + +// PullSource adapts the RevenueCat transport result to the platform-neutral +// source-pull orchestration port. +func (c *Client) PullSource(ctx context.Context, externalProjectID string, secret []byte, startingAfter string, output io.Writer) (billingmigration.SourcePullProviderResult, error) { + result, err := c.PullMigrationEvidence(ctx, externalProjectID, secret, startingAfter, output) + if err != nil { + return billingmigration.SourcePullProviderResult{}, err + } + records := make([]billingmigration.SourcePullRecord, len(result.Records)) + for i, record := range result.Records { + records[i] = billingmigration.SourcePullRecord{Kind: record.Kind, SourceIdentifier: record.SourceIdentifier, SourceRevision: record.SourceRevision, Cursor: record.Cursor, Digest: record.Digest, CurrentAccess: record.CurrentAccess, ObservedAt: record.ObservedAt, CustomerID: record.CustomerID, ProductID: record.ProductID, ExternalAppID: record.ExternalAppID, Store: record.Store, Environment: record.Environment, Provider: record.Provider, Platform: record.Platform, ReferenceKind: record.ReferenceKind, ProviderReference: record.ProviderReference, StoreIdentifier: record.StoreIdentifier, EntitlementIDs: record.EntitlementIDs, Ownership: record.Ownership, QuarantineReason: record.QuarantineReason} + } + return billingmigration.SourcePullProviderResult{Records: records, ProvenCapabilities: result.ProvenCapabilities, ResumeCursor: result.ResumeCursor, FinalWatermark: result.FinalWatermark, RecordCount: result.RecordCount, CurrentAccessCount: result.CurrentAccessCount, EvidenceDigest: result.EvidenceDigest}, nil +} + +type migrationCustomer struct { + ID string `json:"id"` + OriginalCustomerID string `json:"original_customer_id"` + UpdatedAt int64 `json:"updated_at"` +} + +type migrationAlias struct { + ID string `json:"id"` + UpdatedAt int64 `json:"updated_at"` +} + +type migrationSubscription struct { + ID string `json:"id"` + CustomerID string `json:"customer_id"` + ProductID string `json:"product_id"` + Store string `json:"store"` + Environment string `json:"environment"` + StoreSubscriptionIdentifier string `json:"store_subscription_identifier"` + Status string `json:"status"` + GivesAccess bool `json:"gives_access"` + UpdatedAt int64 `json:"updated_at"` + Entitlements json.RawMessage `json:"entitlements"` + Ownership json.RawMessage `json:"ownership"` +} + +type migrationProduct struct { + ID, StoreIdentifier, AppID string + AppType string + UpdatedAt int64 +} + +// PullMigrationEvidence follows the documented RevenueCat v2 resource graph. +// Each raw response body is framed with its endpoint/resource/cursor boundary; +// the raw bytes themselves are copied without JSON re-encoding. +func (c *Client) PullMigrationEvidence(ctx context.Context, externalProjectID string, secret []byte, startingAfter string, output io.Writer) (MigrationPullResult, error) { + if externalProjectID == "" || len(secret) == 0 || output == nil { + return MigrationPullResult{}, billingmigration.ErrInvalid + } + ctx, cancel := context.WithTimeout(ctx, c.operationTimeout) + defer cancel() + budget := operationBudget{remainingPages: c.maxPages, remainingRetries: c.maxRetries} + result := MigrationPullResult{} + hash := sha256.New() + seen := make(map[string]struct{}) + appendRecord := func(record MigrationPullRecord) { + key := record.Kind + "\x1f" + record.SourceIdentifier + "\x1f" + record.SourceRevision + "\x1f" + hex.EncodeToString(record.Digest) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + result.Records = append(result.Records, record) + result.RecordCount++ + if record.CurrentAccess { + result.CurrentAccessCount++ + } + } + fetch := func(path, resource, initialCursor string, consume func(json.RawMessage, string) error) (string, []byte, error) { + cursor := initialCursor + terminalCursor := initialCursor + var last []byte + for { + if !budget.takePage() { + return "", nil, &providercatalog.Error{Code: providercatalog.ErrorInvalidResponse} + } + query := url.Values{"limit": {strconv.Itoa(defaultPageLimit)}} + if cursor != "" { + query.Set("starting_after", cursor) + } + var raw json.RawMessage + if err := c.get(ctx, &budget, secret, path, query, &raw); err != nil { + return "", nil, err + } + if err := writeMigrationEvidencePage(output, hash, path, resource, cursor, raw); err != nil { + return "", nil, err + } + result.Pages = append(result.Pages, MigrationEvidencePage{Endpoint: path, Resource: resource, Cursor: cursor, Body: append([]byte(nil), raw...)}) + var page listResponse[json.RawMessage] + if err := json.Unmarshal(raw, &page); err != nil { + return "", nil, &providercatalog.Error{Code: providercatalog.ErrorInvalidResponse} + } + for _, item := range page.Items { + if err := consume(item, cursor); err != nil { + return "", nil, &providercatalog.Error{Code: providercatalog.ErrorInvalidResponse} + } + // RevenueCat documents starting_after as the ID of the last + // object from the previous page. next_page is absent on the + // terminal page, so its final item ID is the only correct resume + // cursor for a later delta. It is treated as an opaque ID after + // decoding; Mosaic never infers order or meaning from its shape. + var identity struct { + ID string `json:"id"` + } + if json.Unmarshal(item, &identity) != nil || identity.ID == "" || invalidSourceID(identity.ID) { + return "", nil, &providercatalog.Error{Code: providercatalog.ErrorInvalidResponse} + } + terminalCursor = identity.ID + } + last = append(last[:0], raw...) + if page.NextPage == "" { + return terminalCursor, last, nil + } + next, err := opaqueCursor(page.NextPage) + if err != nil || next == "" || next == cursor { + return "", nil, &providercatalog.Error{Code: providercatalog.ErrorInvalidResponse} + } + cursor = next + } + } + + customersPath := "/projects/" + url.PathEscape(externalProjectID) + "/customers" + resume, finalCustomerPage, err := fetch(customersPath, "customers", startingAfter, func(raw json.RawMessage, cursor string) error { + var customer migrationCustomer + if json.Unmarshal(raw, &customer) != nil || customer.ID == "" || invalidSourceID(customer.ID) { + return errors.New("invalid customer") + } + appendRecord(migrationRecord("customer", customer.ID, customer.UpdatedAt, raw, false, cursor)) + if customer.OriginalCustomerID != "" { + if invalidSourceID(customer.OriginalCustomerID) { + return errors.New("invalid original customer") + } + record := migrationRecord("customer", customer.OriginalCustomerID, customer.UpdatedAt, raw, false, cursor) + record.CustomerID = customer.ID + appendRecord(record) + } + subscriptionsPath := "/projects/" + url.PathEscape(externalProjectID) + "/customers/" + url.PathEscape(customer.ID) + "/subscriptions" + _, _, nestedErr := fetch(subscriptionsPath, "customer:"+customer.ID+":subscriptions", "", func(subscriptionRaw json.RawMessage, nestedCursor string) error { + var sub migrationSubscription + if json.Unmarshal(subscriptionRaw, &sub) != nil || sub.ID == "" || invalidSourceID(sub.ID) || sub.ProductID == "" || invalidSourceID(sub.ProductID) { + return errors.New("invalid subscription") + } + if sub.CustomerID == "" { + sub.CustomerID = customer.ID + } + record := migrationRecord("subscription", sub.ID, sub.UpdatedAt, subscriptionRaw, sub.GivesAccess, nestedCursor) + record.CustomerID, record.ProductID, record.Store, record.Environment = sub.CustomerID, sub.ProductID, sub.Store, sub.Environment + record.ProviderReference = sub.StoreSubscriptionIdentifier + record.EntitlementIDs = entitlementIDs(sub.Entitlements) + record.Ownership = append(json.RawMessage(nil), sub.Ownership...) + record.Provider, record.Platform, record.ReferenceKind, record.QuarantineReason = normalizeStoreReference(sub.Store, sub.StoreSubscriptionIdentifier) + appendRecord(record) + return nil + }) + if nestedErr != nil { + return nestedErr + } + aliasesPath := "/projects/" + url.PathEscape(externalProjectID) + "/customers/" + url.PathEscape(customer.ID) + "/aliases" + _, _, nestedErr = fetch(aliasesPath, "customer:"+customer.ID+":aliases", "", func(aliasRaw json.RawMessage, nestedCursor string) error { + var alias migrationAlias + if json.Unmarshal(aliasRaw, &alias) != nil || alias.ID == "" || invalidSourceID(alias.ID) { + return errors.New("invalid alias") + } + record := migrationRecord("alias", alias.ID, alias.UpdatedAt, aliasRaw, false, nestedCursor) + record.CustomerID = customer.ID + appendRecord(record) + return nil + }) + return nestedErr + }) + if err != nil { + return MigrationPullResult{}, err + } + result.ResumeCursor = resume + productsPath := "/projects/" + url.PathEscape(externalProjectID) + "/products" + // expand is part of the official resource request and therefore part of the endpoint boundary. + fetchProducts := func() error { + cursor := "" + for { + if !budget.takePage() { + return &providercatalog.Error{Code: providercatalog.ErrorInvalidResponse} + } + query := url.Values{"limit": {strconv.Itoa(defaultPageLimit)}, "expand": {"items.app"}} + if cursor != "" { + query.Set("starting_after", cursor) + } + var raw json.RawMessage + if err := c.get(ctx, &budget, secret, productsPath, query, &raw); err != nil { + return err + } + if err := writeMigrationEvidencePage(output, hash, productsPath+"?expand=items.app", "products", cursor, raw); err != nil { + return err + } + result.Pages = append(result.Pages, MigrationEvidencePage{Endpoint: productsPath + "?expand=items.app", Resource: "products", Cursor: cursor, Body: append([]byte(nil), raw...)}) + var page listResponse[json.RawMessage] + if json.Unmarshal(raw, &page) != nil { + return &providercatalog.Error{Code: providercatalog.ErrorInvalidResponse} + } + for _, item := range page.Items { + product, parseErr := parseMigrationProduct(item) + if parseErr != nil { + return &providercatalog.Error{Code: providercatalog.ErrorInvalidResponse} + } + record := migrationRecord("product", product.ID, product.UpdatedAt, item, false, cursor) + record.ProductID, record.ExternalAppID, record.Store = product.ID, product.AppID, product.AppType + record.ProviderReference = product.StoreIdentifier + record.StoreIdentifier = product.StoreIdentifier + record.Provider, record.Platform, _, record.QuarantineReason = normalizeStoreReference(product.AppType, "") + appendRecord(record) + } + if page.NextPage == "" { + return nil + } + next, parseErr := opaqueCursor(page.NextPage) + if parseErr != nil || next == "" || next == cursor { + return &providercatalog.Error{Code: providercatalog.ErrorInvalidResponse} + } + cursor = next + } + } + if err := fetchProducts(); err != nil { + return MigrationPullResult{}, err + } + final := sha256.Sum256(append([]byte("revenuecat-v2-final-page\x1f"), finalCustomerPage...)) + result.FinalWatermark = "rcv2:sha256:" + hex.EncodeToString(final[:]) + result.ProvenCapabilities = migrationPullCapabilities(result.Pages) + sort.Slice(result.Records, func(i, j int) bool { + a, b := result.Records[i], result.Records[j] + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + if a.SourceIdentifier != b.SourceIdentifier { + return a.SourceIdentifier < b.SourceIdentifier + } + return a.SourceRevision < b.SourceRevision + }) + result.EvidenceDigest = hash.Sum(nil) + return result, nil +} + +func migrationPullCapabilities(pages []MigrationEvidencePage) []string { + seen := map[string]bool{} + for _, page := range pages { + switch { + case page.Resource == "customers": + seen[billingmigration.SourceCapabilityReadCustomers] = true + if page.Cursor != "" { + seen[billingmigration.SourceCapabilityIncrementalDelta] = true + } + case strings.HasSuffix(page.Resource, ":subscriptions"): + seen[billingmigration.SourceCapabilityReadSubscriptions] = true + case strings.HasSuffix(page.Resource, ":aliases"): + seen[billingmigration.SourceCapabilityReadAliases] = true + } + } + capabilities := make([]string, 0, len(seen)) + for capability := range seen { + capabilities = append(capabilities, capability) + } + sort.Strings(capabilities) + return capabilities +} + +func writeMigrationEvidencePage(output io.Writer, digest io.Writer, endpoint, resource, cursor string, raw []byte) error { + for _, writer := range []io.Writer{output, digest} { + if _, err := writer.Write([]byte("MOSAIC-RC-V2-PAGE\x00")); err != nil { + return err + } + for _, value := range [][]byte{[]byte(endpoint), []byte(resource), []byte(cursor), raw} { + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(value))) + if _, err := writer.Write(size[:]); err != nil { + return err + } + if _, err := writer.Write(value); err != nil { + return err + } + } + } + return nil +} + +func parseMigrationProduct(raw json.RawMessage) (migrationProduct, error) { + var wire struct { + ID, StoreIdentifier, AppID string + UpdatedAt int64 `json:"updated_at"` + App json.RawMessage `json:"app"` + } + if err := json.Unmarshal(raw, &wire); err != nil { + return migrationProduct{}, err + } + // Go field-name matching does not translate underscores. + var fields struct { + ID string `json:"id"` + StoreIdentifier string `json:"store_identifier"` + AppID string `json:"app_id"` + UpdatedAt int64 `json:"updated_at"` + App struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"app"` + } + if err := json.Unmarshal(raw, &fields); err != nil || fields.ID == "" || fields.StoreIdentifier == "" || fields.App.ID == "" || fields.App.Type == "" || invalidSourceID(fields.ID) || invalidSourceID(fields.StoreIdentifier) || invalidSourceID(fields.App.ID) { + return migrationProduct{}, errors.New("invalid product") + } + return migrationProduct{ID: fields.ID, StoreIdentifier: fields.StoreIdentifier, AppID: fields.App.ID, AppType: fields.App.Type, UpdatedAt: fields.UpdatedAt}, nil +} + +func normalizeStoreReference(store, reference string) (provider, platform, referenceKind, quarantine string) { + switch store { + case "app_store", "mac_app_store": + return "app_store", "ios", "app_store_transaction_id", "" + case "play_store": + // RevenueCat v2 exposes an order id here, not a Google purchase token. + return "google_play", "android", "google_play_order_id", "" + default: + return "", "", "", "unsupported_store" + } +} + +func entitlementIDs(raw json.RawMessage) []string { + var list []string + if json.Unmarshal(raw, &list) == nil { + sort.Strings(list) + return list + } + var page listResponse[struct { + ID string `json:"id"` + }] + if json.Unmarshal(raw, &page) == nil { + for _, item := range page.Items { + if item.ID != "" && !invalidSourceID(item.ID) { + list = append(list, item.ID) + } + } + } + sort.Strings(list) + return list +} + +func migrationRecord(kind, id string, revision int64, value any, current bool, cursor string) MigrationPullRecord { + canonical, _ := json.Marshal(value) + digest := sha256.Sum256(canonical) + return MigrationPullRecord{Kind: kind, SourceIdentifier: id, SourceRevision: fmt.Sprintf("%d:%x", revision, digest[:]), Cursor: cursor, Digest: digest[:], CurrentAccess: current, ObservedAt: time.UnixMilli(revision).UTC()} +} + +func opaqueCursor(nextPage string) (string, error) { + parsed, err := url.Parse(nextPage) + if err != nil { + return "", err + } + values, ok := parsed.Query()["starting_after"] + if !ok || len(values) != 1 { + return "", errors.New("missing cursor") + } + return values[0], nil +} + +func invalidSourceID(value string) bool { + if len(value) > 512 { + return true + } + for _, r := range value { + if r <= 0x1f || r == 0x7f { + return true + } + } + return false +} diff --git a/apps/api/internal/platform/revenuecat/migration_import_test.go b/apps/api/internal/platform/revenuecat/migration_import_test.go new file mode 100644 index 00000000..a7dc238b --- /dev/null +++ b/apps/api/internal/platform/revenuecat/migration_import_test.go @@ -0,0 +1,124 @@ +package revenuecat + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func TestMigrationPullUsesOfficialV2GraphAndPreservesExactPages(t *testing.T) { + var mu sync.Mutex + requests := make([]string, 0) + unknownRaw := []byte(`{"items":[{"id":"alias_A","updated_at":2000}],"provider_unknown":{"escaped":"a\\u0062"},"next_page":""}`) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.URL.RequestURI()) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/v2/projects/rc_project/customers" && r.URL.Query().Get("starting_after") == "opaque+/root=": + _, _ = w.Write([]byte(`{"items":[{"id":"customer_B","updated_at":3000}],"next_page":""}`)) + case r.URL.Path == "/v2/projects/rc_project/customers": + if r.URL.Query().Get("expand") != "" { + t.Errorf("invented customer expand: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"items":[{"id":"customer_A","original_customer_id":"original_A","updated_at":2000}],"next_page":"https://api.revenuecat.com/v2/projects/p/customers?starting_after=opaque%2B%2Froot%3D"}`)) + case r.URL.Path == "/v2/projects/rc_project/customers/customer_A/subscriptions" && r.URL.Query().Get("starting_after") == "nested": + _, _ = w.Write([]byte(`{"items":[{"id":"subscription_unsupported","customer_id":"customer_A","product_id":"product_other","store":"stripe","store_subscription_identifier":"opaque","gives_access":true,"updated_at":2100}],"next_page":""}`)) + case r.URL.Path == "/v2/projects/rc_project/customers/customer_A/subscriptions": + _, _ = w.Write([]byte(`{"items":[{"id":"subscription_A","customer_id":"customer_A","product_id":"product_A","store":"play_store","environment":"production","store_subscription_identifier":"GPA.opaque.order","gives_access":true,"updated_at":2000,"entitlements":{"items":[{"id":"pro"}]},"ownership":{"type":"purchased"}}],"next_page":"https://api.revenuecat.com/v2/projects/rc_project/customers/customer_A/subscriptions?starting_after=nested"}`)) + case r.URL.Path == "/v2/projects/rc_project/customers/customer_A/aliases": + _, _ = w.Write(unknownRaw) + case r.URL.Path == "/v2/projects/rc_project/customers/customer_B/subscriptions" || r.URL.Path == "/v2/projects/rc_project/customers/customer_B/aliases": + _, _ = w.Write([]byte(`{"items":[],"next_page":""}`)) + case r.URL.Path == "/v2/projects/rc_project/products": + if r.URL.Query().Get("expand") != "items.app" { + t.Errorf("products expand = %q", r.URL.Query().Get("expand")) + } + _, _ = w.Write([]byte(`{"items":[{"id":"product_A","store_identifier":"sku.a","app_id":"rc_app_android","updated_at":2000,"app":{"id":"rc_app_android","type":"play_store"}},{"id":"product_other","store_identifier":"sku.other","app_id":"rc_app_other","app":{"id":"rc_app_other","type":"stripe"}}],"next_page":""}`)) + default: + http.Error(w, r.URL.RequestURI(), http.StatusNotFound) + } + })) + defer server.Close() + client, err := New(Config{BaseURL: server.URL + "/v2", RequestTimeout: time.Second, OperationTimeout: 5 * time.Second}) + if err != nil { + t.Fatal(err) + } + var evidence bytes.Buffer + result, err := client.PullMigrationEvidence(context.Background(), "rc_project", []byte("secret"), "", &evidence) + if err != nil { + t.Fatal(err) + } + if result.ResumeCursor != "customer_B" || result.RecordCount != 8 || result.CurrentAccessCount != 2 { + t.Fatalf("result = %#v", result) + } + if strings.Join(result.ProvenCapabilities, ",") != "incremental_delta,read_aliases,read_customers,read_subscriptions" { + t.Fatalf("proven capabilities = %#v", result.ProvenCapabilities) + } + if !bytes.Contains(evidence.Bytes(), unknownRaw) { + t.Fatal("exact raw alias page was not retained") + } + var play, unsupported bool + for _, record := range result.Records { + switch record.SourceIdentifier { + case "subscription_A": + play = record.Provider == "google_play" && record.Platform == "android" && record.Environment == "production" && record.ReferenceKind == "google_play_order_id" && len(record.EntitlementIDs) == 1 && record.EntitlementIDs[0] == "pro" + case "subscription_unsupported": + unsupported = record.Provider == "" && record.QuarantineReason == "unsupported_store" + } + } + if !play || !unsupported { + t.Fatalf("normalization play=%v unsupported=%v records=%#v", play, unsupported, result.Records) + } + joined := strings.Join(requests, "\n") + if strings.Contains(joined, "transfers") || strings.Contains(joined, "/v2/customers/") || !strings.Contains(joined, "/projects/rc_project/customers/customer_A/subscriptions") || !strings.Contains(joined, "/projects/rc_project/customers/customer_A/aliases") { + t.Fatalf("resource graph:\n%s", joined) + } +} + +func TestMigrationPullSinglePageResumesAfterTerminalCustomer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v2/projects/rc_project/customers": + _, _ = w.Write([]byte(`{"items":[{"id":"only_customer","updated_at":3000}],"next_page":""}`)) + case r.URL.Path == "/v2/projects/rc_project/customers/only_customer/subscriptions", + r.URL.Path == "/v2/projects/rc_project/customers/only_customer/aliases", + r.URL.Path == "/v2/projects/rc_project/products": + _, _ = w.Write([]byte(`{"items":[],"next_page":""}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client, err := New(Config{BaseURL: server.URL + "/v2", RequestTimeout: time.Second, OperationTimeout: time.Second}) + if err != nil { + t.Fatal(err) + } + result, err := client.PullMigrationEvidence(context.Background(), "rc_project", []byte("secret"), "", &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if result.ResumeCursor != "only_customer" { + t.Fatalf("resume cursor = %q", result.ResumeCursor) + } +} + +func TestMigrationPullRejectsMalformedCustomerIdentity(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"items":[{"id":"bad\ncustomer"}],"next_page":""}`)) + })) + defer server.Close() + client, err := New(Config{BaseURL: server.URL + "/v2", RequestTimeout: time.Second, OperationTimeout: time.Second}) + if err != nil { + t.Fatal(err) + } + if _, err = client.PullMigrationEvidence(context.Background(), "rc_project", []byte("secret"), "", &bytes.Buffer{}); err == nil { + t.Fatal("malformed source identifier was accepted") + } +} diff --git a/apps/api/internal/platform/revenuecat/migration_test.go b/apps/api/internal/platform/revenuecat/migration_test.go new file mode 100644 index 00000000..b717bcdc --- /dev/null +++ b/apps/api/internal/platform/revenuecat/migration_test.go @@ -0,0 +1,186 @@ +package revenuecat + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +func TestAssessMigrationEarnsReadOnlyMigrationCapabilities(t *testing.T) { + var mu sync.Mutex + requests := make([]string, 0) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + mu.Lock() + requests = append(requests, request.URL.RequestURI()) + mu.Unlock() + if request.Header.Get("Authorization") != "Bearer migration-secret" { + t.Fatal("migration credential was not sent as bearer authentication") + } + w.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/v2/projects/rc_project/customers": + if request.URL.Query().Get("limit") != "1" { + t.Fatalf("customer limit = %q", request.URL.Query().Get("limit")) + } + if request.URL.Query().Get("starting_after") == "customer_cursor" { + _, _ = w.Write([]byte(`{"items":[{"id":"customer_next"}],"next_page":""}`)) + return + } + if request.URL.Query().Get("starting_after") != "" { + t.Fatalf("unexpected customer cursor = %q", request.URL.Query().Get("starting_after")) + } + _, _ = w.Write([]byte(`{"items":[{"id":"customer_A"}],"next_page":"https://api.revenuecat.com/v2/projects/other/customers?starting_after=customer_cursor"}`)) + case "/v2/projects/rc_project/customers/customer_A/subscriptions", + "/v2/projects/rc_project/customers/customer_A/aliases": + if request.URL.Query().Get("limit") != "1" { + t.Fatalf("nested limit = %q", request.URL.Query().Get("limit")) + } + _, _ = w.Write([]byte(`{"items":[],"next_page":""}`)) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + client := migrationAssessmentClient(t, server) + assessedAt := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) + client.now = func() time.Time { return assessedAt } + result, err := client.AssessMigration(context.Background(), "rc_project", []byte("migration-secret")) + if err != nil { + t.Fatal(err) + } + want := []string{"read_customers", "read_subscriptions", "read_aliases", "incremental_delta"} + if result.ProviderAPIVersion != billingmigration.ProviderAPIV2 || result.AssessedAt != assessedAt || strings.Join(result.Capabilities, ",") != strings.Join(want, ",") { + t.Fatalf("assessment = %#v", result) + } + mu.Lock() + defer mu.Unlock() + joined := strings.Join(requests, "\n") + if strings.Contains(joined, "transfers") || strings.Contains(joined, "/v2/customers/") || + !strings.Contains(joined, "/projects/rc_project/customers/customer_A/subscriptions?limit=1") || + !strings.Contains(joined, "/projects/rc_project/customers/customer_A/aliases?limit=1") || + !strings.Contains(joined, "/projects/rc_project/customers?limit=1&starting_after=customer_cursor") { + t.Fatalf("assessment used wrong resource graph:\n%s", joined) + } +} + +func TestAssessMigrationPermissionFailuresFailClosedPerProbe(t *testing.T) { + tests := []struct { + name string + deniedPath string + deniedQuery string + wantErr error + wantCapabilities []string + }{ + { + name: "customers", + deniedPath: "/v2/projects/rc_project/customers", + wantErr: billingmigration.ErrInvalid, + deniedQuery: "limit=1", + }, + { + name: "subscriptions", + deniedPath: "/v2/projects/rc_project/customers/customer_A/subscriptions", + wantCapabilities: []string{"read_customers", "read_aliases", "incremental_delta"}, + }, + { + name: "aliases", + deniedPath: "/v2/projects/rc_project/customers/customer_A/aliases", + wantCapabilities: []string{"read_customers", "read_subscriptions", "incremental_delta"}, + }, + { + name: "delta cursor", + deniedPath: "/v2/projects/rc_project/customers", + deniedQuery: "limit=1&starting_after=customer_A", + wantCapabilities: []string{"read_customers", "read_subscriptions", "read_aliases"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + w.Header().Set("Content-Type", "application/json") + if request.URL.Path == tt.deniedPath && (tt.deniedQuery == "" || request.URL.RawQuery == tt.deniedQuery) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"denied"}`)) + return + } + switch request.URL.Path { + case "/v2/projects/rc_project/customers": + _, _ = w.Write([]byte(`{"items":[{"id":"customer_A"}],"next_page":""}`)) + case "/v2/projects/rc_project/customers/customer_A/subscriptions", + "/v2/projects/rc_project/customers/customer_A/aliases": + _, _ = w.Write([]byte(`{"items":[],"next_page":""}`)) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + client := migrationAssessmentClient(t, server) + result, err := client.AssessMigration(context.Background(), "rc_project", []byte("migration-secret")) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("error = %v, want %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if strings.Join(result.Capabilities, ",") != strings.Join(tt.wantCapabilities, ",") { + t.Fatalf("capabilities = %#v", result.Capabilities) + } + }) + } +} + +func TestAssessMigrationEmptyProjectDoesNotInventCustomerScopedCapabilities(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + requests++ + if request.URL.Path != "/v2/projects/rc_project/customers" { + t.Fatalf("unexpected path for empty assessment: %s", request.URL.RequestURI()) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"next_page":""}`)) + })) + defer server.Close() + client := migrationAssessmentClient(t, server) + result, err := client.AssessMigration(context.Background(), "rc_project", []byte("migration-secret")) + if err != nil { + t.Fatal(err) + } + if requests != 1 || strings.Join(result.Capabilities, ",") != "read_customers" { + t.Fatalf("empty assessment requests=%d result=%#v", requests, result) + } +} + +func TestAssessMigrationDoesNotOverclaimDeltaOnMalformedCursor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v2/projects/rc_project/customers" { + http.NotFound(w, request) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"id":"customer_A"}],"next_page":"https://api.revenuecat.com/v2/projects/rc_project/customers"}`)) + })) + defer server.Close() + client := migrationAssessmentClient(t, server) + if _, err := client.AssessMigration(context.Background(), "rc_project", []byte("migration-secret")); !errors.Is(err, billingmigration.ErrUnavailable) { + t.Fatalf("malformed cursor error = %v", err) + } +} + +func migrationAssessmentClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + client, err := New(Config{BaseURL: server.URL + "/v2", RequestTimeout: time.Second, OperationTimeout: time.Second, MaxAttempts: 1}) + if err != nil { + t.Fatal(err) + } + return client +} diff --git a/apps/api/internal/platform/telemetry/export_paths_test.go b/apps/api/internal/platform/telemetry/export_paths_test.go new file mode 100644 index 00000000..9e13ab0b --- /dev/null +++ b/apps/api/internal/platform/telemetry/export_paths_test.go @@ -0,0 +1,190 @@ +package telemetry_test + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "sort" + "sync" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/logging" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/telemetry" + "go.opentelemetry.io/otel" + collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1" + colmetricpb "go.opentelemetry.io/proto/otlp/collector/metrics/v1" + coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/grpc" +) + +// An OTLP collector that records the paths it is asked for, and 404s anything that is +// not a real OTLP signal path — the same way the live collector did. +func TestExportersPostToSignalPaths(t *testing.T) { + var mutex sync.Mutex + seen := map[string]int{} + valid := map[string]bool{"/v1/traces": true, "/v1/metrics": true, "/v1/logs": true} + + collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mutex.Lock() + seen[r.URL.Path]++ + mutex.Unlock() + if !valid[r.URL.Path] { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + })) + defer collector.Close() + + shutdown, err := telemetry.New(context.Background(), telemetry.Config{ + ServiceName: "mosaic-api-test", + Environment: "test", + OTLPEndpoint: collector.URL, + }) + if err != nil { + t.Fatalf("configure telemetry: %v", err) + } + + logger, err := logging.NewExporting("info", "json", &discard{}, "mosaic-api-test") + if err != nil { + t.Fatalf("build exporting logger: %v", err) + } + logger.Info().Str("check", "paths").Msg("hello collector") + + _, span := otel.Tracer("mosaic-api-test").Start(context.Background(), "check") + span.End() + + counter, err := otel.Meter("mosaic-api-test").Int64Counter("pathcheck.runs") + if err != nil { + t.Fatalf("create counter: %v", err) + } + counter.Add(context.Background(), 1) + + shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := shutdown(shutdownContext); err != nil { + t.Fatalf("shutdown telemetry: %v", err) + } + + mutex.Lock() + defer mutex.Unlock() + paths := make([]string, 0, len(seen)) + for path := range seen { + paths = append(paths, path) + } + sort.Strings(paths) + t.Logf("collector received: %v", paths) + for _, want := range []string{"/v1/logs", "/v1/metrics", "/v1/traces"} { + if seen[want] == 0 { + t.Fatalf("collector never received %s; got %v", want, paths) + } + } + for path := range seen { + if !valid[path] { + t.Fatalf("collector received a request on %s, which it 404s", path) + } + } +} + +type discard struct{} + +func (discard) Write(p []byte) (int, error) { return len(p), nil } + +// The gRPC transport takes the same base endpoint. Its exporters are handed the +// signal URL the HTTP transport needs, so this pins the fact that the appended +// signal path is inert over gRPC: the RPC still lands on the collector's fixed +// service method rather than a path the server has never heard of. +func TestGRPCExportersReachTheCollectorFromABaseEndpoint(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + exported := make(chan string, 3) + server := grpc.NewServer() + coltracepb.RegisterTraceServiceServer(server, traceCollector{exported: exported}) + colmetricpb.RegisterMetricsServiceServer(server, metricCollector{exported: exported}) + collogspb.RegisterLogsServiceServer(server, logCollector{exported: exported}) + go func() { _ = server.Serve(listener) }() + defer server.Stop() + + shutdown, err := telemetry.New(context.Background(), telemetry.Config{ + ServiceName: "mosaic-api-test", + Environment: "test", + OTLPEndpoint: "http://" + listener.Addr().String(), + OTLPProtocol: telemetry.ProtocolGRPC, + }) + if err != nil { + t.Fatalf("configure telemetry: %v", err) + } + + logger, err := logging.NewExporting("info", "json", &discard{}, "mosaic-api-test") + if err != nil { + t.Fatalf("build exporting logger: %v", err) + } + logger.Info().Str("check", "grpc").Msg("hello collector") + + _, span := otel.Tracer("mosaic-api-test").Start(context.Background(), "check") + span.End() + + counter, err := otel.Meter("mosaic-api-test").Int64Counter("grpccheck.runs") + if err != nil { + t.Fatalf("create counter: %v", err) + } + counter.Add(context.Background(), 1) + + shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := shutdown(shutdownContext); err != nil { + t.Fatalf("shutdown telemetry: %v", err) + } + + received := map[string]bool{} + for len(received) < 3 { + select { + case signal := <-exported: + received[signal] = true + case <-time.After(5 * time.Second): + t.Fatalf("collector received %v over gRPC, want traces, metrics, and logs", received) + } + } +} + +// The three collectors share one channel; a service per type because the OTLP +// export RPCs all have the same method name. +type traceCollector struct { + coltracepb.UnimplementedTraceServiceServer + exported chan string +} + +func (c traceCollector) Export( + _ context.Context, _ *coltracepb.ExportTraceServiceRequest, +) (*coltracepb.ExportTraceServiceResponse, error) { + c.exported <- "traces" + return &coltracepb.ExportTraceServiceResponse{}, nil +} + +type metricCollector struct { + colmetricpb.UnimplementedMetricsServiceServer + exported chan string +} + +func (c metricCollector) Export( + _ context.Context, _ *colmetricpb.ExportMetricsServiceRequest, +) (*colmetricpb.ExportMetricsServiceResponse, error) { + c.exported <- "metrics" + return &colmetricpb.ExportMetricsServiceResponse{}, nil +} + +type logCollector struct { + collogspb.UnimplementedLogsServiceServer + exported chan string +} + +func (c logCollector) Export( + _ context.Context, _ *collogspb.ExportLogsServiceRequest, +) (*collogspb.ExportLogsServiceResponse, error) { + c.exported <- "logs" + return &collogspb.ExportLogsServiceResponse{}, nil +} diff --git a/apps/api/internal/platform/telemetry/telemetry.go b/apps/api/internal/platform/telemetry/telemetry.go index 96384c4f..43f635bc 100644 --- a/apps/api/internal/platform/telemetry/telemetry.go +++ b/apps/api/internal/platform/telemetry/telemetry.go @@ -2,27 +2,72 @@ package telemetry import ( "context" + "crypto/tls" "errors" "fmt" + "net/url" + "strings" "github.com/rs/zerolog" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/log/global" "go.opentelemetry.io/otel/propagation" + sdklog "go.opentelemetry.io/otel/sdk/log" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.41.0" + "google.golang.org/grpc/credentials" "github.com/Mujhtech/mosaic/apps/api/internal/platform/buildinfo" ) +// Supported values for Config.OTLPProtocol. They mirror the OpenTelemetry +// specification's OTEL_EXPORTER_OTLP_PROTOCOL values so an operator can reuse +// the vendor documentation of whichever collector they point Mosaic at. +const ( + ProtocolHTTP = "http/protobuf" + ProtocolGRPC = "grpc" +) + +// DefaultProtocol is what an unset OTLPProtocol resolves to. HTTP stays the +// default because it survives proxies and ingress that do not speak HTTP/2, +// which is how most hosted collectors are reached. +const DefaultProtocol = ProtocolHTTP + type Config struct { ServiceName string Environment string OTLPEndpoint string + // OTLPProtocol selects the OTLP transport: "http/protobuf" (default) or + // "grpc". Both carry the same payloads; only the wire transport differs, + // and the endpoint port usually does too (4318 for HTTP, 4317 for gRPC). + OTLPProtocol string + // OTLPHeaders carries per-request export headers in the OpenTelemetry + // specification's format ("key1=value1,key2=value2", values + // percent-encoded). This is where a hosted collector's API key or bearer + // token goes, so the values are secrets: they are never logged, and never + // appear in an error returned from this package. + OTLPHeaders string + // OTLPTLSSkipVerify disables certificate verification for an https:// + // endpoint. It exists for collectors behind a private CA or a self-signed + // certificate; it is not a way to run TLS "loosely" on the public internet, + // because an unverified connection cannot tell the collector apart from + // anything that intercepts it — including whatever OTLPHeaders is sent to. + // It has no effect on an http:// endpoint, which is plaintext regardless. + OTLPTLSSkipVerify bool + // DisableLogExport stops log records from being exported while leaving + // traces and metrics alone. Log volume is the expensive signal at most + // vendors, so an operator needs to turn it off without giving up tracing. + // The local log stream is unaffected either way. + DisableLogExport bool // Logger receives OpenTelemetry's own internal errors (export failures, // dropped batches). Without it the SDK writes them to the standard library // logger, which bypasses Mosaic's JSON log stream and makes exporter @@ -57,37 +102,293 @@ func New(ctx context.Context, cfg Config) (Shutdown, error) { sdktrace.WithResource(serviceResource), } metricOptions := []sdkmetric.Option{sdkmetric.WithResource(serviceResource)} + logOptions := []sdklog.LoggerProviderOption{sdklog.WithResource(serviceResource)} if cfg.OTLPEndpoint != "" { - exporter, err := otlptracehttp.New( - ctx, - otlptracehttp.WithEndpointURL(cfg.OTLPEndpoint), - ) + export, err := resolveExport(cfg) if err != nil { - return nil, fmt.Errorf("create OTLP HTTP trace exporter: %w", err) + return nil, err } - options = append(options, sdktrace.WithBatcher(exporter)) - metricExporter, err := otlpmetrichttp.New(ctx, otlpmetrichttp.WithEndpointURL(cfg.OTLPEndpoint)) + traceExporter, err := newTraceExporter(ctx, export) if err != nil { - return nil, fmt.Errorf("create OTLP HTTP metric exporter: %w", err) + return nil, fmt.Errorf("create OTLP %s trace exporter: %w", export.protocol, err) + } + options = append(options, sdktrace.WithBatcher(traceExporter)) + metricExporter, err := newMetricExporter(ctx, export) + if err != nil { + return nil, fmt.Errorf("create OTLP %s metric exporter: %w", export.protocol, err) } metricOptions = append(metricOptions, sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter))) + if !cfg.DisableLogExport { + logExporter, err := newLogExporter(ctx, export) + if err != nil { + return nil, fmt.Errorf("create OTLP %s log exporter: %w", export.protocol, err) + } + logOptions = append(logOptions, sdklog.WithProcessor(sdklog.NewBatchProcessor(logExporter))) + } } provider := sdktrace.NewTracerProvider(options...) otel.SetTracerProvider(provider) metricProvider := sdkmetric.NewMeterProvider(metricOptions...) otel.SetMeterProvider(metricProvider) + // Installing the logger provider globally is what connects an already-built + // exporting logger to the pipeline: until this call its records go to the + // no-op provider and appear on the local stream only. + logProvider := sdklog.NewLoggerProvider(logOptions...) + global.SetLoggerProvider(logProvider) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, )) return func(ctx context.Context) error { - return errors.Join(metricProvider.Shutdown(ctx), provider.Shutdown(ctx)) + // Logs flush first: the last records a shutdown produces are the ones an + // operator most wants to see, and they are the cheapest signal to drain. + return errors.Join( + logProvider.Shutdown(ctx), + metricProvider.Shutdown(ctx), + provider.Shutdown(ctx), + ) }, nil } +// NormalizeProtocol resolves an operator-supplied protocol name to one of the +// supported constants, treating the empty value as the default. Configuration +// loading uses it so an unsupported protocol is reported at startup validation +// alongside every other config problem rather than failing later in New. +func NormalizeProtocol(value string) (string, error) { + return normalizeProtocol(value) +} + +func normalizeProtocol(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "": + return DefaultProtocol, nil + // "http" is not a spec value, but it is the obvious thing to write and + // rejecting it would only cost an operator a deploy cycle to learn. + case ProtocolHTTP, "http": + return ProtocolHTTP, nil + case ProtocolGRPC: + return ProtocolGRPC, nil + default: + return "", fmt.Errorf( + "unsupported OTLP protocol %q: want %q or %q", + value, ProtocolHTTP, ProtocolGRPC, + ) + } +} + +// ParseHeaders decodes the OpenTelemetry OTLP header format — +// "key1=value1,key2=value2" with percent-encoded values — into the map the +// exporters take. An empty input yields no headers. Errors name the offending +// key at most, never a value, because values are credentials. +func ParseHeaders(value string) (map[string]string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, nil + } + headers := make(map[string]string) + for _, pair := range strings.Split(trimmed, ",") { + if strings.TrimSpace(pair) == "" { + continue + } + key, encoded, found := strings.Cut(pair, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return nil, errors.New("OTLP headers must be a comma-separated list of key=value pairs") + } + decoded, err := url.QueryUnescape(strings.TrimSpace(encoded)) + if err != nil { + return nil, fmt.Errorf("OTLP header %q has a malformed percent-encoded value", key) + } + headers[key] = decoded + } + return headers, nil +} + +// Per-signal OTLP paths. OTEL_EXPORTER_OTLP_ENDPOINT is a base endpoint and +// each signal hangs off it, exactly as the OpenTelemetry specification defines +// the variable. +const ( + tracesPath = "/v1/traces" + metricsPath = "/v1/metrics" + logsPath = "/v1/logs" +) + +// exportSettings is the resolved, validated shape of the export configuration: +// everything the exporter constructors need, already normalized. +type exportSettings struct { + protocol string + scheme string + host string + // basePath is any path prefix the collector sits behind, without a trailing + // slash. It is usually empty; a gateway that mounts OTLP under a prefix + // (".../otlp") is why it exists. + basePath string + headers map[string]string + // tlsConfig is nil unless certificate verification is being skipped on an + // https:// endpoint, which is the only case where the default client TLS + // behaviour needs overriding. + tlsConfig *tls.Config + // insecure means the endpoint is plaintext. It is stated to the exporter + // explicitly rather than left to be inferred from the endpoint, so the + // transport does not silently become TLS if the endpoint is ever passed as + // a bare host:port — the form that defaults to secure. + insecure bool +} + +func resolveExport(cfg Config) (exportSettings, error) { + protocol, err := normalizeProtocol(cfg.OTLPProtocol) + if err != nil { + return exportSettings{}, err + } + headers, err := ParseHeaders(cfg.OTLPHeaders) + if err != nil { + return exportSettings{}, err + } + endpoint, err := url.Parse(cfg.OTLPEndpoint) + if err != nil || endpoint.Host == "" { + return exportSettings{}, errors.New("OTLP endpoint must be an absolute http:// or https:// URL") + } + settings := exportSettings{ + protocol: protocol, + scheme: endpoint.Scheme, + host: endpoint.Host, + basePath: strings.TrimSuffix(endpoint.Path, "/"), + headers: headers, + insecure: endpoint.Scheme != "https", + } + // Skipping verification only means anything over TLS. Applying it to a + // plaintext endpoint would, for the gRPC exporter, quietly upgrade the + // connection to TLS against a collector that is not listening for it. + if cfg.OTLPTLSSkipVerify && !settings.insecure { + settings.tlsConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // opt-in, and rejected in production-like environments without an explicit acknowledgement + } + return settings, nil +} + +// signalEndpoint returns the full URL one signal is posted to. +// +// The exporters' WithEndpointURL option takes a signal-specific URL and uses +// its path verbatim; OTEL_EXPORTER_OTLP_ENDPOINT is a base endpoint the SDK +// appends the signal path to. Handing the base URL straight to WithEndpointURL +// posts every signal to the base path, which a collector answers with 404 — for +// logs silently, since only the older trace and metric exporters fall back to +// their default path when it is empty. +// +// An operator who already wrote the signal path gets it left alone, so both +// forms of endpoint work. +func (e exportSettings) signalEndpoint(signalPath string) string { + if strings.HasSuffix(e.basePath, signalPath) { + return e.scheme + "://" + e.host + e.basePath + } + return e.scheme + "://" + e.host + e.basePath + signalPath +} + +// exporterOptions applies the export policy — always the endpoint, plaintext +// stated explicitly, headers only when there are any, TLS only when +// verification is being overridden — to one exporter package's option type. +// +// Every OTLP exporter package exposes the same four options under the same +// names but as unrelated types, so the alternative is this decision written out +// once per signal per transport. Six copies of one policy is how the trace, +// metric, and log exporters end up disagreeing about when a header is sent. +func exporterOptions[O any]( + export exportSettings, + signalPath string, + withEndpointURL func(string) O, + withInsecure func() O, + withHeaders func(map[string]string) O, + withTLS func(*tls.Config) O, +) []O { + options := []O{withEndpointURL(export.signalEndpoint(signalPath))} + if export.insecure { + options = append(options, withInsecure()) + } + if len(export.headers) > 0 { + options = append(options, withHeaders(export.headers)) + } + if export.tlsConfig != nil { + options = append(options, withTLS(export.tlsConfig)) + } + return options +} + +// grpcTLS adapts a TLS config to the credentials form the gRPC exporters take, +// so both transports can be described by the same withTLS shape. +func grpcTLS[O any](withCredentials func(credentials.TransportCredentials) O) func(*tls.Config) O { + return func(config *tls.Config) O { + return withCredentials(credentials.NewTLS(config)) + } +} + +// newTraceExporter builds the span exporter for the resolved protocol. Both +// transports take the endpoint as a URL, so the scheme an operator writes +// (http:// vs https://) still decides whether the connection is encrypted. +func newTraceExporter(ctx context.Context, export exportSettings) (sdktrace.SpanExporter, error) { + if export.protocol == ProtocolGRPC { + return otlptracegrpc.New(ctx, exporterOptions( + export, + tracesPath, + otlptracegrpc.WithEndpointURL, + otlptracegrpc.WithInsecure, + otlptracegrpc.WithHeaders, + grpcTLS(otlptracegrpc.WithTLSCredentials), + )...) + } + return otlptracehttp.New(ctx, exporterOptions( + export, + tracesPath, + otlptracehttp.WithEndpointURL, + otlptracehttp.WithInsecure, + otlptracehttp.WithHeaders, + otlptracehttp.WithTLSClientConfig, + )...) +} + +func newMetricExporter(ctx context.Context, export exportSettings) (sdkmetric.Exporter, error) { + if export.protocol == ProtocolGRPC { + return otlpmetricgrpc.New(ctx, exporterOptions( + export, + metricsPath, + otlpmetricgrpc.WithEndpointURL, + otlpmetricgrpc.WithInsecure, + otlpmetricgrpc.WithHeaders, + grpcTLS(otlpmetricgrpc.WithTLSCredentials), + )...) + } + return otlpmetrichttp.New(ctx, exporterOptions( + export, + metricsPath, + otlpmetrichttp.WithEndpointURL, + otlpmetrichttp.WithInsecure, + otlpmetrichttp.WithHeaders, + otlpmetrichttp.WithTLSClientConfig, + )...) +} + +func newLogExporter(ctx context.Context, export exportSettings) (sdklog.Exporter, error) { + if export.protocol == ProtocolGRPC { + return otlploggrpc.New(ctx, exporterOptions( + export, + logsPath, + otlploggrpc.WithEndpointURL, + otlploggrpc.WithInsecure, + otlploggrpc.WithHeaders, + grpcTLS(otlploggrpc.WithTLSCredentials), + )...) + } + return otlploghttp.New(ctx, exporterOptions( + export, + logsPath, + otlploghttp.WithEndpointURL, + otlploghttp.WithInsecure, + otlploghttp.WithHeaders, + otlploghttp.WithTLSClientConfig, + )...) +} + // errorHandler routes OpenTelemetry SDK errors into Mosaic's structured log // stream so an operator sees exporter failures in the same JSON pipeline as // every other backend error. diff --git a/apps/api/internal/platform/telemetry/telemetry_test.go b/apps/api/internal/platform/telemetry/telemetry_test.go new file mode 100644 index 00000000..a2967c5a --- /dev/null +++ b/apps/api/internal/platform/telemetry/telemetry_test.go @@ -0,0 +1,288 @@ +package telemetry + +import ( + "context" + "crypto/tls" + "reflect" + "strings" + "testing" + "time" + + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/global" +) + +func TestNormalizeProtocolResolvesSupportedTransports(t *testing.T) { + for name, test := range map[string]struct { + value string + want string + wantError bool + }{ + "empty falls back to the default": {value: "", want: DefaultProtocol}, + "spec http value": {value: "http/protobuf", want: ProtocolHTTP}, + "shorthand http value": {value: "HTTP", want: ProtocolHTTP}, + "grpc": {value: " grpc ", want: ProtocolGRPC}, + "unsupported transport": {value: "http/json", wantError: true}, + } { + t.Run(name, func(t *testing.T) { + protocol, err := NormalizeProtocol(test.value) + if test.wantError { + if err == nil { + t.Fatalf("normalize %q succeeded, want rejection", test.value) + } + return + } + if err != nil { + t.Fatalf("normalize %q: %v", test.value, err) + } + if protocol != test.want { + t.Fatalf("protocol = %q, want %q", protocol, test.want) + } + }) + } +} + +func TestParseHeadersDecodesSpecFormat(t *testing.T) { + headers, err := ParseHeaders(" api-key = secret-token , x-tenant=acme%20corp ") + if err != nil { + t.Fatalf("parse headers: %v", err) + } + want := map[string]string{"api-key": "secret-token", "x-tenant": "acme corp"} + if !reflect.DeepEqual(headers, want) { + t.Fatalf("headers = %#v, want %#v", headers, want) + } + if empty, err := ParseHeaders(" "); err != nil || empty != nil { + t.Fatalf("empty headers = %#v, %v; want no headers and no error", empty, err) + } +} + +func TestParseHeadersRejectsMalformedInputWithoutLeakingValues(t *testing.T) { + for name, value := range map[string]string{ + "missing separator": "authorization Bearer super-secret", + "empty key": "=super-secret", + "bad encoding": "authorization=Bearer%zzsuper-secret", + } { + t.Run(name, func(t *testing.T) { + if _, err := ParseHeaders(value); err == nil { + t.Fatal("parse succeeded, want rejection") + } else if strings.Contains(err.Error(), "super-secret") { + t.Fatalf("error leaked a header value: %v", err) + } + }) + } +} + +// Skip-verify must stay inert on a plaintext endpoint: for the gRPC exporter, +// attaching TLS credentials there would silently switch the connection to TLS +// against a collector that is not serving it. +func TestResolveExportAppliesSkipVerifyOnlyOverTLS(t *testing.T) { + for name, test := range map[string]struct { + endpoint string + wantTLS bool + wantInsecure bool + }{ + "https endpoint": {endpoint: "https://collector.invalid:4318", wantTLS: true}, + "http endpoint": {endpoint: "http://collector.invalid:4318", wantInsecure: true}, + } { + t.Run(name, func(t *testing.T) { + export, err := resolveExport(Config{OTLPEndpoint: test.endpoint, OTLPTLSSkipVerify: true}) + if err != nil { + t.Fatalf("resolve export: %v", err) + } + if test.wantTLS != (export.tlsConfig != nil) { + t.Fatalf("tls config = %#v, want present = %t", export.tlsConfig, test.wantTLS) + } + if export.tlsConfig != nil && !export.tlsConfig.InsecureSkipVerify { + t.Fatal("tls config does not skip verification") + } + // A plaintext endpoint must be declared insecure to the exporter, not + // left for it to infer, so the two are never both set. + if export.insecure != test.wantInsecure { + t.Fatalf("insecure = %t, want %t", export.insecure, test.wantInsecure) + } + if export.insecure && export.tlsConfig != nil { + t.Fatal("plaintext export must not carry a TLS config") + } + }) + } +} + +// OTEL_EXPORTER_OTLP_ENDPOINT is a base endpoint: each signal hangs off it. A +// base URL handed to the exporters verbatim posts every signal to the base +// path, which a collector answers with 404 — the failure only the log exporter +// surfaces, since the trace and metric exporters quietly fall back to their +// default path. +func TestSignalEndpointAppendsPerSignalPath(t *testing.T) { + for name, test := range map[string]struct { + endpoint string + want map[string]string + }{ + "base endpoint without a path": { + endpoint: "http://collector.example", + want: map[string]string{ + tracesPath: "http://collector.example/v1/traces", + metricsPath: "http://collector.example/v1/metrics", + logsPath: "http://collector.example/v1/logs", + }, + }, + "base endpoint with a port and trailing slash": { + endpoint: "https://collector.example:4318/", + want: map[string]string{ + tracesPath: "https://collector.example:4318/v1/traces", + logsPath: "https://collector.example:4318/v1/logs", + }, + }, + "collector behind a path prefix": { + endpoint: "https://gateway.example/otlp", + want: map[string]string{ + tracesPath: "https://gateway.example/otlp/v1/traces", + logsPath: "https://gateway.example/otlp/v1/logs", + }, + }, + // Someone who already wrote the signal path must not get it twice. + "endpoint already naming the signal": { + endpoint: "https://collector.example/v1/logs", + want: map[string]string{logsPath: "https://collector.example/v1/logs"}, + }, + } { + t.Run(name, func(t *testing.T) { + export, err := resolveExport(Config{OTLPEndpoint: test.endpoint}) + if err != nil { + t.Fatalf("resolve export: %v", err) + } + for signalPath, want := range test.want { + if got := export.signalEndpoint(signalPath); got != want { + t.Fatalf("endpoint for %s = %q, want %q", signalPath, got, want) + } + } + }) + } +} + +// The insecure option must reach every exporter, not just the one that was +// checked by hand: exporterOptions is where that guarantee lives. +func TestExporterOptionsDeclarePlaintextOnce(t *testing.T) { + type option string + endpoint := func(url string) option { return option("endpoint:" + url) } + insecure := func() option { return "insecure" } + headers := func(map[string]string) option { return "headers" } + tlsOption := func(*tls.Config) option { return "tls" } + + plaintext, err := resolveExport(Config{OTLPEndpoint: "http://collector.invalid:4318"}) + if err != nil { + t.Fatalf("resolve export: %v", err) + } + options := exporterOptions(plaintext, logsPath, endpoint, insecure, headers, tlsOption) + want := []option{"endpoint:http://collector.invalid:4318/v1/logs", "insecure"} + if !reflect.DeepEqual(options, want) { + t.Fatalf("options = %#v, want %#v", options, want) + } + + secure, err := resolveExport(Config{OTLPEndpoint: "https://collector.invalid:4318", OTLPTLSSkipVerify: true}) + if err != nil { + t.Fatalf("resolve export: %v", err) + } + secureOptions := exporterOptions(secure, tracesPath, endpoint, insecure, headers, tlsOption) + wantSecure := []option{"endpoint:https://collector.invalid:4318/v1/traces", "tls"} + if !reflect.DeepEqual(secureOptions, wantSecure) { + t.Fatalf("options = %#v, want %#v", secureOptions, wantSecure) + } +} + +// New must build both exporters for either transport without reaching the +// collector: exporter construction is lazy, so a startup failure here would be +// a configuration bug rather than an unreachable-collector symptom. +func TestNewBuildsExportersForEitherTransport(t *testing.T) { + for name, test := range map[string]struct { + protocol string + endpoint string + skipVerify bool + }{ + "http": {protocol: "http/protobuf", endpoint: "http://collector.invalid:4318"}, + "grpc": {protocol: "grpc", endpoint: "http://collector.invalid:4317"}, + "http over unverified tls": {protocol: "http/protobuf", endpoint: "https://collector.invalid:4318", skipVerify: true}, + "grpc over unverified tls": {protocol: "grpc", endpoint: "https://collector.invalid:4317", skipVerify: true}, + } { + t.Run(name, func(t *testing.T) { + shutdown, err := New(context.Background(), Config{ + ServiceName: "mosaic-api-test", + Environment: "test", + OTLPEndpoint: test.endpoint, + OTLPProtocol: test.protocol, + OTLPHeaders: "authorization=Bearer%20token,x-tenant=acme", + OTLPTLSSkipVerify: test.skipVerify, + }) + if err != nil { + t.Fatalf("configure telemetry over %s: %v", test.protocol, err) + } + // Shutdown flushes to an endpoint that does not resolve, so it is + // bounded here and its export failure is expected; the exporters + // building at all is what this test guards. + shutdownContext, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := shutdown(shutdownContext); err != nil { + t.Logf("shutdown reported an export failure: %v", err) + } + }) + } +} + +// Log export is the one signal an operator turns off for cost, so disabling it +// must leave traces and metrics — and startup — untouched. +func TestNewBuildsLogPipelineUnlessDisabled(t *testing.T) { + for name, disabled := range map[string]bool{"exporting logs": false, "log export disabled": true} { + t.Run(name, func(t *testing.T) { + shutdown, err := New(context.Background(), Config{ + ServiceName: "mosaic-api-test", + Environment: "test", + OTLPEndpoint: "http://collector.invalid:4318", + DisableLogExport: disabled, + }) + if err != nil { + t.Fatalf("configure telemetry: %v", err) + } + // A logger provider is installed either way, so code that emits + // records never has to check whether export is on. + var record log.Record + record.SetBody(log.StringValue("startup")) + global.Logger("mosaic-api-test").Emit(context.Background(), record) + + shutdownContext, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := shutdown(shutdownContext); err != nil { + t.Logf("shutdown reported an export failure: %v", err) + } + }) + } +} + +func TestNewRejectsUnsupportedProtocol(t *testing.T) { + _, err := New(context.Background(), Config{ + ServiceName: "mosaic-api-test", + Environment: "test", + OTLPEndpoint: "http://collector.invalid:4318", + OTLPProtocol: "thrift", + }) + if err == nil { + t.Fatal("configure telemetry succeeded with an unsupported protocol") + } + if !strings.Contains(err.Error(), "unsupported OTLP protocol") { + t.Fatalf("error = %v, want an unsupported-protocol message", err) + } +} + +// With no endpoint the exporters are never built, so the protocol value is +// irrelevant and must not turn a working no-export setup into a startup error. +func TestNewIgnoresProtocolWithoutEndpoint(t *testing.T) { + shutdown, err := New(context.Background(), Config{ + ServiceName: "mosaic-api-test", + Environment: "test", + OTLPProtocol: "thrift", + }) + if err != nil { + t.Fatalf("configure telemetry without an endpoint: %v", err) + } + if err := shutdown(context.Background()); err != nil { + t.Fatalf("shutdown telemetry: %v", err) + } +} diff --git a/apps/api/internal/providercredential/subject.go b/apps/api/internal/providercredential/subject.go index 9ac0b396..f06d4f12 100644 --- a/apps/api/internal/providercredential/subject.go +++ b/apps/api/internal/providercredential/subject.go @@ -34,6 +34,11 @@ const ( // write the table but not decrypt it — fails to open rather than signing // deliveries for the wrong tenant. SubjectWebhookSigningSecret = "webhook_signing_secret" + // SubjectBillingMigrationCredential keeps migration API keys cryptographically + // separate from commerce/catalog connections. A credential copied between a + // provider connection and a migration program therefore cannot be opened, + // even when both rows belong to the same Project. + SubjectBillingMigrationCredential = "billing_migration_credential" ) // SubjectScope binds a v2 envelope to one tenant and one row. Every field is @@ -53,7 +58,8 @@ func validSubjectScope(scope SubjectScope) bool { return false } switch scope.SubjectKind { - case SubjectStoreServerCredential, SubjectBillingRawInput, SubjectWebhookSigningSecret: + case SubjectStoreServerCredential, SubjectBillingRawInput, SubjectWebhookSigningSecret, + SubjectBillingMigrationCredential: return true default: return false diff --git a/apps/api/internal/transport/billingaccess/handler.go b/apps/api/internal/transport/billingaccess/handler.go index ae2d4b7a..aebf135a 100644 --- a/apps/api/internal/transport/billingaccess/handler.go +++ b/apps/api/internal/transport/billingaccess/handler.go @@ -14,9 +14,12 @@ package billingaccesshttp import ( + "bytes" "encoding/json" "errors" + "io" "net/http" + "regexp" "strconv" "strings" "time" @@ -247,6 +250,12 @@ type syncEnvelope struct { Payload syncPayload `json:"payload"` } +type syncDiscriminator struct { + AuthoritativeEntitlementContractVersion string `json:"authoritativeEntitlementContractVersion"` + RecordType string `json:"recordType"` + Payload json.RawMessage `json:"payload"` +} + type syncPayload struct { BillingCustomerID string `json:"billingCustomerId,omitempty"` KnownSnapshotVersion int64 `json:"knownSnapshotVersion,omitempty"` @@ -256,6 +265,51 @@ type syncPayload struct { CorrelationID string `json:"correlationId"` } +type authoritySyncPayload struct { + KnownAuthorityEpoch *int64 `json:"knownAuthorityEpoch,omitempty"` + KnownSnapshotVersion *int64 `json:"knownSnapshotVersion,omitempty"` + KnownSnapshotAuthorityDigest string `json:"knownSnapshotAuthorityDigest,omitempty"` + Request authoritySyncRequestMetadata `json:"request"` +} + +type authoritySyncRequestMetadata struct { + ApplicationID string `json:"applicationId"` + Platform string `json:"platform"` + AppVersion string `json:"appVersion"` + SDKVersion string `json:"sdkVersion"` + SupportedContractVersions []string `json:"supportedContractVersions"` + Capabilities []string `json:"capabilities"` +} + +func (p authoritySyncPayload) Validate() error { + digestRule := validation.When(p.KnownSnapshotAuthorityDigest != "", + validation.Match(regexp.MustCompile(`^sha256:[a-f0-9]{64}$`))) + return validation.ValidateStruct(&p, + validation.Field(&p.KnownAuthorityEpoch, validation.NilOrNotEmpty, validation.Min(0)), + validation.Field(&p.KnownSnapshotVersion, validation.NilOrNotEmpty, validation.Min(0)), + validation.Field(&p.KnownSnapshotAuthorityDigest, digestRule), + validation.Field(&p.Request, validation.By(func(value any) error { + request := value.(authoritySyncRequestMetadata) + if err := validation.ValidateStruct(&request, + validation.Field(&request.ApplicationID, validation.Required, validation.Length(1, 128)), + validation.Field(&request.Platform, validation.Required, validation.In("ios", "android")), + validation.Field(&request.AppVersion, validation.Required, validation.Length(1, 64)), + validation.Field(&request.SDKVersion, validation.Required, validation.Length(1, 64)), + validation.Field(&request.SupportedContractVersions, validation.Required, validation.Length(1, 8), validation.Each(validation.In("1", "2"))), + validation.Field(&request.Capabilities, validation.Required, validation.Length(1, 16), validation.Each(validation.In( + "authority_epoch", "authority_scope", "urgent_authority_sync", "mosaic_authoritative_targeting"))), + ); err != nil { + return err + } + if !uniqueStrings(request.SupportedContractVersions) || !uniqueStrings(request.Capabilities) || + !containsString(request.SupportedContractVersions, "2") || !containsString(request.Capabilities, "authority_epoch") { + return errors.New("request negotiation values are not unique or omit required v2 support") + } + return nil + })), + ) +} + func (h *Handler) syncEntitlements(w http.ResponseWriter, r *http.Request) { authenticated, err := h.service.AuthenticateCustomerToken(r.Context(), bearer(r), strings.TrimSpace(r.Header.Get(SDKKeyHeader))) @@ -265,70 +319,93 @@ func (h *Handler) syncEntitlements(w http.ResponseWriter, r *http.Request) { } request := billingaccess.SyncRequest{CorrelationID: correlationID(r)} + var authorityRequest *billingaccess.AuthoritySyncRequest if r.Method == http.MethodPost { - var envelope syncEnvelope + var envelope syncDiscriminator if !decode(w, r, &envelope) { return } - if envelope.AuthoritativeEntitlementContractVersion != billingaccess.ContractVersion || - envelope.RecordType != "entitlementSyncRequest" { + if envelope.RecordType != "entitlementSyncRequest" { writeValidation(w, r, map[string][]string{ - "recordType": {"The record is not an Authoritative Entitlement Contract v1 sync request."}}) + "recordType": {"The record is not an Authoritative Entitlement sync request."}}) return } - if !supportsContract(envelope.Payload.SupportedAuthoritativeEntitlementContracts) { - // The caller cannot read anything Mosaic can produce. This is a - // negotiation failure, not an authentication or state problem. - response.Error(w, r, response.NewAPIError(http.StatusNotAcceptable, - "contract_version_unsupported", - "No supported Authoritative Entitlement Contract version was offered.")) + switch envelope.AuthoritativeEntitlementContractVersion { + case billingaccess.ContractVersion: + var payload syncPayload + if !decodePayload(w, r, envelope.Payload, &payload) { + return + } + if !supportsContract(payload.SupportedAuthoritativeEntitlementContracts) { + // The caller cannot read anything Mosaic can produce. This is a + // negotiation failure, not an authentication or state problem. + response.Error(w, r, response.NewAPIError(http.StatusNotAcceptable, + "contract_version_unsupported", + "No supported Authoritative Entitlement Contract version was offered.")) + return + } + request.CustomerIDHint = payload.BillingCustomerID + request.KnownSnapshotVersion = payload.KnownSnapshotVersion + request.EntityTag = payload.EntityTag + request.RequestedKeys = payload.RequestedEntitlementKeys + if payload.CorrelationID != "" { + request.CorrelationID = payload.CorrelationID + } + case billingaccess.AuthorityContractVersion: + var payload authoritySyncPayload + if !decodePayload(w, r, envelope.Payload, &payload) { + return + } + if err := payload.Validate(); err != nil { + writeValidation(w, r, validationFields(err)) + return + } + authorityRequest = &billingaccess.AuthoritySyncRequest{ + KnownAuthorityEpoch: payload.KnownAuthorityEpoch, + KnownSnapshotVersion: payload.KnownSnapshotVersion, + KnownSnapshotAuthorityDigest: payload.KnownSnapshotAuthorityDigest, + ApplicationID: payload.Request.ApplicationID, Platform: payload.Request.Platform, + AppVersion: payload.Request.AppVersion, SDKVersion: payload.Request.SDKVersion, + SupportedContractVersions: payload.Request.SupportedContractVersions, + Capabilities: payload.Request.Capabilities, + } + default: + writeValidation(w, r, map[string][]string{"authoritativeEntitlementContractVersion": {"Only exact contract versions 1 and 2 are supported."}}) return } - request.CustomerIDHint = envelope.Payload.BillingCustomerID - request.KnownSnapshotVersion = envelope.Payload.KnownSnapshotVersion - request.EntityTag = envelope.Payload.EntityTag - request.RequestedKeys = envelope.Payload.RequestedEntitlementKeys - if envelope.Payload.CorrelationID != "" { - request.CorrelationID = envelope.Payload.CorrelationID - } } - result, err := h.service.Sync(r.Context(), authenticated, request) + var result billingaccess.SyncResult + if authorityRequest != nil { + result, err = h.service.SyncAuthorityV2(r.Context(), authenticated, *authorityRequest) + } else { + // GET is deliberately and permanently v1-only. + result, err = h.service.Sync(r.Context(), authenticated, request) + } if err != nil { writeError(w, r, err) return } - w.Header().Set("ETag", `"`+result.EntityTag+`"`) + if result.EntityTag != "" { + w.Header().Set("ETag", `"`+result.EntityTag+`"`) + } w.Header().Set("Cache-Control", "private, no-cache") // The freshness window travels as headers as well as inside the record. // The record is where every SDK reads it; the headers exist so an // intermediary and an operator can see the same window without parsing the // body. - w.Header().Set("Mosaic-Refresh-After", billingaccess.ContractTimestamp(result.RefreshAfter)) - w.Header().Set("Mosaic-Valid-Until", billingaccess.ContractTimestamp(result.ValidUntil)) - w.Header().Set("Mosaic-Stale-Grace-Seconds", strconv.Itoa(int(result.StaleGrace/time.Second))) - - // There is exactly one conditional mechanism on this surface, and it is the - // POST body's `knownSnapshotVersion` (defect D-5, ratified). - // - // The GET form is a plain full-snapshot read. It carries no way to state a - // snapshot version, and version equality is a precondition of `unchanged` — - // a matching entity tag alone would confirm a cache without proving - // monotonicity. The handler used to carry a 304 branch gated to GET plus - // If-None-Match; the precondition made it unreachable on every request that - // could ever take it, so it was dead code that advertised a bandwidth saving - // the surface did not provide. It is removed rather than made reachable: - // making it reachable would mean either dropping the monotonicity - // precondition for one verb or inventing an unratified query parameter. - // - // POST answers 200 with the canonical `snapshotUnchanged` record even when - // the caller's version matches. That record carries refreshAfter, - // validUntil, and staleGraceSeconds inside a frozen schema every SDK already - // validates, whereas a bare 304 carries no body and would force all three - // platforms to read freshness out of `Mosaic-…` header names no schema - // defines. Freshness that only exists in undocumented headers is freshness - // the contract cannot guarantee. + if !result.RefreshAfter.IsZero() { + w.Header().Set("Mosaic-Refresh-After", billingaccess.ContractTimestamp(result.RefreshAfter)) + w.Header().Set("Mosaic-Valid-Until", billingaccess.ContractTimestamp(result.ValidUntil)) + w.Header().Set("Mosaic-Stale-Grace-Seconds", strconv.Itoa(int(result.StaleGrace/time.Second))) + } + + // GET remains a plain v1 full-snapshot read. POST conditionals are explicit + // contract input: v1 requires the known version and entity tag, while v2 + // requires the exact epoch, version, and previously observed authority + // digest. Both return the canonical snapshotUnchanged body rather than 304 + // so freshness remains part of the versioned contract. response.Representation(w, http.StatusOK, contractContentType, result.Payload) } @@ -344,6 +421,26 @@ func supportsContract(offered []string) bool { return false } +func uniqueStrings(values []string) bool { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if _, exists := seen[value]; exists { + return false + } + seen[value] = struct{}{} + } + return true +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + // --------------------------------------------------------------------------- // Trusted-server reads // --------------------------------------------------------------------------- @@ -547,6 +644,20 @@ func decode(w http.ResponseWriter, r *http.Request, target any) bool { return true } +func decodePayload(w http.ResponseWriter, r *http.Request, payload json.RawMessage, target any) bool { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeValidation(w, r, map[string][]string{"payload": {"The request payload is malformed or contains unknown fields."}}) + return false + } + if decoder.Decode(&struct{}{}) != io.EOF { + writeValidation(w, r, map[string][]string{"payload": {"The request payload must contain one JSON object."}}) + return false + } + return true +} + func bearer(r *http.Request) string { value := strings.TrimSpace(r.Header.Get("Authorization")) if len(value) > 7 && strings.EqualFold(value[:7], "Bearer ") { @@ -617,6 +728,9 @@ func writeError(w http.ResponseWriter, r *http.Request, err error) { case errors.Is(err, billingaccess.ErrUnavailable): status, code, message = http.StatusServiceUnavailable, "billing_storage_unavailable", "Billing state could not be read." + case errors.Is(err, billingaccess.ErrAuthorityUpgradeRequired): + status, code, message = http.StatusUpgradeRequired, "authority_contract_upgrade_required", + "This Application scope requires Authoritative Entitlement Contract v2." } response.Error(w, r, response.NewAPIError(status, code, message)) } diff --git a/apps/api/internal/transport/billingaccess/sync_contract_test.go b/apps/api/internal/transport/billingaccess/sync_contract_test.go index 4328e2c4..b3009e93 100644 --- a/apps/api/internal/transport/billingaccess/sync_contract_test.go +++ b/apps/api/internal/transport/billingaccess/sync_contract_test.go @@ -45,6 +45,24 @@ const ( type stubRepository struct{} func (stubRepository) BillingEnabled(context.Context, string) (bool, error) { return true, nil } +func (stubRepository) LegacyAuthority(context.Context, billingaccess.AuthorityScope) (string, error) { + return "source", nil +} +func (stubRepository) MinimumSupport(context.Context, billingaccess.AuthorityScope) (billingaccess.MinimumSupport, error) { + return billingaccess.MinimumSupport{ProgramID: "bmp_sync_test", MinimumSDKVersion: "2.0.0", MinimumAppVersion: "4.0.0", MaximumAppVersion: "5.9.9", RequiredCapabilities: []string{"authority_epoch"}}, nil +} +func (stubRepository) AuthoritySelection(ctx context.Context, scope billingaccess.AuthorityScope, customerID string, _ time.Time) (billingaccess.AuthoritySelection, error) { + view, _ := (stubRepository{}).CurrentSnapshot(ctx, scope.ProjectID, scope.EnvironmentID, customerID) + return billingaccess.AuthoritySelection{Scope: scope, ProgramID: "bmp_sync_test", AuthorityEpoch: 4, + AuthorityKind: "source", TransitionState: "stable", Snapshot: view, + MinimumSupport: billingaccess.MinimumSupport{ProgramID: "bmp_sync_test", MinimumSDKVersion: "2.0.0", MinimumAppVersion: "4.0.0", MaximumAppVersion: "5.9.9", RequiredCapabilities: []string{"authority_epoch"}}}, nil +} +func (stubRepository) ObservedSnapshotDigest(context.Context, billingaccess.AuthoritySelection, []byte) (bool, error) { + return false, nil +} +func (stubRepository) AppendSyncObservation(context.Context, billingaccess.SyncObservation) error { + return nil +} func (stubRepository) CurrentSnapshot(_ context.Context, projectID, environmentID, customerID string) (billingaccess.SnapshotView, error) { at := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) @@ -114,7 +132,7 @@ func (stubKeys) AuthenticateSDKKey(_ context.Context, raw string) (billingaccess if strings.TrimSpace(raw) != testSDKKey { return billingaccess.KeyScope{}, billingaccess.ErrUnauthenticated } - return billingaccess.KeyScope{ProjectID: testProjectID, EnvironmentID: testEnvID}, nil + return billingaccess.KeyScope{ProjectID: testProjectID, EnvironmentID: testEnvID, ApplicationID: "app_sync_test", Platform: "ios"}, nil } func syncRouter() http.Handler { @@ -266,3 +284,40 @@ func TestConditionalGetAnswersFullSnapshotWithFreshnessHeaders(t *testing.T) { } } } + +func TestPostStrictlyDiscriminatesAuthorityV2(t *testing.T) { + handler := syncRouter() + body := `{"authoritativeEntitlementContractVersion":"2","recordType":"entitlementSyncRequest","payload":{"request":{"applicationId":"app_sync_test","platform":"ios","appVersion":"4.2.0","sdkVersion":"2.0.0","supportedContractVersions":["1","2"],"capabilities":["authority_epoch"]}}}` + request := httptest.NewRequest(http.MethodPost, "/sdk/billing/entitlements", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+testToken) + request.Header.Set(billingaccesshttp.SDKKeyHeader, testSDKKey) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("v2 POST status %d: %s", recorder.Code, recorder.Body.String()) + } + var record struct { + Version, RecordType string + Payload map[string]any `json:"payload"` + } + var envelope map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope["authoritativeEntitlementContractVersion"] != "2" || envelope["recordType"] != "customerEntitlementSnapshot" { + t.Fatalf("POST did not select v2: %s", recorder.Body.String()) + } + + unknown := strings.Replace(body, `"request":{`, `"unknown":true,"request":{`, 1) + request = httptest.NewRequest(http.MethodPost, "/sdk/billing/entitlements", strings.NewReader(unknown)) + request.Header.Set("Authorization", "Bearer "+testToken) + request.Header.Set(billingaccesshttp.SDKKeyHeader, testSDKKey) + request.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnprocessableEntity { + t.Fatalf("v2 unknown field status %d, want 422: %s", recorder.Code, recorder.Body.String()) + } + _ = record +} diff --git a/apps/api/internal/transport/billingmigration/evidence_handler.go b/apps/api/internal/transport/billingmigration/evidence_handler.go new file mode 100644 index 00000000..e1a6b99a --- /dev/null +++ b/apps/api/internal/transport/billingmigration/evidence_handler.go @@ -0,0 +1,238 @@ +package billingmigrationhttp + +import ( + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + validation "github.com/go-ozzo/ozzo-validation/v4" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +var sha256DigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +func (h *Handler) listManifests(w http.ResponseWriter, r *http.Request) { + limit, ok := readLimit(w, r) + if !ok { + return + } + items, err := h.services.Program.ListManifests(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "programId"), limit) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, recordItems("sourceManifest", items)) +} + +type mappingSetRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + Version int `json:"version"` + Entries []billingmigration.MappingEntry `json:"entries"` +} + +func (r mappingSetRequest) Validate() error { + return validation.ValidateStruct(&r, validation.Field(&r.ExpectedStateVersion, validation.Min(1)), validation.Field(&r.Version, validation.Min(1)), validation.Field(&r.Entries, validation.Required, validation.Length(1, 10000))) +} +func (h *Handler) createMappingSet(w http.ResponseWriter, r *http.Request) { + var request mappingSetRequest + if !decode(w, r, &request) { + return + } + item, err := h.services.Program.CreateMappingSet(r.Context(), actor(r), billingmigration.CreateMappingSetInput{ProjectID: chi.URLParam(r, "projectId"), ProgramID: chi.URLParam(r, "programId"), ExpectedStateVersion: request.ExpectedStateVersion, Version: request.Version, Entries: request.Entries}) + if err != nil { + writeError(w, r, err) + return + } + response.Created(w, r, record("mappingSet", item)) +} +func (h *Handler) listMappingSets(w http.ResponseWriter, r *http.Request) { + limit, ok := readLimit(w, r) + if !ok { + return + } + items, err := h.services.Program.ListMappingSets(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "programId"), limit) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, recordItems("mappingSet", items)) +} + +type stateVersionRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` +} + +func (r stateVersionRequest) Validate() error { + return validation.Validate(&r.ExpectedStateVersion, validation.Min(1)) +} +func (h *Handler) freezeMappingSet(w http.ResponseWriter, r *http.Request) { + var request stateVersionRequest + if !decode(w, r, &request) { + return + } + err := h.services.Program.FreezeMappingSet(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "programId"), chi.URLParam(r, "mappingSetId"), request.ExpectedStateVersion) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, map[string]any{"status": "frozen"}) +} + +type importBatchRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ManifestID string `json:"manifestId"` + MappingSetID string `json:"mappingSetId"` + RecordCount int `json:"recordCount"` + CursorBefore string `json:"cursorBefore"` +} + +func (r importBatchRequest) Validate() error { + return validation.ValidateStruct(&r, validation.Field(&r.ExpectedStateVersion, validation.Min(1)), validation.Field(&r.ManifestID, validation.Required, validation.Length(1, 128)), validation.Field(&r.MappingSetID, validation.Required, validation.Length(1, 128)), validation.Field(&r.RecordCount, validation.Min(0), validation.Max(1000)), validation.Field(&r.CursorBefore, validation.Length(0, 512))) +} +func (h *Handler) createImportBatch(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request importBatchRequest + if !decode(w, r, &request) { + return + } + item, replay, err := h.services.Program.CreateImportBatch(r.Context(), actor(r), billingmigration.CreateImportBatchInput{ProjectID: chi.URLParam(r, "projectId"), ProgramID: chi.URLParam(r, "programId"), ManifestID: request.ManifestID, MappingSetID: request.MappingSetID, IdempotencyKey: key, CursorBefore: request.CursorBefore, ExpectedStateVersion: request.ExpectedStateVersion, RecordCount: request.RecordCount}) + if err != nil { + writeError(w, r, err) + return + } + if replay { + response.OK(w, r, record("importBatch", item)) + return + } + response.Accepted(w, r, record("importBatch", item)) +} +func (h *Handler) listImportBatches(w http.ResponseWriter, r *http.Request) { + limit, ok := readLimit(w, r) + if !ok { + return + } + items, err := h.services.Program.ListImportBatches(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "programId"), limit) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, recordItems("importBatch", items)) +} +func (h *Handler) getImportBatch(w http.ResponseWriter, r *http.Request) { + item, err := h.services.Program.ImportBatch(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "programId"), chi.URLParam(r, "batchId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, record("importBatch", item)) +} + +type runRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ManifestDigest string `json:"manifestDigest"` + MappingDigest string `json:"mappingDigest"` +} + +func (r runRequest) Validate() error { + return validation.ValidateStruct(&r, validation.Field(&r.ExpectedStateVersion, validation.Min(1)), validation.Field(&r.ManifestDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&r.MappingDigest, validation.Required, validation.Match(sha256DigestPattern))) +} +func (h *Handler) queueDryRun(w http.ResponseWriter, r *http.Request) { h.queueRun(w, r, "dry_run") } +func (h *Handler) queueShadowRun(w http.ResponseWriter, r *http.Request) { h.queueRun(w, r, "shadow") } +func (h *Handler) queueRun(w http.ResponseWriter, r *http.Request, kind string) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request runRequest + if !decode(w, r, &request) { + return + } + item, replay, err := h.services.Program.QueueRun(r.Context(), actor(r), billingmigration.QueueRunInput{ProjectID: chi.URLParam(r, "projectId"), ProgramID: chi.URLParam(r, "programId"), RunKind: kind, IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion, ManifestDigest: request.ManifestDigest, MappingDigest: request.MappingDigest}) + if err != nil { + writeError(w, r, err) + return + } + if replay { + response.OK(w, r, item) + return + } + response.Accepted(w, r, item) +} +func (h *Handler) getRunJob(w http.ResponseWriter, r *http.Request) { + item, err := h.services.Program.RunJob(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "programId"), chi.URLParam(r, "runJobId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, item) +} +func (h *Handler) listDivergences(w http.ResponseWriter, r *http.Request) { + limit, ok := readLimit(w, r) + if !ok { + return + } + items, err := h.services.Program.ListDivergences(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "programId"), limit) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, recordItems("divergence", items)) +} +func (h *Handler) assessReadiness(w http.ResponseWriter, r *http.Request) { + var request stateVersionRequest + if !decode(w, r, &request) { + return + } + item, err := h.services.Program.AssessCurrentReadiness(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "programId"), request.ExpectedStateVersion) + if err != nil { + writeError(w, r, err) + return + } + response.Created(w, r, record("readinessAssessment", item)) +} +func (h *Handler) latestReadiness(w http.ResponseWriter, r *http.Request) { + item, err := h.services.Program.LatestReadiness(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "programId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, record("readinessAssessment", item)) +} + +func idempotencyKey(w http.ResponseWriter, r *http.Request) (string, bool) { + key := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + if key == "" || len(key) > 128 { + response.Error(w, r, response.ValidationFailed(map[string][]string{"Idempotency-Key": {"must be between 1 and 128 characters"}})) + return "", false + } + return key, true +} +func readLimit(w http.ResponseWriter, r *http.Request) (int, bool) { + raw := r.URL.Query().Get("limit") + if raw == "" { + return 50, true + } + limit, err := strconv.Atoi(raw) + if err != nil || limit < 1 || limit > 100 { + writeError(w, r, billingmigration.ErrInvalid) + return 0, false + } + return limit, true +} +func record[T any](kind string, payload T) billingmigration.ContractRecord[T] { + return billingmigration.ContractRecord[T]{BillingMigrationOperationsContractVersion: billingmigration.ContractVersion, RecordType: kind, Payload: payload} +} +func recordItems[T any](kind string, items []T) map[string]any { + records := make([]billingmigration.ContractRecord[T], 0, len(items)) + for _, item := range items { + records = append(records, record(kind, item)) + } + return map[string]any{"items": records} +} diff --git a/apps/api/internal/transport/billingmigration/handler.go b/apps/api/internal/transport/billingmigration/handler.go new file mode 100644 index 00000000..73816557 --- /dev/null +++ b/apps/api/internal/transport/billingmigration/handler.go @@ -0,0 +1,237 @@ +// Package billingmigrationhttp exposes the Phase 9C operator foundation. +package billingmigrationhttp + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/rs/zerolog" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +// Mapping sets may contain up to 10,000 bounded entries. Sixteen MiB keeps +// that documented contract representable with JSON encoding overhead while +// retaining a strict transport cap. +const maxRequestBytes = 16 << 20 + +type Services struct { + Program *billingmigration.Service + SourcePull *billingmigration.SourcePullService + Operations *billingmigration.OperationsService + Redelivery *billingmigration.RedeliveryService + Reads *billingmigration.OperationalReadService + Stabilization *billingmigration.StabilizationService + RollbackReadiness *billingmigration.RollbackReadinessService + RepairOnline bool +} + +type Handler struct{ services Services } + +func RegisterProjectRoutes(router chi.Router, services Services, expensive ...func(http.Handler) http.Handler) { + handler := &Handler{services: services} + router.Route("/billing/migration-programs", func(programs chi.Router) { + programs.Get("/", handler.listPrograms) + programs.With(nonNil(expensive)...).Post("/", handler.createProgram) + programs.Get("/{programId}", handler.getProgram) + programs.Get("/{programId}/manifests", handler.listManifests) + programs.Get("/{programId}/mapping-sets", handler.listMappingSets) + programs.With(nonNil(expensive)...).Post("/{programId}/mapping-sets", handler.createMappingSet) + programs.Post("/{programId}/mapping-sets/{mappingSetId}/freeze", handler.freezeMappingSet) + programs.Get("/{programId}/import-batches", handler.listImportBatches) + programs.With(nonNil(expensive)...).Post("/{programId}/import-batches", handler.createImportBatch) + programs.Get("/{programId}/import-batches/{batchId}", handler.getImportBatch) + programs.With(nonNil(expensive)...).Post("/{programId}/dry-runs", handler.queueDryRun) + programs.With(nonNil(expensive)...).Post("/{programId}/shadow-runs", handler.queueShadowRun) + programs.Get("/{programId}/runs/{runJobId}", handler.getRunJob) + programs.Get("/{programId}/divergences", handler.listDivergences) + programs.Post("/{programId}/readiness-assessments", handler.assessReadiness) + programs.Get("/{programId}/readiness-assessments/latest", handler.latestReadiness) + registerOperationalRoutes(programs, handler, nonNil(expensive)) + }) +} + +type scopeRequest struct { + ApplicationID string `json:"applicationId"` + Platform string `json:"platform"` +} + +type createProgramRequest struct { + EnvironmentID string `json:"environmentId"` + Applications []scopeRequest `json:"applications"` + RevenueCatProjectID string `json:"revenueCatProjectId"` + RevenueCatAPIKey string `json:"revenueCatApiKey"` + StabilizationDays int `json:"stabilizationDays,omitempty"` + RollbackWindowDays int `json:"rollbackWindowDays,omitempty"` +} + +func (request createProgramRequest) Validate() error { + err := validation.ValidateStruct(&request, + validation.Field(&request.EnvironmentID, validation.Required, validation.Length(1, 128)), + validation.Field(&request.Applications, validation.Required, validation.Length(1, 100)), + validation.Field(&request.RevenueCatProjectID, validation.Required, validation.Length(1, 256)), + validation.Field(&request.RevenueCatAPIKey, validation.Required, validation.Length(1, 4096)), + validation.Field(&request.StabilizationDays, validation.Min(0), validation.Max(30)), + validation.Field(&request.RollbackWindowDays, validation.Min(0), validation.Max(30)), + ) + if err != nil { + return err + } + for _, item := range request.Applications { + if err := validation.ValidateStruct(&item, + validation.Field(&item.ApplicationID, validation.Required, validation.Length(1, 128)), + validation.Field(&item.Platform, validation.Required, validation.In("ios", "android")), + ); err != nil { + return err + } + } + return nil +} + +func (h *Handler) createProgram(w http.ResponseWriter, r *http.Request) { + var request createProgramRequest + if !decode(w, r, &request) { + return + } + idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + if idempotencyKey == "" || len(idempotencyKey) > 128 { + response.Error(w, r, response.ValidationFailed(map[string][]string{"Idempotency-Key": {"must be between 1 and 128 characters"}})) + return + } + scopes := make([]billingmigration.ScopeItem, 0, len(request.Applications)) + for _, item := range request.Applications { + scopes = append(scopes, billingmigration.ScopeItem{ApplicationID: item.ApplicationID, Platform: item.Platform}) + } + created, replayed, err := h.services.Program.CreateProgram(r.Context(), actor(r), billingmigration.CreateProgramInput{ + ProjectID: chi.URLParam(r, "projectId"), EnvironmentID: request.EnvironmentID, + Applications: scopes, ExternalProjectID: request.RevenueCatProjectID, + Credential: []byte(request.RevenueCatAPIKey), IdempotencyKey: idempotencyKey, + StabilizationDays: request.StabilizationDays, RollbackWindowDays: request.RollbackWindowDays, + }) + request.RevenueCatAPIKey = "" + if err != nil { + writeError(w, r, err) + return + } + w.Header().Set("Location", "/v1/projects/"+chi.URLParam(r, "projectId")+"/billing/migration-programs/"+created.Program.ProgramID) + record := record("migrationProgram", created) + if replayed { + response.OK(w, r, record) + return + } + response.Created(w, r, record) +} + +func (h *Handler) listPrograms(w http.ResponseWriter, r *http.Request) { + limit := 50 + if raw := r.URL.Query().Get("limit"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + writeError(w, r, billingmigration.ErrInvalid) + return + } + limit = parsed + } + programs, err := h.services.Program.ListPrograms(r.Context(), actor(r), chi.URLParam(r, "projectId"), limit) + if err != nil { + writeError(w, r, err) + return + } + records := make([]billingmigration.ContractRecord[billingmigration.ProgramDetail], 0, len(programs)) + for _, program := range programs { + records = append(records, record("migrationProgram", program)) + } + response.OK(w, r, map[string]any{"items": records}) +} + +func (h *Handler) getProgram(w http.ResponseWriter, r *http.Request) { + program, err := h.services.Program.Program(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "programId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, record("migrationProgram", program)) +} + +func actor(r *http.Request) billingmigration.Actor { + principal, _ := authn.FromContext(r.Context()) + return billingmigration.Actor{ID: principal.ActorID} +} + +func decode(w http.ResponseWriter, r *http.Request, target interface{ Validate() error }) bool { + if encoding := r.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + writeError(w, r, billingmigration.ErrInvalid) + return false + } + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeError(w, r, billingmigration.ErrInvalid) + return false + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(w, r, billingmigration.ErrInvalid) + return false + } + if err := target.Validate(); err != nil { + response.Error(w, r, response.ValidationFailed(validationFields(err))) + return false + } + return true +} + +func validationFields(err error) map[string][]string { + errorsByField, ok := err.(validation.Errors) + if !ok { + return nil + } + result := make(map[string][]string, len(errorsByField)) + for field, fieldError := range errorsByField { + result[field] = []string{fieldError.Error()} + } + return result +} + +func writeError(w http.ResponseWriter, r *http.Request, err error) { + status, code, message := http.StatusInternalServerError, "internal_error", "An unexpected error occurred." + switch { + case errors.Is(err, billingmigration.ErrUnauthenticated): + status, code, message = http.StatusUnauthorized, "unauthenticated", "Authentication is required." + case errors.Is(err, billingmigration.ErrForbidden): + status, code, message = http.StatusForbidden, "migration_capability_denied", "You do not have permission to perform this migration command." + case errors.Is(err, billingmigration.ErrNotFound): + status, code, message = http.StatusNotFound, "migration_program_not_found", "The migration program was not found." + case errors.Is(err, billingmigration.ErrConflict): + status, code, message = http.StatusConflict, "migration_state_conflict", "The command conflicts with the current migration state or idempotency record." + case errors.Is(err, billingmigration.ErrIdempotencyConflict), errors.Is(err, billingmigration.ErrStaleState), errors.Is(err, billingmigration.ErrStaleDigest), errors.Is(err, billingmigration.ErrPointerCoverage): + status, code, message = http.StatusConflict, "migration_state_conflict", "The command conflicts with the current migration state or immutable evidence." + case errors.Is(err, billingmigration.ErrInvalid): + status, code, message = http.StatusUnprocessableEntity, "validation_failed", "The migration request is invalid." + case errors.Is(err, billingmigration.ErrUnavailable): + status, code, message = http.StatusServiceUnavailable, "migration_dependency_unavailable", "A required migration dependency is unavailable." + default: + zerolog.Ctx(r.Context()).Error().Str("migration_error_kind", fmt.Sprintf("%T", err)). + Msg("billing migration request failed") + } + response.Error(w, r, response.NewAPIError(status, code, message)) +} + +func nonNil(middleware []func(http.Handler) http.Handler) []func(http.Handler) http.Handler { + result := make([]func(http.Handler) http.Handler, 0, len(middleware)) + for _, item := range middleware { + if item != nil { + result = append(result, item) + } + } + return result +} diff --git a/apps/api/internal/transport/billingmigration/handler_test.go b/apps/api/internal/transport/billingmigration/handler_test.go new file mode 100644 index 00000000..9112b2f8 --- /dev/null +++ b/apps/api/internal/transport/billingmigration/handler_test.go @@ -0,0 +1,264 @@ +package billingmigrationhttp + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +type handlerRepository struct { + detail billingmigration.ProgramDetail +} + +func (*handlerRepository) Authorize(context.Context, billingmigration.Actor, string, string) (billingmigration.Authorization, error) { + return billingmigration.Authorization{OrganizationID: "org_one", Role: "owner"}, nil +} +func (*handlerRepository) AllowedCapabilities(context.Context, billingmigration.Actor, string) ([]string, error) { + return []string{billingmigration.CapabilityView, billingmigration.CapabilityManageSource}, nil +} +func (*handlerRepository) Idempotency(context.Context, string, string) (billingmigration.StoredIdempotency, error) { + return billingmigration.StoredIdempotency{}, billingmigration.ErrNotFound +} +func (repository *handlerRepository) CreateProgram(_ context.Context, command billingmigration.CreateProgramCommand) (billingmigration.ProgramDetail, error) { + repository.detail = billingmigration.ProgramDetail{Program: command.Program, SourceCapabilityAssessment: &command.Assessment} + return repository.detail, nil +} +func (*handlerRepository) ListPrograms(context.Context, string, int) ([]billingmigration.ProgramDetail, error) { + return nil, nil +} +func (repository *handlerRepository) Program(context.Context, string, string) (billingmigration.ProgramDetail, error) { + return repository.detail, nil +} + +type handlerCipher struct{} + +func (handlerCipher) EncryptSubject(_ []byte, scope providercredential.SubjectScope) (providercredential.Envelope, error) { + return providercredential.Envelope{Version: 1, Algorithm: "AES-256-GCM", KeyID: "key", + Nonce: make([]byte, 12), Ciphertext: make([]byte, 16), Fingerprint: make([]byte, 32), + CredentialClass: scope.CredentialClass}, nil +} +func (handlerCipher) DecryptSubject(providercredential.Envelope, providercredential.SubjectScope) ([]byte, error) { + return nil, errors.New("unused") +} +func (handlerCipher) ActiveKeyID() string { return "key" } + +type handlerAssessor struct{} + +func (handlerAssessor) AssessMigration(context.Context, string, []byte) (billingmigration.CapabilityResult, error) { + return billingmigration.CapabilityResult{ProviderAPIVersion: "v2", Capabilities: []string{"read_customers"}, AssessedAt: time.Now().UTC()}, nil +} + +func TestCreateProgramReturnsStrictRecordWithoutCredential(t *testing.T) { + service := billingmigration.NewService(&handlerRepository{}, handlerCipher{}, handlerAssessor{}, billingmigration.WithRandom(zeroReader{})) + router := chi.NewRouter() + router.Use(authn.Middleware(authn.ResolverFunc(func(*http.Request) (authn.Principal, error) { + return authn.Principal{ActorID: "owner_one", Method: "test"}, nil + }))) + router.Route("/v1/projects/{projectId}", func(project chi.Router) { RegisterProjectRoutes(project, Services{Program: service}) }) + body := `{"environmentId":"environment_one","applications":[{"applicationId":"app_one","platform":"ios"}],"revenueCatProjectId":"rc_project","revenueCatApiKey":"migration-secret"}` + request := httptest.NewRequest(http.MethodPost, "/v1/projects/project_one/billing/migration-programs/", bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Idempotency-Key", "create-one") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + responseBody := recorder.Body.String() + if strings.Contains(responseBody, "migration-secret") || strings.Contains(responseBody, "rc_project") { + t.Fatalf("response exposed migration source secret metadata: %s", responseBody) + } + if !strings.Contains(responseBody, `"billingMigrationOperationsContractVersion":"1"`) || + !strings.Contains(responseBody, `"recordType":"migrationProgram"`) { + t.Fatalf("response is not a strict migrationProgram record: %s", responseBody) + } + if !strings.Contains(responseBody, `"sourceCapabilityAssessment"`) || !strings.Contains(responseBody, `"read_customers"`) || !strings.Contains(responseBody, `"operatorCapabilities":["view","manage-source"]`) { + t.Fatalf("response discarded source capability assessment: %s", responseBody) + } +} + +func TestManifestAppendIsNotExposedToUnverifiedOperatorObjects(t *testing.T) { + service := billingmigration.NewService(&handlerRepository{}, handlerCipher{}, handlerAssessor{}) + router := chi.NewRouter() + router.Use(authn.Middleware(authn.ResolverFunc(func(*http.Request) (authn.Principal, error) { + return authn.Principal{ActorID: "owner_one", Method: "test"}, nil + }))) + router.Route("/v1/projects/{projectId}", func(project chi.Router) { RegisterProjectRoutes(project, Services{Program: service}) }) + request := httptest.NewRequest(http.MethodPost, + "/v1/projects/project_one/billing/migration-programs/program_one/manifests", + strings.NewReader(`{"objectKey":"foreign-project/object","objectChecksum":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`)) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusMethodNotAllowed { + t.Fatalf("unverified manifest append status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestRunRequestAcceptsOnlyFrozenDigestEncoding(t *testing.T) { + valid := "sha256:" + strings.Repeat("a", 64) + if err := (runRequest{ExpectedStateVersion: 1, ManifestDigest: valid, MappingDigest: valid}).Validate(); err != nil { + t.Fatalf("valid frozen digest rejected: %v", err) + } + for _, invalid := range []string{strings.Repeat("a", 64), "sha256:" + strings.Repeat("A", 64)} { + if err := (runRequest{ExpectedStateVersion: 1, ManifestDigest: invalid, MappingDigest: valid}).Validate(); err == nil { + t.Fatalf("invalid digest accepted: %q", invalid) + } + } +} + +func TestMappingRequestLargerThanLegacySixteenKiBIsAccepted(t *testing.T) { + var body strings.Builder + body.WriteString(`{"expectedStateVersion":1,"version":1,"entries":[`) + for index := 0; index < 100; index++ { + if index > 0 { + body.WriteByte(',') + } + body.WriteString(`{"sourceKind":"customer_id","sourceIdentifier":"`) + body.WriteString(strings.Repeat("a", 256)) + body.WriteString(`","targetId":"customer_one","matchKind":"exact"}`) + } + body.WriteString(`]}`) + if body.Len() <= 16<<10 { + t.Fatalf("fixture no longer exercises legacy limit: %d bytes", body.Len()) + } + + request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body.String())) + recorder := httptest.NewRecorder() + var decoded mappingSetRequest + if !decode(recorder, request, &decoded) { + t.Fatalf("bounded mapping request rejected: status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if len(decoded.Entries) != 100 { + t.Fatalf("decoded entries=%d", len(decoded.Entries)) + } +} + +func TestSourcePullRequiresIdempotencyKeyBeforeDispatch(t *testing.T) { + router := chi.NewRouter() + router.Route("/v1/projects/{projectId}", func(project chi.Router) { + RegisterProjectRoutes(project, Services{Program: billingmigration.NewService(&handlerRepository{}, handlerCipher{}, handlerAssessor{})}) + }) + request := httptest.NewRequest(http.MethodPost, "/v1/projects/project_one/billing/migration-programs/program_one/source-pulls", strings.NewReader(`{"intent":"snapshot","expectedStateVersion":1}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("disabled source-pull status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + // Once the runtime seam exists, the transport rejects a missing key before + // it can enqueue work. A nil repository is safe because dispatch must not occur. + router = chi.NewRouter() + router.Route("/v1/projects/{projectId}", func(project chi.Router) { + RegisterProjectRoutes(project, Services{Program: billingmigration.NewService(&handlerRepository{}, handlerCipher{}, handlerAssessor{}), SourcePull: billingmigration.NewSourcePullService(nil, nil)}) + }) + request = httptest.NewRequest(http.MethodPost, "/v1/projects/project_one/billing/migration-programs/program_one/source-pulls", strings.NewReader(`{"intent":"snapshot","expectedStateVersion":1}`)) + request.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnprocessableEntity || !strings.Contains(recorder.Body.String(), "Idempotency-Key") { + t.Fatalf("missing idempotency status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestRepairExecutionFailsClosedWhenExecutorIsOffline(t *testing.T) { + handler := &Handler{services: Services{RepairOnline: false}} + request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) + recorder := httptest.NewRecorder() + handler.executeRepair(recorder, request) + if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), "migration_dependency_unavailable") { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +type pendingRepairRepository struct { + billingmigration.OperationsRepository + state string +} + +func (repository pendingRepairRepository) PrepareRepair(context.Context, billingmigration.RepairExecutionWrite) (billingmigration.PreparedRepair, error) { + return billingmigration.PreparedRepair{ExecutionID: "mre_pending", RepairKind: billingmigration.RepairRevalidateProviderReference, CaseID: "case_one", AttemptNumber: 1, PreviewBeforeDigest: bytes.Repeat([]byte{1}, 32), State: repository.state}, nil +} + +type pendingRepairExecutor struct{} + +func (pendingRepairExecutor) Preview(context.Context, billingmigration.RepairRequest) (billingmigration.RepairImpact, error) { + return billingmigration.RepairImpact{}, errors.New("unused") +} +func (pendingRepairExecutor) Execute(context.Context, billingmigration.RepairRequest) (billingmigration.RepairResult, error) { + return billingmigration.RepairResult{}, billingmigration.ErrValidationPending +} + +func TestRepairExecutionPendingNewAndReplayReturnAccepted(t *testing.T) { + digest := "sha256:" + strings.Repeat("a", 64) + for _, state := range []string{billingmigration.RepairPreparationNew, billingmigration.RepairPreparationUnsettled} { + service := billingmigration.NewOperationsService(&handlerRepository{}, pendingRepairRepository{state: state}, pendingRepairExecutor{}) + router := chi.NewRouter() + router.Use(authn.Middleware(authn.ResolverFunc(func(*http.Request) (authn.Principal, error) { + return authn.Principal{ActorID: "owner_one", Method: "test"}, nil + }))) + router.Route("/v1/projects/{projectId}", func(project chi.Router) { + RegisterProjectRoutes(project, Services{Operations: service, RepairOnline: true}) + }) + body := `{"previewId":"preview_one","expectedStateVersion":1,"expectedPreviewDigest":"` + digest + `","expectedCaseDigest":"` + digest + `","expectedPolicyDigest":"` + digest + `","expectedScopeDigest":"` + digest + `"}` + request := httptest.NewRequest(http.MethodPost, "/v1/projects/project_one/billing/migration-programs/program_one/repair-executions", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Idempotency-Key", "repair-one") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusAccepted || !strings.Contains(recorder.Body.String(), `"executionStatus":"pending"`) || strings.Contains(recorder.Body.String(), `"afterDigest"`) || strings.Contains(recorder.Body.String(), `"resultDigest"`) { + t.Fatalf("state=%s status=%d body=%s", state, recorder.Code, recorder.Body.String()) + } + } +} + +func TestStabilizationObservationRejectsCallerSuppliedMetrics(t *testing.T) { + handler := &Handler{services: Services{Stabilization: billingmigration.NewStabilizationService(&handlerRepository{}, nil)}} + request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"expectedStateVersion":2,"expectedAuthorityEpoch":3,"expectedPolicyDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","healthy":true}`)) + request.Header.Set("Idempotency-Key", "observe-one") + recorder := httptest.NewRecorder() + handler.observeStabilization(recorder, request) + if recorder.Code != http.StatusUnprocessableEntity || !strings.Contains(recorder.Body.String(), "validation_failed") { + t.Fatalf("caller-supplied stabilization metric status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestOperationalErrorAndReplayResponseSemantics(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/", nil) + recorder := httptest.NewRecorder() + writeError(recorder, request, billingmigration.ErrForbidden) + if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "migration_capability_denied") { + t.Fatalf("forbidden status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + recorder = httptest.NewRecorder() + replayResponse(recorder, request, "sourcePullJob", map[string]string{"id": "job"}, true, true) + if recorder.Code != http.StatusOK { + t.Fatalf("idempotent replay status=%d body=%s", recorder.Code, recorder.Body.String()) + } + recorder = httptest.NewRecorder() + replayResponse(recorder, request, "sourcePullJob", map[string]string{"id": "job"}, false, true) + if recorder.Code != http.StatusAccepted { + t.Fatalf("new async command status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +type zeroReader struct{} + +func (zeroReader) Read(buffer []byte) (int, error) { + for index := range buffer { + buffer[index] = 0 + } + return len(buffer), nil +} diff --git a/apps/api/internal/transport/billingmigration/operations_handler.go b/apps/api/internal/transport/billingmigration/operations_handler.go new file mode 100644 index 00000000..8edaa35e --- /dev/null +++ b/apps/api/internal/transport/billingmigration/operations_handler.go @@ -0,0 +1,652 @@ +package billingmigrationhttp + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5" + validation "github.com/go-ozzo/ozzo-validation/v4" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +func registerOperationalRoutes(programs chi.Router, h *Handler, expensive []func(http.Handler) http.Handler) { + programs.Get("/{programId}/source-pulls", h.listSourcePulls) + programs.Get("/{programId}/source-pulls/{sourcePullId}", h.getSourcePull) + programs.With(expensive...).Post("/{programId}/source-pulls", h.queueSourcePull) + programs.Post("/{programId}/promote-ready", h.promoteReady) + programs.Post("/{programId}/cutover-proposals", h.proposeCutover) + programs.Post("/{programId}/rollback-proposals", h.proposeRollback) + programs.Post("/{programId}/proposals/{proposalId}/approvals", h.approveProposal) + programs.Post("/{programId}/checkpoints", h.createCheckpoint) + programs.With(expensive...).Post("/{programId}/cutover-executions", h.executeCutover) + programs.With(expensive...).Post("/{programId}/rollback-executions", h.executeRollback) + programs.Post("/{programId}/cases", h.createCase) + programs.Post("/{programId}/cases/{caseId}/transitions", h.transitionCase) + programs.With(expensive...).Post("/{programId}/repair-previews", h.previewRepair) + programs.With(expensive...).Post("/{programId}/repair-executions", h.executeRepair) + programs.Post("/{programId}/webhook-redeliveries", h.redeliverWebhook) + programs.Post("/{programId}/credential-removals", h.removeCredential) + programs.Post("/{programId}/legal-hold-proposals", h.proposeLegalHold) + programs.Post("/{programId}/legal-hold-proposals/{proposalId}/approvals", h.approveLegalHold) + programs.Get("/{programId}/completion", h.inspectCompletion) + programs.Post("/{programId}/completion", h.completeMigration) + registerOperationalReadRoutes(programs, h) + registerStabilizationRoutes(programs, h) +} + +func projectProgram(r *http.Request) (string, string) { + return chi.URLParam(r, "projectId"), chi.URLParam(r, "programId") +} + +func replayResponse(w http.ResponseWriter, r *http.Request, kind string, value any, replay bool, accepted bool) { + record := record(kind, value) + if replay { + response.OK(w, r, record) + } else if accepted { + response.Accepted(w, r, record) + } else { + response.Created(w, r, record) + } +} + +type sourcePullRequest struct { + Intent string `json:"intent"` + StartingCursor string `json:"startingCursor,omitempty"` + StartingWatermark string `json:"startingWatermark,omitempty"` + StartingWatermarkDigest string `json:"startingWatermarkDigest,omitempty"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` +} + +func (v sourcePullRequest) Validate() error { + return validation.ValidateStruct(&v, + validation.Field(&v.Intent, validation.Required, validation.In("snapshot", "delta", "final_delta")), + validation.Field(&v.StartingCursor, validation.Length(0, 512)), + validation.Field(&v.StartingWatermark, validation.Length(0, 512)), + validation.Field(&v.StartingWatermarkDigest, validation.When(v.StartingWatermarkDigest != "", validation.Match(sha256DigestPattern))), + validation.Field(&v.ExpectedStateVersion, validation.Min(1))) +} + +func (h *Handler) queueSourcePull(w http.ResponseWriter, r *http.Request) { + if h.services.SourcePull == nil { + writeError(w, r, billingmigration.ErrUnavailable) + return + } + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request sourcePullRequest + if !decode(w, r, &request) { + return + } + var watermarkDigest []byte + if request.StartingWatermarkDigest != "" { + var err error + watermarkDigest, err = billingmigration.ParseDigest(request.StartingWatermarkDigest) + if err != nil { + writeError(w, r, billingmigration.ErrInvalid) + return + } + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.SourcePull.Queue(r.Context(), billingmigration.SourcePullCommand{Actor: actor(r), ProjectID: projectID, ProgramID: programID, Intent: request.Intent, StartingCursor: request.StartingCursor, StartingWatermark: request.StartingWatermark, StartingWatermarkDigest: watermarkDigest, IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "sourcePullJob", item, replay, true) +} + +func (h *Handler) promoteReady(w http.ResponseWriter, r *http.Request) { + var request stateVersionRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, err := h.services.Program.PromoteReady(r.Context(), actor(r), billingmigration.PromoteReadyInput{ProjectID: projectID, ProgramID: programID, ExpectedStateVersion: request.ExpectedStateVersion}) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, record("authoritativeReadiness", item)) +} + +type digestSetRequest struct { + Scope string `json:"scope"` + Manifest string `json:"manifest"` + Mapping string `json:"mapping"` + Policy string `json:"policy"` + Evidence string `json:"evidence"` + Readiness string `json:"readiness"` + FinalWatermark string `json:"finalWatermark"` + ApplicationVersion string `json:"applicationVersion"` +} + +func (v digestSetRequest) Validate() error { + return validation.ValidateStruct(&v, + validation.Field(&v.Scope, validation.Required, validation.Match(sha256DigestPattern)), + validation.Field(&v.Manifest, validation.Required, validation.Match(sha256DigestPattern)), + validation.Field(&v.Mapping, validation.Required, validation.Match(sha256DigestPattern)), + validation.Field(&v.Policy, validation.Required, validation.Match(sha256DigestPattern)), + validation.Field(&v.Evidence, validation.Required, validation.Match(sha256DigestPattern)), + validation.Field(&v.Readiness, validation.Required, validation.Match(sha256DigestPattern)), + validation.Field(&v.FinalWatermark, validation.Required, validation.Match(sha256DigestPattern)), + validation.Field(&v.ApplicationVersion, validation.Required, validation.Match(sha256DigestPattern))) +} + +func (v digestSetRequest) domain() billingmigration.PreApprovalDigests { + return billingmigration.PreApprovalDigests{Scope: v.Scope, Manifest: v.Manifest, Mapping: v.Mapping, Policy: v.Policy, Evidence: v.Evidence, Readiness: v.Readiness, FinalWatermark: v.FinalWatermark, ApplicationVersion: v.ApplicationVersion} +} + +type cutoverProposalRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedDigests digestSetRequest `json:"expectedDigests"` + Reason string `json:"reason"` + ExpiresAt time.Time `json:"expiresAt"` +} + +func (v cutoverProposalRequest) Validate() error { + if err := validation.ValidateStruct(&v, validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.Reason, validation.Required, validation.Length(1, 500)), validation.Field(&v.ExpiresAt, validation.Required)); err != nil { + return err + } + return v.ExpectedDigests.Validate() +} + +func (h *Handler) proposeCutover(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request cutoverProposalRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Program.ProposeCutover(r.Context(), actor(r), billingmigration.ProposeCutoverInput{ProjectID: projectID, ProgramID: programID, IdempotencyKey: key, Command: "cutover", ExpectedStateVersion: request.ExpectedStateVersion, ExpectedDigests: request.ExpectedDigests.domain(), Reason: request.Reason, ExpiresAt: request.ExpiresAt}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "cutoverProposal", item, replay, false) +} + +type rollbackProposalRequest struct { + CheckpointID string `json:"checkpointId"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedCheckpointDigest string `json:"expectedCheckpointDigest"` + ExpectedAuthorityDigest string `json:"expectedAuthorityDigest"` + ExpectedRollbackPrerequisitesDigest string `json:"expectedRollbackPrerequisitesDigest"` + Reason string `json:"reason"` + ExpiresAt time.Time `json:"expiresAt"` +} + +func (v rollbackProposalRequest) Validate() error { + return validation.ValidateStruct(&v, + validation.Field(&v.CheckpointID, validation.Required, validation.Length(1, 128)), + validation.Field(&v.ExpectedStateVersion, validation.Min(1)), + validation.Field(&v.ExpectedCheckpointDigest, validation.Required, validation.Match(sha256DigestPattern)), + validation.Field(&v.ExpectedAuthorityDigest, validation.Required, validation.Match(sha256DigestPattern)), + validation.Field(&v.ExpectedRollbackPrerequisitesDigest, validation.Required, validation.Match(sha256DigestPattern)), + validation.Field(&v.Reason, validation.Required, validation.Length(1, 500)), validation.Field(&v.ExpiresAt, validation.Required)) +} + +func (h *Handler) proposeRollback(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request rollbackProposalRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Program.ProposeRollback(r.Context(), actor(r), billingmigration.ProposeRollbackInput{ProjectID: projectID, ProgramID: programID, IdempotencyKey: key, CheckpointID: request.CheckpointID, ExpectedStateVersion: request.ExpectedStateVersion, ExpectedCheckpointDigest: request.ExpectedCheckpointDigest, ExpectedAuthorityDigest: request.ExpectedAuthorityDigest, ExpectedRollbackPrerequisitesDigest: request.ExpectedRollbackPrerequisitesDigest, Reason: request.Reason, ExpiresAt: request.ExpiresAt}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "rollbackProposal", item, replay, false) +} + +type approvalRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` +} + +func (v approvalRequest) Validate() error { + return validation.Validate(&v.ExpectedStateVersion, validation.Min(1)) +} + +func (h *Handler) approveProposal(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request approvalRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Program.ApproveCutover(r.Context(), actor(r), billingmigration.ApproveCutoverInput{ProjectID: projectID, ProgramID: programID, ProposalID: chi.URLParam(r, "proposalId"), IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "migrationApproval", item, replay, false) +} + +type checkpointRequest struct { + ApprovalID string `json:"approvalId"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedDigests digestSetRequest `json:"expectedDigests"` + ApprovalDigest string `json:"approvalDigest"` + CohortDigest string `json:"cohortDigest"` +} + +func (v checkpointRequest) Validate() error { + if err := validation.ValidateStruct(&v, validation.Field(&v.ApprovalID, validation.Required, validation.Length(1, 128)), validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.ApprovalDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.CohortDigest, validation.Required, validation.Match(sha256DigestPattern))); err != nil { + return err + } + return v.ExpectedDigests.Validate() +} + +func (h *Handler) createCheckpoint(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request checkpointRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Program.CreateCheckpoint(r.Context(), actor(r), billingmigration.CreateCheckpointInput{ProjectID: projectID, ProgramID: programID, ApprovalID: request.ApprovalID, IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion, ExpectedDigests: request.ExpectedDigests.domain(), ApprovalDigest: request.ApprovalDigest, CohortDigest: request.CohortDigest}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "migrationCheckpoint", item, replay, false) +} + +type executionScopeRequest struct { + EnvironmentID string `json:"environmentId"` + Applications []scopeRequest `json:"applications"` +} + +func (v executionScopeRequest) domain(projectID string) billingmigration.Scope { + items := make([]billingmigration.ScopeItem, 0, len(v.Applications)) + for _, item := range v.Applications { + items = append(items, billingmigration.ScopeItem{ApplicationID: item.ApplicationID, Platform: item.Platform}) + } + return billingmigration.Scope{ProjectID: projectID, EnvironmentID: v.EnvironmentID, Applications: items} +} + +func (v executionScopeRequest) Validate() error { + if err := validation.ValidateStruct(&v, validation.Field(&v.EnvironmentID, validation.Required, validation.Length(1, 128)), validation.Field(&v.Applications, validation.Required, validation.Length(1, 100))); err != nil { + return err + } + for _, item := range v.Applications { + if err := validation.ValidateStruct(&item, validation.Field(&item.ApplicationID, validation.Required, validation.Length(1, 128)), validation.Field(&item.Platform, validation.Required, validation.In("ios", "android"))); err != nil { + return err + } + } + return nil +} + +type cutoverExecutionRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedDigests digestSetRequest `json:"expectedDigests"` + ApprovalDigest string `json:"approvalDigest"` + Reason string `json:"reason"` + Scope executionScopeRequest `json:"scope"` + CheckpointID string `json:"checkpointId"` + ApprovalID string `json:"approvalId"` + ExpectedAuthorityEpoch int64 `json:"expectedAuthorityEpoch"` +} + +func (v cutoverExecutionRequest) Validate() error { + if err := validation.ValidateStruct(&v, validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.ApprovalDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.Reason, validation.Required, validation.Length(1, 500)), validation.Field(&v.CheckpointID, validation.Required, validation.Length(1, 128)), validation.Field(&v.ApprovalID, validation.Required, validation.Length(1, 128)), validation.Field(&v.ExpectedAuthorityEpoch, validation.Min(0))); err != nil { + return err + } + if err := v.ExpectedDigests.Validate(); err != nil { + return err + } + return v.Scope.Validate() +} + +func (h *Handler) executeCutover(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request cutoverExecutionRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + d := request.ExpectedDigests + item, replay, err := h.services.Program.ExecuteCutover(r.Context(), actor(r), billingmigration.ExecuteCutoverInput{ProjectID: projectID, ProgramID: programID, IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion, ExpectedDigests: billingmigration.CutoverCommandDigests{Scope: d.Scope, Manifest: d.Manifest, Mapping: d.Mapping, Policy: d.Policy, Evidence: d.Evidence, Readiness: d.Readiness, FinalWatermark: d.FinalWatermark, ApplicationVersion: d.ApplicationVersion, Approval: request.ApprovalDigest}, Reason: request.Reason, Scope: request.Scope.domain(projectID), CheckpointID: request.CheckpointID, ApprovalID: request.ApprovalID, ExpectedAuthorityEpoch: request.ExpectedAuthorityEpoch}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "authorityExecution", item, replay, false) +} + +type rollbackExecutionRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedCheckpointDigest string `json:"expectedCheckpointDigest"` + ExpectedAuthorityDigest string `json:"expectedAuthorityDigest"` + ExpectedRollbackPrerequisitesDigest string `json:"expectedRollbackPrerequisitesDigest"` + ExpectedApprovalDigest string `json:"expectedApprovalDigest"` + Reason string `json:"reason"` + Scope executionScopeRequest `json:"scope"` + CheckpointID string `json:"checkpointId"` + ApprovalID string `json:"approvalId"` + ExpectedAuthorityEpoch int64 `json:"expectedAuthorityEpoch"` +} + +func (v rollbackExecutionRequest) Validate() error { + if err := validation.ValidateStruct(&v, validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.ExpectedCheckpointDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedAuthorityDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedRollbackPrerequisitesDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedApprovalDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.Reason, validation.Required, validation.Length(1, 500)), validation.Field(&v.CheckpointID, validation.Required, validation.Length(1, 128)), validation.Field(&v.ApprovalID, validation.Required, validation.Length(1, 128)), validation.Field(&v.ExpectedAuthorityEpoch, validation.Min(1))); err != nil { + return err + } + return v.Scope.Validate() +} +func (h *Handler) executeRollback(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request rollbackExecutionRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Program.ExecuteRollback(r.Context(), actor(r), billingmigration.ExecuteRollbackInput{ProjectID: projectID, ProgramID: programID, IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion, ExpectedDigests: billingmigration.RollbackCommandDigests{Checkpoint: request.ExpectedCheckpointDigest, Authority: request.ExpectedAuthorityDigest, RollbackPrerequisites: request.ExpectedRollbackPrerequisitesDigest, Approval: request.ExpectedApprovalDigest}, Reason: request.Reason, Scope: request.Scope.domain(projectID), CheckpointID: request.CheckpointID, ApprovalID: request.ApprovalID, ExpectedAuthorityEpoch: request.ExpectedAuthorityEpoch}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "authorityExecution", item, replay, false) +} + +type caseRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + Classification string `json:"classification"` + Reason string `json:"reason"` + LinkedDivergenceID string `json:"linkedDivergenceId,omitempty"` + LinkedSourceRecordID string `json:"linkedSourceRecordId,omitempty"` +} + +func (v caseRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.Classification, validation.Required, validation.In("critical", "blocking", "warning")), validation.Field(&v.Reason, validation.Required, validation.Length(1, 500)), validation.Field(&v.LinkedDivergenceID, validation.Length(0, 128)), validation.Field(&v.LinkedSourceRecordID, validation.Length(0, 128))) +} +func (h *Handler) createCase(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request caseRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Operations.CreateCase(r.Context(), actor(r), billingmigration.CreateCaseInput{ProjectID: projectID, ProgramID: programID, IdempotencyKey: key, Classification: request.Classification, Reason: request.Reason, ExpectedStateVersion: request.ExpectedStateVersion, LinkedDivergenceID: request.LinkedDivergenceID, LinkedSourceRecordID: request.LinkedSourceRecordID}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "migrationCase", item, replay, false) +} + +type caseTransitionRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedCaseDigest string `json:"expectedCaseDigest"` + Status string `json:"status"` + Reason string `json:"reason"` +} + +func (v caseTransitionRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.ExpectedCaseDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.Status, validation.Required, validation.In("in_progress", "resolved", "dismissed")), validation.Field(&v.Reason, validation.Required, validation.Length(1, 500))) +} +func (h *Handler) transitionCase(w http.ResponseWriter, r *http.Request) { + var request caseTransitionRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, err := h.services.Operations.TransitionCase(r.Context(), actor(r), billingmigration.TransitionCaseInput{ProjectID: projectID, ProgramID: programID, CaseID: chi.URLParam(r, "caseId"), Status: request.Status, Reason: request.Reason, ExpectedCaseDigest: request.ExpectedCaseDigest, ExpectedStateVersion: request.ExpectedStateVersion}) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, record("migrationCase", item)) +} + +type repairPreviewRequest struct { + CaseID string `json:"caseId"` + RepairKind string `json:"repairKind"` + ScopeKind string `json:"scopeKind"` + ScopeReferences []string `json:"scopeReferences"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedCaseDigest string `json:"expectedCaseDigest"` + ExpectedPolicyDigest string `json:"expectedPolicyDigest"` + ExpectedScopeDigest string `json:"expectedScopeDigest"` + Reason string `json:"reason"` + ExpiresAt time.Time `json:"expiresAt"` +} + +func (v repairPreviewRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.CaseID, validation.Required, validation.Length(1, 128)), validation.Field(&v.RepairKind, validation.Required), validation.Field(&v.ScopeKind, validation.Required), validation.Field(&v.ScopeReferences, validation.Required, validation.Length(1, 100)), validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.ExpectedCaseDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedPolicyDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedScopeDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.Reason, validation.Required, validation.Length(1, 500)), validation.Field(&v.ExpiresAt, validation.Required)) +} +func (h *Handler) previewRepair(w http.ResponseWriter, r *http.Request) { + if !h.services.RepairOnline { + writeError(w, r, billingmigration.ErrUnavailable) + return + } + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request repairPreviewRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Operations.PreviewRepair(r.Context(), actor(r), billingmigration.PreviewRepairInput{ProjectID: projectID, ProgramID: programID, CaseID: request.CaseID, IdempotencyKey: key, RepairKind: request.RepairKind, ScopeKind: request.ScopeKind, Reason: request.Reason, ScopeReferences: request.ScopeReferences, ExpectedStateVersion: request.ExpectedStateVersion, ExpectedCaseDigest: request.ExpectedCaseDigest, ExpectedPolicyDigest: request.ExpectedPolicyDigest, ExpectedScopeDigest: request.ExpectedScopeDigest, ExpiresAt: request.ExpiresAt}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "repairPreview", item, replay, false) +} + +type repairExecutionRequest struct { + PreviewID string `json:"previewId"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedPreviewDigest string `json:"expectedPreviewDigest"` + ExpectedCaseDigest string `json:"expectedCaseDigest"` + ExpectedPolicyDigest string `json:"expectedPolicyDigest"` + ExpectedScopeDigest string `json:"expectedScopeDigest"` +} + +func (v repairExecutionRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.PreviewID, validation.Required, validation.Length(1, 128)), validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.ExpectedPreviewDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedCaseDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedPolicyDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedScopeDigest, validation.Required, validation.Match(sha256DigestPattern))) +} +func (h *Handler) executeRepair(w http.ResponseWriter, r *http.Request) { + if !h.services.RepairOnline { + writeError(w, r, billingmigration.ErrUnavailable) + return + } + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request repairExecutionRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Operations.ExecuteRepair(r.Context(), actor(r), billingmigration.ExecuteRepairInput{ProjectID: projectID, ProgramID: programID, PreviewID: request.PreviewID, IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion, ExpectedPreviewDigest: request.ExpectedPreviewDigest, ExpectedCaseDigest: request.ExpectedCaseDigest, ExpectedPolicyDigest: request.ExpectedPolicyDigest, ExpectedScopeDigest: request.ExpectedScopeDigest}) + if err != nil { + writeError(w, r, err) + return + } + result := record("repairExecution", item) + if item.Status == "pending" { + response.Accepted(w, r, result) + } else if replay { + response.OK(w, r, result) + } else { + response.Created(w, r, result) + } +} + +type redeliveryRequest struct { + EventID string `json:"eventId"` + DestinationID string `json:"destinationId"` + ExpectedEventDigest string `json:"expectedEventDigest"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` + Reason string `json:"reason"` +} + +func (v redeliveryRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.EventID, validation.Required, validation.Length(1, 128)), validation.Field(&v.DestinationID, validation.Required, validation.Length(1, 128)), validation.Field(&v.ExpectedEventDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.Reason, validation.Required, validation.Length(1, 500))) +} +func (h *Handler) redeliverWebhook(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request redeliveryRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Redelivery.Redeliver(r.Context(), actor(r), billingmigration.RedeliveryInput{ProjectID: projectID, ProgramID: programID, EventID: request.EventID, DestinationID: request.DestinationID, IdempotencyKey: key, ExpectedEventDigest: request.ExpectedEventDigest, Reason: request.Reason, ExpectedStateVersion: request.ExpectedStateVersion}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "webhookRedelivery", item, replay, true) +} + +type removalRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + Reason string `json:"reason"` + IrreversibleAcknowledged bool `json:"irreversibleAcknowledged"` +} + +func (v removalRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.Reason, validation.Required, validation.Length(1, 500)), validation.Field(&v.IrreversibleAcknowledged, validation.In(true))) +} +func (h *Handler) removeCredential(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request removalRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Operations.RemoveMigrationCredential(r.Context(), actor(r), billingmigration.RemoveCredentialInput{ProjectID: projectID, ProgramID: programID, IdempotencyKey: key, Reason: request.Reason, ExpectedStateVersion: request.ExpectedStateVersion, IrreversibleAcknowledged: request.IrreversibleAcknowledged}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "credentialRemoval", item, replay, false) +} + +type legalHoldProposalRequest struct { + Command string `json:"command"` + Reason string `json:"reason"` + ExternalComplianceReference string `json:"externalComplianceReference"` + ExpectedPreviousCommandDigest string `json:"expectedPreviousCommandDigest,omitempty"` + ExpiresAt time.Time `json:"expiresAt"` +} + +func (v legalHoldProposalRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.Command, validation.Required, validation.In("set", "release")), validation.Field(&v.Reason, validation.Required, validation.Length(1, 500)), validation.Field(&v.ExternalComplianceReference, validation.Required, validation.Length(1, 256)), validation.Field(&v.ExpectedPreviousCommandDigest, validation.When(v.ExpectedPreviousCommandDigest != "", validation.Match(sha256DigestPattern))), validation.Field(&v.ExpiresAt, validation.Required)) +} +func (h *Handler) proposeLegalHold(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request legalHoldProposalRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Operations.ProposeLegalHold(r.Context(), actor(r), billingmigration.ProposeLegalHoldInput{ProjectID: projectID, ProgramID: programID, IdempotencyKey: key, Command: request.Command, Reason: request.Reason, ExternalComplianceReference: request.ExternalComplianceReference, ExpectedPreviousCommandDigest: request.ExpectedPreviousCommandDigest, ExpiresAt: request.ExpiresAt}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "legalHoldProposal", item, replay, false) +} + +type legalHoldApprovalRequest struct { + ExpectedProposalDigest string `json:"expectedProposalDigest"` +} + +func (v legalHoldApprovalRequest) Validate() error { + return validation.Validate(&v.ExpectedProposalDigest, validation.Required, validation.Match(sha256DigestPattern)) +} +func (h *Handler) approveLegalHold(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request legalHoldApprovalRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Operations.ApproveLegalHold(r.Context(), actor(r), billingmigration.ApproveLegalHoldInput{ProjectID: projectID, ProgramID: programID, ProposalID: chi.URLParam(r, "proposalId"), IdempotencyKey: key, ExpectedProposalDigest: request.ExpectedProposalDigest}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "legalHold", item, replay, false) +} + +func (h *Handler) inspectCompletion(w http.ResponseWriter, r *http.Request) { + projectID, programID := projectProgram(r) + item, err := h.services.Operations.InspectCompletion(r.Context(), actor(r), projectID, programID) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, record("completionPrerequisites", item)) +} + +type completionRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedPolicyDigest string `json:"expectedPolicyDigest"` + ExpectedAuthorityDigest string `json:"expectedAuthorityDigest"` + ExpectedStabilityEvidenceDigest string `json:"expectedStabilityEvidenceDigest"` +} + +func (v completionRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.ExpectedPolicyDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedAuthorityDigest, validation.Required, validation.Match(sha256DigestPattern)), validation.Field(&v.ExpectedStabilityEvidenceDigest, validation.Required, validation.Match(sha256DigestPattern))) +} +func (h *Handler) completeMigration(w http.ResponseWriter, r *http.Request) { + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request completionRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Operations.CompleteMigration(r.Context(), actor(r), billingmigration.CompleteMigrationInput{ProjectID: projectID, ProgramID: programID, IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion, ExpectedPolicyDigest: request.ExpectedPolicyDigest, ExpectedAuthorityDigest: request.ExpectedAuthorityDigest, ExpectedStabilityEvidenceDigest: request.ExpectedStabilityEvidenceDigest}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "completionReport", item, replay, false) +} diff --git a/apps/api/internal/transport/billingmigration/read_ab_handler.go b/apps/api/internal/transport/billingmigration/read_ab_handler.go new file mode 100644 index 00000000..3f904956 --- /dev/null +++ b/apps/api/internal/transport/billingmigration/read_ab_handler.go @@ -0,0 +1,168 @@ +package billingmigrationhttp + +import ( + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" + "github.com/go-chi/chi/v5" + "net/http" +) + +func (h *Handler) readReady(w http.ResponseWriter, r *http.Request) bool { + if h.services.Reads == nil { + writeError(w, r, billingmigration.ErrUnavailable) + return false + } + return true +} +func (h *Handler) listSourcePulls(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + limit, ok := readLimit(w, r) + if !ok { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.SourcePulls(r.Context(), actor(r), p, g, r.URL.Query().Get("cursor"), limit) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, v) +} +func (h *Handler) getSourcePull(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.SourcePull(r.Context(), actor(r), p, g, chi.URLParam(r, "sourcePullId")) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, record("sourcePullJob", v)) +} +func (h *Handler) listProposals(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + l, o := readLimit(w, r) + if !o { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.Proposals(r.Context(), actor(r), p, g, r.URL.Query().Get("cursor"), l) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, v) +} +func (h *Handler) getProposal(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.Proposal(r.Context(), actor(r), p, g, chi.URLParam(r, "proposalId")) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, record("migrationProposal", v)) +} +func (h *Handler) listApprovals(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + l, o := readLimit(w, r) + if !o { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.Approvals(r.Context(), actor(r), p, g, r.URL.Query().Get("cursor"), l) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, v) +} +func (h *Handler) getApproval(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.Approval(r.Context(), actor(r), p, g, chi.URLParam(r, "approvalId")) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, record("migrationApproval", v)) +} +func (h *Handler) listCheckpoints(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + l, o := readLimit(w, r) + if !o { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.Checkpoints(r.Context(), actor(r), p, g, r.URL.Query().Get("cursor"), l) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, v) +} +func (h *Handler) getCheckpoint(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.Checkpoint(r.Context(), actor(r), p, g, chi.URLParam(r, "checkpointId")) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, record("migrationCheckpoint", v)) +} +func (h *Handler) getLatestCheckpoint(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.LatestCheckpoint(r.Context(), actor(r), p, g) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, record("migrationCheckpoint", v)) +} +func (h *Handler) listAuthorityExecutions(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + l, o := readLimit(w, r) + if !o { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.AuthorityExecutions(r.Context(), actor(r), p, g, r.URL.Query().Get("cursor"), l) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, v) +} +func (h *Handler) getAuthorityExecution(w http.ResponseWriter, r *http.Request) { + if !h.readReady(w, r) { + return + } + p, g := projectProgram(r) + v, e := h.services.Reads.AuthorityExecution(r.Context(), actor(r), p, g, chi.URLParam(r, "executionId")) + if e != nil { + writeError(w, r, e) + return + } + response.OK(w, r, record("authorityExecution", v)) +} diff --git a/apps/api/internal/transport/billingmigration/read_cde_handler.go b/apps/api/internal/transport/billingmigration/read_cde_handler.go new file mode 100644 index 00000000..241aaa80 --- /dev/null +++ b/apps/api/internal/transport/billingmigration/read_cde_handler.go @@ -0,0 +1,207 @@ +package billingmigrationhttp + +import ( + "net/http" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" + "github.com/go-chi/chi/v5" +) + +func registerOperationalReadRoutes(programs chi.Router, h *Handler) { + programs.Get("/{programId}/proposals", h.listProposals) + programs.Get("/{programId}/proposals/{proposalId}", h.getProposal) + programs.Get("/{programId}/approvals", h.listApprovals) + programs.Get("/{programId}/approvals/{approvalId}", h.getApproval) + programs.Get("/{programId}/checkpoints", h.listCheckpoints) + programs.Get("/{programId}/checkpoints/latest", h.getLatestCheckpoint) + programs.Get("/{programId}/checkpoints/{checkpointId}", h.getCheckpoint) + programs.Get("/{programId}/authority-executions", h.listAuthorityExecutions) + programs.Get("/{programId}/authority-executions/{executionId}", h.getAuthorityExecution) + programs.Get("/{programId}/cases", h.listCases) + programs.Get("/{programId}/cases/{caseId}", h.getCase) + programs.Get("/{programId}/cases/{caseId}/actions", h.listCaseActions) + programs.Get("/{programId}/repair-previews", h.listRepairPreviews) + programs.Get("/{programId}/repair-previews/{previewId}", h.getRepairPreview) + programs.Get("/{programId}/repair-executions", h.listRepairExecutions) + programs.Get("/{programId}/repair-executions/{executionId}", h.getRepairExecution) + programs.Get("/{programId}/webhook-redeliveries", h.listWebhookRedeliveries) + programs.Get("/{programId}/webhook-redeliveries/{redeliveryId}", h.getWebhookRedelivery) + programs.Get("/{programId}/credential-removals", h.listCredentialRemovals) + programs.Get("/{programId}/credential-removals/current", h.getCurrentCredentialRemoval) + programs.Get("/{programId}/credential-removals/{removalId}", h.getCredentialRemoval) + programs.Get("/{programId}/legal-hold-proposals", h.listLegalHoldProposals) + programs.Get("/{programId}/legal-hold-proposals/{proposalId}", h.getLegalHoldProposal) + programs.Get("/{programId}/legal-holds", h.listLegalHolds) + programs.Get("/{programId}/legal-holds/current", h.getCurrentLegalHold) + programs.Get("/{programId}/legal-holds/{holdId}", h.getLegalHold) + programs.Get("/{programId}/completion-history", h.listCompletionReports) + programs.Get("/{programId}/completion-history/{reportId}", h.getCompletionReport) +} + +func registerStabilizationRoutes(programs chi.Router, h *Handler) { + programs.Post("/{programId}/stabilization-policy", h.freezeStabilizationPolicy) + programs.Get("/{programId}/stabilization-policy/current", h.getCurrentStabilizationPolicy) + programs.Post("/{programId}/stabilization-observations", h.observeStabilization) + programs.Get("/{programId}/stabilization-observations", h.listStabilizationObservations) + programs.Get("/{programId}/stabilization-observations/latest", h.getLatestStabilizationObservation) + programs.Post("/{programId}/rollback-readiness-assessments", h.assessRollbackReadiness) + programs.Get("/{programId}/rollback-readiness-assessments", h.listRollbackReadinessAssessments) + programs.Get("/{programId}/rollback-readiness-assessments/latest", h.getLatestRollbackReadinessAssessment) + programs.Get("/{programId}/rollback-readiness-checkpoints/latest", h.getLatestRollbackReadinessCheckpoint) +} + +func serveTypedList[T any](h *Handler, w http.ResponseWriter, r *http.Request, read func(string, string, string, int) (T, error)) { + if !h.readReady(w, r) { + return + } + limit, ok := readLimit(w, r) + if !ok { + return + } + projectID, programID := projectProgram(r) + page, err := read(projectID, programID, r.URL.Query().Get("cursor"), limit) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, page) +} +func serveTypedDetail[T any](h *Handler, w http.ResponseWriter, r *http.Request, kind string, read func(string, string) (T, error)) { + if !h.readReady(w, r) { + return + } + projectID, programID := projectProgram(r) + item, err := read(projectID, programID) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, record(kind, item)) +} + +func (h *Handler) listCases(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.CasePage, error) { + return h.services.Reads.Cases(r.Context(), actor(r), p, g, c, l) + }) +} +func (h *Handler) getCase(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "migrationCase", func(p, g string) (billingmigration.MigrationCase, error) { + return h.services.Reads.Case(r.Context(), actor(r), p, g, chi.URLParam(r, "caseId")) + }) +} +func (h *Handler) listCaseActions(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.CaseActionPage, error) { + return h.services.Reads.CaseActions(r.Context(), actor(r), p, g, chi.URLParam(r, "caseId"), c, l) + }) +} +func (h *Handler) listRepairPreviews(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.RepairPreviewPage, error) { + return h.services.Reads.RepairPreviews(r.Context(), actor(r), p, g, c, l) + }) +} +func (h *Handler) getRepairPreview(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "repairPreview", func(p, g string) (billingmigration.RepairPreviewRecord, error) { + return h.services.Reads.RepairPreview(r.Context(), actor(r), p, g, chi.URLParam(r, "previewId")) + }) +} +func (h *Handler) listRepairExecutions(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.RepairExecutionPage, error) { + return h.services.Reads.RepairExecutions(r.Context(), actor(r), p, g, c, l) + }) +} +func (h *Handler) getRepairExecution(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "repairExecution", func(p, g string) (billingmigration.RepairExecutionRecord, error) { + return h.services.Reads.RepairExecution(r.Context(), actor(r), p, g, chi.URLParam(r, "executionId")) + }) +} +func (h *Handler) listWebhookRedeliveries(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.RedeliveryPage, error) { + return h.services.Reads.Redeliveries(r.Context(), actor(r), p, g, c, l) + }) +} +func (h *Handler) getWebhookRedelivery(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "webhookRedelivery", func(p, g string) (billingmigration.RedeliveryRecord, error) { + return h.services.Reads.Redelivery(r.Context(), actor(r), p, g, chi.URLParam(r, "redeliveryId")) + }) +} +func (h *Handler) listCredentialRemovals(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.CredentialRemovalPage, error) { + return h.services.Reads.CredentialRemovals(r.Context(), actor(r), p, g, c, l) + }) +} +func (h *Handler) getCredentialRemoval(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "credentialRemoval", func(p, g string) (billingmigration.CredentialRemovalRecord, error) { + return h.services.Reads.CredentialRemoval(r.Context(), actor(r), p, g, chi.URLParam(r, "removalId")) + }) +} +func (h *Handler) getCurrentCredentialRemoval(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "credentialRemoval", func(p, g string) (billingmigration.CredentialRemovalRecord, error) { + return h.services.Reads.CurrentCredentialRemoval(r.Context(), actor(r), p, g) + }) +} +func (h *Handler) listLegalHoldProposals(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.LegalHoldProposalPage, error) { + return h.services.Reads.LegalHoldProposals(r.Context(), actor(r), p, g, c, l) + }) +} +func (h *Handler) getLegalHoldProposal(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "legalHoldProposal", func(p, g string) (billingmigration.LegalHoldProposalRecord, error) { + return h.services.Reads.LegalHoldProposal(r.Context(), actor(r), p, g, chi.URLParam(r, "proposalId")) + }) +} +func (h *Handler) listLegalHolds(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.LegalHoldPage, error) { + return h.services.Reads.LegalHolds(r.Context(), actor(r), p, g, c, l) + }) +} +func (h *Handler) getLegalHold(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "legalHold", func(p, g string) (billingmigration.LegalHoldRecord, error) { + return h.services.Reads.LegalHold(r.Context(), actor(r), p, g, chi.URLParam(r, "holdId")) + }) +} +func (h *Handler) getCurrentLegalHold(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "legalHold", func(p, g string) (billingmigration.LegalHoldRecord, error) { + return h.services.Reads.CurrentLegalHold(r.Context(), actor(r), p, g) + }) +} +func (h *Handler) listCompletionReports(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.CompletionReportPage, error) { + return h.services.Reads.CompletionReports(r.Context(), actor(r), p, g, c, l) + }) +} +func (h *Handler) getCompletionReport(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "completionReport", func(p, g string) (billingmigration.CompletionReportRecord, error) { + return h.services.Reads.CompletionReport(r.Context(), actor(r), p, g, chi.URLParam(r, "reportId")) + }) +} +func (h *Handler) getCurrentStabilizationPolicy(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "stabilizationPolicy", func(p, g string) (billingmigration.StabilizationPolicyRecord, error) { + return h.services.Reads.CurrentStabilizationPolicy(r.Context(), actor(r), p, g) + }) +} +func (h *Handler) listStabilizationObservations(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.StabilizationObservationPage, error) { + return h.services.Reads.StabilizationObservations(r.Context(), actor(r), p, g, c, l) + }) +} +func (h *Handler) getLatestStabilizationObservation(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "stabilizationObservation", func(p, g string) (billingmigration.StabilizationObservationRecord, error) { + return h.services.Reads.LatestStabilizationObservation(r.Context(), actor(r), p, g) + }) +} +func (h *Handler) listRollbackReadinessAssessments(w http.ResponseWriter, r *http.Request) { + serveTypedList(h, w, r, func(p, g, c string, l int) (billingmigration.RollbackReadinessAssessmentPage, error) { + return h.services.Reads.RollbackReadinessAssessments(r.Context(), actor(r), p, g, c, l) + }) +} +func (h *Handler) getLatestRollbackReadinessAssessment(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "rollbackReadinessAssessment", func(p, g string) (billingmigration.RollbackReadinessAssessmentRecord, error) { + return h.services.Reads.LatestRollbackReadinessAssessment(r.Context(), actor(r), p, g) + }) +} +func (h *Handler) getLatestRollbackReadinessCheckpoint(w http.ResponseWriter, r *http.Request) { + serveTypedDetail(h, w, r, "rollbackReadinessCheckpoint", func(p, g string) (billingmigration.RollbackReadinessCheckpointRecord, error) { + return h.services.Reads.LatestRollbackReadinessCheckpoint(r.Context(), actor(r), p, g) + }) +} diff --git a/apps/api/internal/transport/billingmigration/read_stabilization_handler.go b/apps/api/internal/transport/billingmigration/read_stabilization_handler.go new file mode 100644 index 00000000..a376265e --- /dev/null +++ b/apps/api/internal/transport/billingmigration/read_stabilization_handler.go @@ -0,0 +1,129 @@ +package billingmigrationhttp + +import ( + "net/http" + + validation "github.com/go-ozzo/ozzo-validation/v4" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" +) + +type stabilizationThresholdsRequest struct { + AuthorityMismatchMax int64 `json:"authorityMismatchMax"` + AccessAPIErrorMax int64 `json:"accessApiErrorMax"` + SDKSyncFailureMax int64 `json:"sdkSyncFailureMax"` + DivergenceMax int64 `json:"divergenceMax"` + ValidationBacklogMax int64 `json:"validationBacklogMax"` + SourceDeltaLagMaxSeconds int64 `json:"sourceDeltaLagMaxSeconds"` + WebhookFailureMax int64 `json:"webhookFailureMax"` + WebhookFreshnessMaxSeconds int64 `json:"webhookFreshnessMaxSeconds"` + QuarantineMax int64 `json:"quarantineMax"` + SupportCaseMax int64 `json:"supportCaseMax"` + OldAppVersionMax int64 `json:"oldAppVersionMax"` + WorkerUnhealthyMax int64 `json:"workerUnhealthyMax"` +} + +func (v stabilizationThresholdsRequest) domain() billingmigration.StabilizationThresholds { + return billingmigration.StabilizationThresholds{AuthorityMismatchMax: v.AuthorityMismatchMax, AccessAPIErrorMax: v.AccessAPIErrorMax, SDKSyncFailureMax: v.SDKSyncFailureMax, DivergenceMax: v.DivergenceMax, ValidationBacklogMax: v.ValidationBacklogMax, SourceDeltaLagMaxSeconds: v.SourceDeltaLagMaxSeconds, WebhookFailureMax: v.WebhookFailureMax, WebhookFreshnessMaxSeconds: v.WebhookFreshnessMaxSeconds, QuarantineMax: v.QuarantineMax, SupportCaseMax: v.SupportCaseMax, OldAppVersionMax: v.OldAppVersionMax, WorkerUnhealthyMax: v.WorkerUnhealthyMax} +} + +type freezePolicyRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + Thresholds stabilizationThresholdsRequest `json:"thresholds"` +} + +func (v freezePolicyRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.Thresholds, validation.By(func(any) error { + if !v.Thresholds.domain().Valid() { + return validation.NewError("validation_invalid", "contains invalid thresholds") + } + return nil + }))) +} + +func (h *Handler) freezeStabilizationPolicy(w http.ResponseWriter, r *http.Request) { + if h.services.Stabilization == nil { + writeError(w, r, billingmigration.ErrUnavailable) + return + } + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request freezePolicyRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Stabilization.FreezePolicy(r.Context(), actor(r), billingmigration.FreezeStabilizationPolicyInput{ProjectID: projectID, ProgramID: programID, IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion, Thresholds: request.Thresholds.domain()}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "stabilizationPolicy", item, replay, false) +} + +type stabilizationObservationRequest struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedAuthorityEpoch int64 `json:"expectedAuthorityEpoch"` + ExpectedPolicyDigest string `json:"expectedPolicyDigest"` +} + +func (v stabilizationObservationRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.ExpectedAuthorityEpoch, validation.Min(1)), validation.Field(&v.ExpectedPolicyDigest, validation.Required, validation.Match(sha256DigestPattern))) +} + +func (h *Handler) observeStabilization(w http.ResponseWriter, r *http.Request) { + if h.services.Stabilization == nil { + writeError(w, r, billingmigration.ErrUnavailable) + return + } + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request stabilizationObservationRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + item, replay, err := h.services.Stabilization.Observe(r.Context(), actor(r), billingmigration.RecordStabilizationInput{ProjectID: projectID, ProgramID: programID, IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion, ExpectedAuthorityEpoch: request.ExpectedAuthorityEpoch, ExpectedPolicyDigest: request.ExpectedPolicyDigest}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "stabilizationObservation", item, replay, false) +} + +type rollbackReadinessRequest struct { + ObservationID string `json:"observationId"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` + ExpectedAuthorityEpoch int64 `json:"expectedAuthorityEpoch"` + ExpectedObservationDigest string `json:"expectedObservationDigest"` +} + +func (v rollbackReadinessRequest) Validate() error { + return validation.ValidateStruct(&v, validation.Field(&v.ObservationID, validation.Required, validation.Length(1, 128)), validation.Field(&v.ExpectedStateVersion, validation.Min(1)), validation.Field(&v.ExpectedAuthorityEpoch, validation.Min(1)), validation.Field(&v.ExpectedObservationDigest, validation.Required, validation.Match(sha256DigestPattern))) +} + +func (h *Handler) assessRollbackReadiness(w http.ResponseWriter, r *http.Request) { + if h.services.RollbackReadiness == nil { + writeError(w, r, billingmigration.ErrUnavailable) + return + } + key, ok := idempotencyKey(w, r) + if !ok { + return + } + var request rollbackReadinessRequest + if !decode(w, r, &request) { + return + } + projectID, programID := projectProgram(r) + assessment, checkpoint, replay, err := h.services.RollbackReadiness.Assess(r.Context(), actor(r), billingmigration.AssessRollbackReadinessInput{ProjectID: projectID, ProgramID: programID, ObservationID: request.ObservationID, IdempotencyKey: key, ExpectedStateVersion: request.ExpectedStateVersion, ExpectedAuthorityEpoch: request.ExpectedAuthorityEpoch, ExpectedObservationDigest: request.ExpectedObservationDigest}) + if err != nil { + writeError(w, r, err) + return + } + replayResponse(w, r, "rollbackReadinessResult", map[string]any{"assessment": assessment, "checkpoint": checkpoint}, replay, false) +} diff --git a/apps/api/internal/transport/billingwebhook/handler.go b/apps/api/internal/transport/billingwebhook/handler.go index 52c3435f..349a7e94 100644 --- a/apps/api/internal/transport/billingwebhook/handler.go +++ b/apps/api/internal/transport/billingwebhook/handler.go @@ -383,6 +383,8 @@ func writeError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, billingwebhook.ErrUnauthenticated): status, code, message = http.StatusUnauthorized, "unauthenticated", "Authentication is required." + case errors.Is(err, billingwebhook.ErrForbidden): + status, code, message = http.StatusForbidden, "forbidden", "You do not have permission to perform this action." case errors.Is(err, billingwebhook.ErrNotFound): status, code, message = http.StatusNotFound, "not_found", "The requested resource was not found." case errors.Is(err, billingwebhook.ErrDestinationRefused): diff --git a/apps/api/internal/transport/cloudworkspace/handler.go b/apps/api/internal/transport/cloudworkspace/handler.go index d99a1c2d..b7993214 100644 --- a/apps/api/internal/transport/cloudworkspace/handler.go +++ b/apps/api/internal/transport/cloudworkspace/handler.go @@ -43,6 +43,7 @@ func RegisterRoutes(router chi.Router, service *cloudworkspace.Service, resolver func RegisterWorkspaceRoutes(router chi.Router, service *cloudworkspace.Service) { handler := &Handler{service: service} + router.Get("/workspace/bootstrap", handler.getWorkspaceBootstrap) router.Route("/organizations", func(router chi.Router) { router.Get("/", handler.listOrganizations) router.Post("/", handler.createOrganization) diff --git a/apps/api/internal/transport/cloudworkspace/handler_bootstrap_test.go b/apps/api/internal/transport/cloudworkspace/handler_bootstrap_test.go new file mode 100644 index 00000000..9b13e615 --- /dev/null +++ b/apps/api/internal/transport/cloudworkspace/handler_bootstrap_test.go @@ -0,0 +1,80 @@ +package cloudworkspacehttp_test + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/cloudworkspacememory" + cloudworkspacehttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/cloudworkspace" +) + +func TestWorkspaceBootstrapRequiresAPrincipalAndCarriesProjects(t *testing.T) { + service := cloudworkspace.NewService(cloudworkspacememory.New()) + anonymous := cloudworkspacehttp.Routes(service, authn.AnonymousResolver{}) + assertErrorCode(t, request(t, anonymous, http.MethodGet, "/workspace/bootstrap", ""), + http.StatusUnauthorized, "unauthenticated") + + actor := cloudworkspace.Actor{ID: "actor-owner"} + organization, err := service.CreateOrganization(t.Context(), actor, "Northwind") + if err != nil { + t.Fatalf("create organization: %v", err) + } + project, err := service.CreateProject(t.Context(), actor, organization.ID, "mobile", "Mobile") + if err != nil { + t.Fatalf("create project: %v", err) + } + handler := cloudworkspacehttp.Routes(service, authn.ResolverFunc(func(*http.Request) (authn.Principal, error) { + return authn.Principal{ActorID: actor.ID, Method: "test"}, nil + })) + + recorder := request(t, handler, http.MethodGet, "/workspace/bootstrap", "") + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) + } + var envelope struct { + Data cloudworkspace.WorkspaceBootstrap `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode bootstrap: %v", err) + } + if len(envelope.Data.Organizations) != 1 { + t.Fatalf("organizations = %#v", envelope.Data.Organizations) + } + entry := envelope.Data.Organizations[0] + if entry.Organization.ID != organization.ID || entry.Role != cloudworkspace.RoleOwner { + t.Fatalf("entry = %#v", entry) + } + if len(entry.Projects) != 1 || entry.Projects[0].ID != project.ID { + t.Fatalf("projects = %#v", entry.Projects) + } +} + +func TestWorkspaceBootstrapEncodesEmptyCollectionsAsArrays(t *testing.T) { + service := cloudworkspace.NewService(cloudworkspacememory.New()) + actor := cloudworkspace.Actor{ID: "actor-owner"} + if _, err := service.CreateOrganization(t.Context(), actor, "Northwind"); err != nil { + t.Fatalf("create organization: %v", err) + } + member := cloudworkspace.Actor{ID: "actor-outsider"} + principal := actor + handler := cloudworkspacehttp.Routes(service, authn.ResolverFunc(func(*http.Request) (authn.Principal, error) { + return authn.Principal{ActorID: principal.ID, Method: "test"}, nil + })) + + // A null here would make the client's "no organizations yet" and "no projects + // yet" branches depend on a null check the generated types do not describe. + body := request(t, handler, http.MethodGet, "/workspace/bootstrap", "").Body.String() + if !strings.Contains(body, `"projects":[]`) { + t.Fatalf("expected an empty project array in %s", body) + } + + principal = member + body = request(t, handler, http.MethodGet, "/workspace/bootstrap", "").Body.String() + if !strings.Contains(body, `"organizations":[]`) { + t.Fatalf("expected an empty organization array in %s", body) + } +} diff --git a/apps/api/internal/transport/cloudworkspace/handler_workspace.go b/apps/api/internal/transport/cloudworkspace/handler_workspace.go index 7ac23ea6..06b7eb15 100644 --- a/apps/api/internal/transport/cloudworkspace/handler_workspace.go +++ b/apps/api/internal/transport/cloudworkspace/handler_workspace.go @@ -9,6 +9,14 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" ) +func (h *Handler) getWorkspaceBootstrap(w http.ResponseWriter, r *http.Request) { + result, err := h.service.Bootstrap(r.Context(), actor(r)) + if err != nil { + writeServiceError(w, r, err) + return + } + response.OK(w, r, result) +} func (h *Handler) createOrganization(w http.ResponseWriter, r *http.Request) { request := new(organizationRequest) if !decodeAndValidate(w, r, request) { diff --git a/apps/api/migrations/00051_billing_identity_binding_jobs.sql b/apps/api/migrations/00051_billing_identity_binding_jobs.sql new file mode 100644 index 00000000..d42db98e --- /dev/null +++ b/apps/api/migrations/00051_billing_identity_binding_jobs.sql @@ -0,0 +1,59 @@ +-- Phase 9B: make the identity half of the Phase 9A -> 9B fact seam durable. +-- +-- Validation used to call the identity binder only after the fact transaction +-- committed. A process exit in that interval permanently lost the association +-- work. This queue is written in the fact/attempt transaction and contains only +-- digests and non-secret identifiers, so retry never needs the retained raw +-- provider body and never repeats provider validation. + +-- +goose Up +CREATE TABLE billing_identity_binding_jobs ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + validation_attempt_id text NOT NULL UNIQUE, + raw_input_id text NOT NULL, + provider text NOT NULL CHECK (provider IN ('app_store', 'google_play')), + lineage_key_digest bytea NOT NULL CHECK (octet_length(lineage_key_digest) = 32), + fact_chain_digest bytea NOT NULL CHECK (octet_length(fact_chain_digest) = 32), + reference_digests bytea[] NOT NULL DEFAULT '{}', + correlators jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(correlators) = 'array'), + acquired_at timestamptz NOT NULL, + status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'leased', 'completed', 'failed')), + attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + max_attempts integer NOT NULL DEFAULT 8 CHECK (max_attempts > 0), + available_at timestamptz NOT NULL, + lease_owner text, + lease_expires_at timestamptz, + last_error_code text, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT billing_identity_binding_jobs_environment_fk + FOREIGN KEY (environment_id, project_id) REFERENCES environments(id, project_id), + CONSTRAINT billing_identity_binding_jobs_attempt_fk + FOREIGN KEY (validation_attempt_id, project_id) + REFERENCES billing_validation_attempts(id, project_id) ON DELETE CASCADE, + CONSTRAINT billing_identity_binding_jobs_raw_input_fk + FOREIGN KEY (raw_input_id, project_id) REFERENCES billing_raw_inputs(id, project_id), + CONSTRAINT billing_identity_binding_jobs_lease_shape CHECK ( + (status = 'leased' AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL) + OR (status <> 'leased' AND lease_owner IS NULL AND lease_expires_at IS NULL) + ), + CONSTRAINT billing_identity_binding_jobs_reference_digests_check CHECK ( + array_position(reference_digests, NULL) IS NULL + ) +); + +CREATE INDEX billing_identity_binding_jobs_claim_idx + ON billing_identity_binding_jobs (available_at, created_at, id) + WHERE status IN ('queued', 'leased'); + +-- The partial unique index is the cross-worker serialization boundary. Two +-- evidence-bearing attempts for one lineage may be queued, but only one may be +-- inside the identity decision at a time. +CREATE UNIQUE INDEX billing_identity_binding_jobs_lineage_lease_idx + ON billing_identity_binding_jobs (environment_id, provider, lineage_key_digest) + WHERE status = 'leased'; + +-- +goose Down +DROP TABLE billing_identity_binding_jobs; diff --git a/apps/api/migrations/00052_phase_9c_billing_migration_foundation.sql b/apps/api/migrations/00052_phase_9c_billing_migration_foundation.sql new file mode 100644 index 00000000..169f34bd --- /dev/null +++ b/apps/api/migrations/00052_phase_9c_billing_migration_foundation.sql @@ -0,0 +1,420 @@ +-- Phase 9C Stage 2B: immutable migration evidence and resumable control-plane records. +-- Source records are deliberately isolated from billing_transaction_facts. + +-- +goose Up +CREATE TABLE billing_migration_credentials ( + id text PRIMARY KEY, + project_id text NOT NULL, + provider text NOT NULL CHECK (provider = 'revenuecat'), + external_project_id text NOT NULL CHECK (btrim(external_project_id) <> ''), + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')), + envelope_version integer NOT NULL CHECK (envelope_version = 1), + algorithm text NOT NULL CHECK (algorithm = 'AES-256-GCM'), + key_id text NOT NULL CHECK (btrim(key_id) <> ''), + nonce bytea NOT NULL CHECK (octet_length(nonce) = 12), + ciphertext bytea NOT NULL CHECK (octet_length(ciphertext) >= 16), + fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32), + created_by_actor_id text NOT NULL, + created_at timestamptz NOT NULL, + revoked_at timestamptz, + UNIQUE (id, project_id), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE RESTRICT, + CHECK ((status = 'revoked') = (revoked_at IS NOT NULL)) +); +CREATE INDEX billing_migration_credentials_project_idx + ON billing_migration_credentials(project_id, created_at DESC, id); + +CREATE TABLE billing_migration_programs ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + source_adapter text NOT NULL CHECK (source_adapter = 'revenuecat'), + source_adapter_version text NOT NULL CHECK (btrim(source_adapter_version) <> ''), + credential_id text NOT NULL, + state text NOT NULL DEFAULT 'draft' CHECK (state IN ( + 'draft','assessing','mapping','importing','dry_run','shadowing','ready', + 'cutover_pending','stabilizing','completed','rolled_back','failed','cancelled')), + state_version bigint NOT NULL DEFAULT 1 CHECK (state_version >= 1), + authority_epoch_before bigint NOT NULL DEFAULT 0 CHECK (authority_epoch_before >= 0), + stabilization_days integer NOT NULL DEFAULT 7 CHECK (stabilization_days BETWEEN 1 AND 30), + rollback_window_days integer NOT NULL DEFAULT 7 CHECK (rollback_window_days BETWEEN 1 AND 30), + scope_digest bytea NOT NULL CHECK (octet_length(scope_digest) = 32), + policy_digest bytea NOT NULL CHECK (octet_length(policy_digest) = 32), + idempotency_key text NOT NULL CHECK (btrim(idempotency_key) <> ''), + request_digest bytea NOT NULL CHECK (octet_length(request_digest) = 32), + created_by_actor_id text NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + UNIQUE (id, project_id), + UNIQUE (id, project_id, environment_id), + UNIQUE (project_id, idempotency_key), + FOREIGN KEY (environment_id, project_id) REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (credential_id, project_id) REFERENCES billing_migration_credentials(id, project_id) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_programs_project_idx + ON billing_migration_programs(project_id, created_at DESC, id); + +CREATE TABLE billing_migration_program_scopes ( + program_id text NOT NULL, + project_id text NOT NULL, + environment_id text NOT NULL, + application_id text NOT NULL, + platform text NOT NULL CHECK (platform IN ('ios', 'android')), + created_at timestamptz NOT NULL, + PRIMARY KEY (program_id, application_id, platform), + FOREIGN KEY (program_id, project_id, environment_id) + REFERENCES billing_migration_programs(id, project_id, environment_id) ON DELETE RESTRICT, + FOREIGN KEY (application_id, project_id, platform) REFERENCES applications(id, project_id, platform) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_program_scopes_scope_idx + ON billing_migration_program_scopes(project_id, environment_id, application_id, platform); + +CREATE TABLE billing_migration_capability_assessments ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + state_version bigint NOT NULL CHECK (state_version >= 1), + provider_api_version text NOT NULL CHECK (btrim(provider_api_version) <> ''), + capabilities text[] NOT NULL CHECK ( + cardinality(capabilities) BETWEEN 1 AND 5 AND + capabilities <@ ARRAY['read_customers','read_subscriptions','read_aliases','read_transfers','incremental_delta']::text[]), + assessment_digest bytea NOT NULL CHECK (octet_length(assessment_digest) = 32), + assessed_at timestamptz NOT NULL, + UNIQUE (program_id, assessment_digest), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_capability_assessments_program_idx + ON billing_migration_capability_assessments(program_id, assessed_at DESC, id); + +CREATE TABLE billing_migration_source_manifests ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + state_version bigint NOT NULL CHECK (state_version >= 1), + adapter_version text NOT NULL CHECK (btrim(adapter_version) <> ''), + provider_api_version text NOT NULL CHECK (btrim(provider_api_version) <> ''), + schema_version text NOT NULL CHECK (btrim(schema_version) <> ''), + record_count bigint NOT NULL CHECK (record_count >= 0), + current_access_record_count bigint NOT NULL CHECK (current_access_record_count BETWEEN 0 AND record_count), + object_key text NOT NULL CHECK (btrim(object_key) <> ''), + object_checksum bytea NOT NULL CHECK (octet_length(object_checksum) = 32), + object_size_bytes bigint NOT NULL CHECK (object_size_bytes >= 0), + object_encryption text NOT NULL CHECK (object_encryption = 'AES-256-GCM'), + manifest_digest bytea NOT NULL CHECK (octet_length(manifest_digest) = 32), + source_watermark text NOT NULL DEFAULT '', + captured_at timestamptz NOT NULL, + UNIQUE (id, program_id, project_id), + UNIQUE (program_id, manifest_digest), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_source_manifests_program_idx + ON billing_migration_source_manifests(program_id, captured_at DESC, id); + +CREATE TABLE billing_migration_source_records ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + manifest_id text NOT NULL, + source_kind text NOT NULL CHECK (source_kind IN ('customer','alias','subscription','transaction','transfer')), + source_identifier text NOT NULL CHECK ( + octet_length(source_identifier) BETWEEN 1 AND 512 AND source_identifier !~ '[[:cntrl:]]'), + source_revision text NOT NULL CHECK (octet_length(source_revision) BETWEEN 1 AND 256), + source_cursor text NOT NULL DEFAULT '', + record_digest bytea NOT NULL CHECK (octet_length(record_digest) = 32), + current_access boolean NOT NULL DEFAULT false, + normalization_schema_version text NOT NULL CHECK (btrim(normalization_schema_version) <> ''), + evidence_kind text NOT NULL CHECK (evidence_kind IN ( + 'trusted_source_export','trusted_provider_api','provider_signed','provider_validated','historical_informational')), + observed_at timestamptz NOT NULL, + created_at timestamptz NOT NULL, + UNIQUE (program_id, source_kind, source_identifier, source_revision, record_digest), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (manifest_id, program_id, project_id) + REFERENCES billing_migration_source_manifests(id, program_id, project_id) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_source_records_program_cursor_idx + ON billing_migration_source_records(program_id, source_cursor, id); + +CREATE TABLE billing_migration_mapping_sets ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + version integer NOT NULL CHECK (version >= 1), + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'frozen')), + mapping_digest bytea NOT NULL CHECK (octet_length(mapping_digest) = 32), + expected_program_state_version bigint NOT NULL CHECK (expected_program_state_version >= 1), + created_by_actor_id text NOT NULL, + created_at timestamptz NOT NULL, + frozen_at timestamptz, + UNIQUE (id, program_id, project_id), + UNIQUE (program_id, version), + UNIQUE (program_id, mapping_digest), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + CHECK ((status = 'frozen') = (frozen_at IS NOT NULL)) +); +CREATE INDEX billing_migration_mapping_sets_program_idx + ON billing_migration_mapping_sets(program_id, version DESC); + +CREATE TABLE billing_migration_mapping_entries ( + id text PRIMARY KEY, + mapping_set_id text NOT NULL, + program_id text NOT NULL, + project_id text NOT NULL, + source_kind text NOT NULL CHECK (source_kind IN ('customer_id','original_customer_id','audited_alias','product','entitlement')), + source_identifier text NOT NULL CHECK ( + octet_length(source_identifier) BETWEEN 1 AND 512 AND source_identifier !~ '[[:cntrl:]]'), + target_id text NOT NULL, + match_kind text NOT NULL CHECK (match_kind IN ('exact','audited_alias')), + application_id text, + platform text CHECK (platform IN ('ios','android')), + created_at timestamptz NOT NULL, + UNIQUE (mapping_set_id, source_kind, source_identifier, application_id, platform), + FOREIGN KEY (mapping_set_id, program_id, project_id) + REFERENCES billing_migration_mapping_sets(id, program_id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (program_id, application_id, platform) + REFERENCES billing_migration_program_scopes(program_id, application_id, platform) ON DELETE RESTRICT, + CHECK ((application_id IS NULL) = (platform IS NULL)) +); +CREATE INDEX billing_migration_mapping_entries_set_idx + ON billing_migration_mapping_entries(mapping_set_id, source_kind, id); +CREATE UNIQUE INDEX billing_migration_mapping_entries_global_source_key + ON billing_migration_mapping_entries(mapping_set_id, source_kind, source_identifier) + WHERE application_id IS NULL; + +CREATE TABLE billing_migration_import_batches ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + manifest_id text NOT NULL, + mapping_set_id text NOT NULL, + idempotency_key text NOT NULL CHECK (btrim(idempotency_key) <> ''), + request_digest bytea NOT NULL CHECK (octet_length(request_digest) = 32), + expected_program_state_version bigint NOT NULL CHECK (expected_program_state_version >= 1), + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','completed','failed')), + record_count integer NOT NULL CHECK (record_count BETWEEN 0 AND 1000), + validated_count integer NOT NULL DEFAULT 0 CHECK (validated_count >= 0), + quarantined_count integer NOT NULL DEFAULT 0 CHECK (quarantined_count >= 0), + cursor_before text NOT NULL DEFAULT '', + cursor_after text NOT NULL DEFAULT '', + lease_owner text, + lease_expires_at timestamptz, + attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + lease_generation bigint NOT NULL DEFAULT 0 CHECK (lease_generation >= 0), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + UNIQUE (program_id, idempotency_key), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (manifest_id, program_id, project_id) + REFERENCES billing_migration_source_manifests(id, program_id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (mapping_set_id, program_id, project_id) + REFERENCES billing_migration_mapping_sets(id, program_id, project_id) ON DELETE RESTRICT, + CHECK (validated_count + quarantined_count <= record_count), + CHECK ((status = 'running') = (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)) +); +CREATE INDEX billing_migration_import_batches_claim_idx + ON billing_migration_import_batches(updated_at, id) WHERE status IN ('pending','running'); + +CREATE TABLE billing_migration_run_jobs ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + run_kind text NOT NULL CHECK (run_kind IN ('dry_run','shadow')), + idempotency_key text NOT NULL CHECK (btrim(idempotency_key) <> ''), + request_digest bytea NOT NULL CHECK (octet_length(request_digest) = 32), + expected_program_state_version bigint NOT NULL CHECK (expected_program_state_version >= 1), + manifest_digest bytea NOT NULL CHECK (octet_length(manifest_digest) = 32), + mapping_digest bytea NOT NULL CHECK (octet_length(mapping_digest) = 32), + policy_digest bytea NOT NULL CHECK (octet_length(policy_digest) = 32), + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','completed','failed')), + result_run_id text, + lease_owner text, + lease_expires_at timestamptz, + lease_generation bigint NOT NULL DEFAULT 0 CHECK (lease_generation >= 0), + attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + UNIQUE (program_id, idempotency_key), + UNIQUE (id, program_id, project_id), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + CHECK ((status = 'running') = (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)), + CHECK ((status = 'completed') = (result_run_id IS NOT NULL)) +); +CREATE INDEX billing_migration_run_jobs_claim_idx + ON billing_migration_run_jobs(updated_at, id) WHERE status IN ('pending','running'); + +CREATE TABLE billing_migration_runs ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + run_kind text NOT NULL CHECK (run_kind IN ('dry_run','shadow')), + state_version bigint NOT NULL CHECK (state_version >= 1), + manifest_digest bytea NOT NULL CHECK (octet_length(manifest_digest) = 32), + mapping_digest bytea NOT NULL CHECK (octet_length(mapping_digest) = 32), + policy_digest bytea NOT NULL CHECK (octet_length(policy_digest) = 32), + source_watermark text NOT NULL, + provider_watermark text NOT NULL, + shadow_watermark text NOT NULL, + critical_count bigint NOT NULL DEFAULT 0 CHECK (critical_count >= 0), + blocking_count bigint NOT NULL DEFAULT 0 CHECK (blocking_count >= 0), + warning_count bigint NOT NULL DEFAULT 0 CHECK (warning_count >= 0), + informational_count bigint NOT NULL DEFAULT 0 CHECK (informational_count >= 0), + run_digest bytea NOT NULL CHECK (octet_length(run_digest) = 32), + completed_at timestamptz NOT NULL, + UNIQUE (id, program_id, project_id), + UNIQUE (program_id, run_digest), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_runs_program_idx ON billing_migration_runs(program_id, completed_at DESC, id); + +ALTER TABLE billing_migration_run_jobs ADD CONSTRAINT billing_migration_run_jobs_result_fk + FOREIGN KEY (result_run_id, program_id, project_id) + REFERENCES billing_migration_runs(id, program_id, project_id) ON DELETE RESTRICT; + +CREATE TABLE billing_migration_divergences ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + run_id text NOT NULL, + state_version bigint NOT NULL CHECK (state_version >= 1), + classification text NOT NULL CHECK (classification IN ('critical','blocking','warning','informational')), + reason text NOT NULL CHECK (reason IN ( + 'source_grants_mosaic_denies','identity_conflict','authority_scope_conflict','mapping_missing', + 'provider_validation_missing','watermark_stale','unsupported_application_version', + 'historical_mismatch','provider_timing_lag','normalization_difference')), + evidence_digest bytea NOT NULL CHECK (octet_length(evidence_digest) = 32), + classification_rule_version text NOT NULL CHECK (btrim(classification_rule_version) <> ''), + observed_at timestamptz NOT NULL, + UNIQUE (run_id, evidence_digest), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (run_id, program_id, project_id) + REFERENCES billing_migration_runs(id, program_id, project_id) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_divergences_program_class_idx + ON billing_migration_divergences(program_id, classification, observed_at DESC, id); + +CREATE TABLE billing_migration_readiness_assessments ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + state_version bigint NOT NULL CHECK (state_version >= 1), + ready boolean NOT NULL, + current_access_mapping_percent numeric(5,2) NOT NULL CHECK (current_access_mapping_percent BETWEEN 0 AND 100), + current_access_evidence_percent numeric(5,2) NOT NULL CHECK (current_access_evidence_percent BETWEEN 0 AND 100), + critical_count bigint NOT NULL CHECK (critical_count >= 0), + blocking_count bigint NOT NULL CHECK (blocking_count >= 0), + warning_count bigint NOT NULL CHECK (warning_count >= 0), + informational_count bigint NOT NULL CHECK (informational_count >= 0), + final_delta_completed boolean NOT NULL, + watermarks_fresh boolean NOT NULL, + supported_versions_authority_aware boolean NOT NULL, + readiness_digest bytea NOT NULL CHECK (octet_length(readiness_digest) = 32), + assessed_at timestamptz NOT NULL, + UNIQUE (program_id, readiness_digest), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + CHECK (ready = ( + current_access_mapping_percent = 100 AND current_access_evidence_percent = 100 AND + critical_count = 0 AND blocking_count = 0 AND final_delta_completed AND + watermarks_fresh AND supported_versions_authority_aware)) +); +CREATE INDEX billing_migration_readiness_program_idx + ON billing_migration_readiness_assessments(program_id, assessed_at DESC, id); + +-- +goose StatementBegin +CREATE FUNCTION reject_billing_migration_immutable_change() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION '% is immutable', TG_TABLE_NAME USING ERRCODE = '55000'; +END; +$$; +-- +goose StatementEnd + +CREATE TRIGGER billing_migration_scopes_immutable BEFORE UPDATE OR DELETE ON billing_migration_program_scopes +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_assessments_immutable BEFORE UPDATE OR DELETE ON billing_migration_capability_assessments +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_manifests_immutable BEFORE UPDATE OR DELETE ON billing_migration_source_manifests +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_records_immutable BEFORE UPDATE OR DELETE ON billing_migration_source_records +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_entries_immutable BEFORE UPDATE OR DELETE ON billing_migration_mapping_entries +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_runs_immutable BEFORE UPDATE OR DELETE ON billing_migration_runs +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_divergences_immutable BEFORE UPDATE OR DELETE ON billing_migration_divergences +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_readiness_immutable BEFORE UPDATE OR DELETE ON billing_migration_readiness_assessments +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); + +-- +goose StatementBegin +CREATE FUNCTION protect_frozen_billing_migration_mapping() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.status = 'frozen' THEN + RAISE EXCEPTION 'frozen billing migration mapping set is immutable' USING ERRCODE = '55000'; + END IF; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + IF NEW.id <> OLD.id OR NEW.program_id <> OLD.program_id OR NEW.project_id <> OLD.project_id OR + NEW.version <> OLD.version OR NEW.mapping_digest <> OLD.mapping_digest OR + NEW.expected_program_state_version <> OLD.expected_program_state_version OR + NEW.created_by_actor_id <> OLD.created_by_actor_id OR NEW.created_at <> OLD.created_at OR + NEW.status <> 'frozen' OR NEW.frozen_at IS NULL THEN + RAISE EXCEPTION 'mapping set permits only draft to frozen transition' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER billing_migration_mapping_sets_frozen +BEFORE UPDATE OR DELETE ON billing_migration_mapping_sets +FOR EACH ROW EXECUTE FUNCTION protect_frozen_billing_migration_mapping(); + +-- +goose StatementBegin +CREATE FUNCTION validate_billing_migration_mapping_target() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.source_kind IN ('customer_id','original_customer_id','audited_alias') AND NOT EXISTS ( + SELECT 1 FROM billing_customers WHERE id=NEW.target_id AND project_id=NEW.project_id + ) THEN + RAISE EXCEPTION 'identity mapping target is outside the migration Project' USING ERRCODE = '23503'; + ELSIF NEW.source_kind = 'product' AND NOT EXISTS ( + SELECT 1 FROM products WHERE id=NEW.target_id AND project_id=NEW.project_id + ) THEN + RAISE EXCEPTION 'product mapping target is outside the migration Project' USING ERRCODE = '23503'; + ELSIF NEW.source_kind = 'entitlement' AND NOT EXISTS ( + SELECT 1 FROM entitlements WHERE id=NEW.target_id AND project_id=NEW.project_id + ) THEN + RAISE EXCEPTION 'entitlement mapping target is outside the migration Project' USING ERRCODE = '23503'; + END IF; + RETURN NEW; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER billing_migration_mapping_target_scope +BEFORE INSERT ON billing_migration_mapping_entries +FOR EACH ROW EXECUTE FUNCTION validate_billing_migration_mapping_target(); + +-- +goose Down +DROP TRIGGER billing_migration_mapping_target_scope ON billing_migration_mapping_entries; +DROP FUNCTION validate_billing_migration_mapping_target; +DROP TRIGGER billing_migration_mapping_sets_frozen ON billing_migration_mapping_sets; +DROP FUNCTION protect_frozen_billing_migration_mapping; +DROP TRIGGER billing_migration_readiness_immutable ON billing_migration_readiness_assessments; +DROP TRIGGER billing_migration_divergences_immutable ON billing_migration_divergences; +DROP TRIGGER billing_migration_runs_immutable ON billing_migration_runs; +DROP TRIGGER billing_migration_entries_immutable ON billing_migration_mapping_entries; +DROP TRIGGER billing_migration_records_immutable ON billing_migration_source_records; +DROP TRIGGER billing_migration_manifests_immutable ON billing_migration_source_manifests; +DROP TRIGGER billing_migration_assessments_immutable ON billing_migration_capability_assessments; +DROP TRIGGER billing_migration_scopes_immutable ON billing_migration_program_scopes; +DROP FUNCTION reject_billing_migration_immutable_change; +DROP TABLE billing_migration_readiness_assessments; +DROP TABLE billing_migration_divergences; +DROP TABLE billing_migration_run_jobs; +DROP TABLE billing_migration_runs; +DROP TABLE billing_migration_import_batches; +DROP TABLE billing_migration_mapping_entries; +DROP TABLE billing_migration_mapping_sets; +DROP TABLE billing_migration_source_records; +DROP TABLE billing_migration_source_manifests; +DROP TABLE billing_migration_capability_assessments; +DROP TABLE billing_migration_program_scopes; +DROP TABLE billing_migration_programs; +DROP TABLE billing_migration_credentials; diff --git a/apps/api/migrations/00053_phase_9c_cutover_operations.sql b/apps/api/migrations/00053_phase_9c_cutover_operations.sql new file mode 100644 index 00000000..343dab5c --- /dev/null +++ b/apps/api/migrations/00053_phase_9c_cutover_operations.sql @@ -0,0 +1,472 @@ +-- Phase 9C Stage 2E packages A/B: reserved cutover, authority, repair, and retention persistence. +-- This migration creates control-plane records only. No authority transition is executed here. + +-- +goose Up +-- +goose StatementBegin +CREATE FUNCTION billing_migration_text_array_unique(values_to_check text[]) RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + SELECT cardinality(values_to_check)=count(DISTINCT value) FROM unnest(values_to_check) value +$$; +-- +goose StatementEnd + +ALTER TABLE billing_migration_credentials + ALTER COLUMN nonce DROP NOT NULL, + ALTER COLUMN ciphertext DROP NOT NULL, + ADD COLUMN removed_at timestamptz, + ADD COLUMN removed_by_actor_id text, + ADD COLUMN removal_digest bytea CHECK (removal_digest IS NULL OR octet_length(removal_digest)=32), + ADD CONSTRAINT billing_migration_credentials_secure_removal CHECK ( + (removed_at IS NULL AND removed_by_actor_id IS NULL AND removal_digest IS NULL AND nonce IS NOT NULL AND ciphertext IS NOT NULL) + OR (removed_at IS NOT NULL AND removed_by_actor_id IS NOT NULL AND removal_digest IS NOT NULL AND nonce IS NULL AND ciphertext IS NULL) + ); +ALTER TABLE billing_migration_import_batches + ADD CONSTRAINT billing_migration_import_batches_scope_unique UNIQUE(id,program_id,project_id); +ALTER TABLE billing_migration_source_records + ADD CONSTRAINT billing_migration_source_records_scope_unique UNIQUE(id,program_id,project_id); +ALTER TABLE customer_entitlement_snapshots + ADD CONSTRAINT customer_entitlement_snapshots_scope_unique UNIQUE(id,project_id,environment_id,billing_customer_id); +ALTER TABLE billing_migration_readiness_assessments + DROP CONSTRAINT billing_migration_readiness_assessments_check, + ADD COLUMN authoritative boolean NOT NULL DEFAULT false, + ADD COLUMN source_capabilities_fresh boolean NOT NULL DEFAULT false, + ADD COLUMN warning_threshold bigint NOT NULL DEFAULT 0 CHECK(warning_threshold>=0), + ADD COLUMN application_version_digest bytea CHECK(application_version_digest IS NULL OR octet_length(application_version_digest)=32), + ADD CONSTRAINT billing_migration_readiness_authoritative_check CHECK (ready = ( + current_access_mapping_percent=100 AND current_access_evidence_percent=100 AND + critical_count=0 AND blocking_count=0 AND final_delta_completed AND watermarks_fresh AND + supported_versions_authority_aware AND + (NOT authoritative OR (source_capabilities_fresh AND warning_count<=warning_threshold AND application_version_digest IS NOT NULL)) + )); + +CREATE TABLE billing_migration_validation_attempts ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, import_batch_id text, + attempt_kind text NOT NULL CHECK (attempt_kind IN ('source_validation','provider_validation','final_delta')), + status text NOT NULL CHECK (status IN ('succeeded','failed','quarantined')), + record_count integer NOT NULL CHECK (record_count >= 0), result_digest bytea NOT NULL CHECK (octet_length(result_digest)=32), + attempted_at timestamptz NOT NULL, + UNIQUE(id,program_id,project_id), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(import_batch_id,program_id,project_id) REFERENCES billing_migration_import_batches(id,program_id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_import_attempts ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, import_batch_id text NOT NULL, + lease_generation bigint NOT NULL CHECK(lease_generation>=0), status text NOT NULL CHECK(status IN ('started','completed','failed')), + cursor_before text NOT NULL DEFAULT '', cursor_after text NOT NULL DEFAULT '', attempt_digest bytea NOT NULL CHECK(octet_length(attempt_digest)=32), + attempted_at timestamptz NOT NULL, completed_at timestamptz, + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(import_batch_id,program_id,project_id) REFERENCES billing_migration_import_batches(id,program_id,project_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_shadow_snapshots ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, environment_id text NOT NULL, + application_id text NOT NULL, platform text NOT NULL CHECK(platform IN ('ios','android')), + billing_customer_id text NOT NULL, source_snapshot_id text, mosaic_snapshot_id text NOT NULL, + shadow_digest bytea NOT NULL CHECK(octet_length(shadow_digest)=32), created_at timestamptz NOT NULL, + UNIQUE(program_id,application_id,platform,billing_customer_id,shadow_digest), + FOREIGN KEY(program_id,project_id,environment_id) REFERENCES billing_migration_programs(id,project_id,environment_id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,application_id,platform) REFERENCES billing_migration_program_scopes(program_id,application_id,platform) ON DELETE RESTRICT, + FOREIGN KEY(billing_customer_id,project_id) REFERENCES billing_customers(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(mosaic_snapshot_id,project_id,environment_id,billing_customer_id) + REFERENCES customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_scope_current_pointers ( + project_id text NOT NULL, environment_id text NOT NULL, application_id text NOT NULL, + platform text NOT NULL CHECK(platform IN ('ios','android')), billing_customer_id text NOT NULL, + current_snapshot_id text NOT NULL, authority_epoch bigint NOT NULL CHECK(authority_epoch>=0), updated_at timestamptz NOT NULL, + PRIMARY KEY(project_id,environment_id,application_id,platform,billing_customer_id), + FOREIGN KEY(environment_id,project_id) REFERENCES environments(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(application_id,project_id,platform) REFERENCES applications(id,project_id,platform) ON DELETE RESTRICT, + FOREIGN KEY(billing_customer_id,project_id) REFERENCES billing_customers(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(current_snapshot_id,project_id,environment_id,billing_customer_id) + REFERENCES customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_scope_prepared_pointers ( + program_id text NOT NULL, project_id text NOT NULL, environment_id text NOT NULL, application_id text NOT NULL, + platform text NOT NULL CHECK(platform IN ('ios','android')), billing_customer_id text NOT NULL, + prepared_snapshot_id text NOT NULL, prepared_digest bytea NOT NULL CHECK(octet_length(prepared_digest)=32), prepared_at timestamptz NOT NULL, + PRIMARY KEY(program_id,application_id,platform,billing_customer_id), + FOREIGN KEY(program_id,project_id,environment_id) REFERENCES billing_migration_programs(id,project_id,environment_id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,application_id,platform) REFERENCES billing_migration_program_scopes(program_id,application_id,platform) ON DELETE RESTRICT, + FOREIGN KEY(billing_customer_id,project_id) REFERENCES billing_customers(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(prepared_snapshot_id,project_id,environment_id,billing_customer_id) + REFERENCES customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_authority_scopes ( + id text PRIMARY KEY, project_id text NOT NULL, environment_id text NOT NULL, application_id text NOT NULL, + platform text NOT NULL CHECK(platform IN ('ios','android')), current_authority text NOT NULL CHECK(current_authority IN ('source','mosaic','source_rollback')), + current_epoch bigint NOT NULL CHECK(current_epoch>=0), active_program_id text, + authority_digest bytea NOT NULL CHECK(octet_length(authority_digest)=32), updated_at timestamptz NOT NULL, + UNIQUE(project_id,environment_id,application_id,platform), UNIQUE(id,project_id), + FOREIGN KEY(environment_id,project_id) REFERENCES environments(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(application_id,project_id,platform) REFERENCES applications(id,project_id,platform) ON DELETE RESTRICT, + FOREIGN KEY(active_program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); +INSERT INTO billing_migration_authority_scopes(id,project_id,environment_id,application_id,platform,current_authority,current_epoch,active_program_id,authority_digest,updated_at) +SELECT 'mas_'||substr(md5(s.project_id||':'||s.environment_id||':'||s.application_id||':'||s.platform),1,16), + s.project_id,s.environment_id,s.application_id,s.platform,'source',s.authority_epoch_before,s.program_id, + decode(md5(s.project_id||':'||s.environment_id||':'||s.application_id||':'||s.platform||':source:'||s.authority_epoch_before::text)||md5(s.program_id),'hex'),s.created_at +FROM ( + SELECT DISTINCT ON (p.project_id,p.environment_id,ps.application_id,ps.platform) + p.id AS program_id,p.project_id,p.environment_id,p.authority_epoch_before,p.created_at,ps.application_id,ps.platform + FROM billing_migration_programs p JOIN billing_migration_program_scopes ps ON ps.program_id=p.id + ORDER BY p.project_id,p.environment_id,ps.application_id,ps.platform,p.created_at,p.id +) s; +CREATE TABLE billing_migration_authority_transitions ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, authority_scope_id text NOT NULL, + from_authority text NOT NULL CHECK(from_authority IN ('source','mosaic','source_rollback')), to_authority text NOT NULL CHECK(to_authority IN ('source','mosaic','source_rollback')), + from_epoch bigint NOT NULL CHECK(from_epoch>=0), to_epoch bigint NOT NULL CHECK(to_epoch=from_epoch+1), + transition_kind text NOT NULL CHECK(transition_kind IN ('cutover','rollback')), + transition_digest bytea NOT NULL CHECK(octet_length(transition_digest)=32), transitioned_at timestamptz NOT NULL, + UNIQUE(authority_scope_id,to_epoch), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(authority_scope_id,project_id) REFERENCES billing_migration_authority_scopes(id,project_id) ON DELETE RESTRICT, + CHECK ((transition_kind='cutover' AND from_authority='source' AND to_authority='mosaic') OR + (transition_kind='rollback' AND from_authority='mosaic' AND to_authority='source_rollback')) +); + +CREATE TABLE billing_migration_readiness_policies ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, state_version bigint NOT NULL CHECK(state_version>=1), + warning_threshold bigint NOT NULL DEFAULT 0 CHECK(warning_threshold>=0), watermark_max_age_seconds integer NOT NULL CHECK(watermark_max_age_seconds BETWEEN 1 AND 86400), + supported_version_window_start timestamptz NOT NULL, application_version_digest bytea NOT NULL CHECK(octet_length(application_version_digest)=32), + policy_digest bytea NOT NULL CHECK(octet_length(policy_digest)=32), + frozen_at timestamptz NOT NULL, UNIQUE(program_id,policy_digest), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_readiness_policy_scopes ( + id text PRIMARY KEY, policy_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, + application_id text NOT NULL, platform text NOT NULL CHECK(platform IN ('ios','android')), + minimum_app_version text NOT NULL CHECK(btrim(minimum_app_version)<>'' AND length(minimum_app_version)<=64), + maximum_app_version text NOT NULL CHECK(btrim(maximum_app_version)<>'' AND length(maximum_app_version)<=64), + traffic_window_started_at timestamptz NOT NULL, traffic_window_ended_at timestamptz NOT NULL, + outside_window_accepted boolean NOT NULL DEFAULT false, outside_window_reason text, + UNIQUE(policy_id,application_id,platform), + FOREIGN KEY(policy_id) REFERENCES billing_migration_readiness_policies(id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,application_id,platform) REFERENCES billing_migration_program_scopes(program_id,application_id,platform) ON DELETE RESTRICT, + CHECK(traffic_window_ended_at>traffic_window_started_at), + CHECK(outside_window_accepted=(outside_window_reason IS NOT NULL)) +); +CREATE TABLE billing_migration_supported_app_versions ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, application_id text NOT NULL, + platform text NOT NULL CHECK(platform IN ('ios','android')), application_version text NOT NULL CHECK(btrim(application_version)<>''), + supported boolean NOT NULL, authority_aware boolean NOT NULL, observation_digest bytea NOT NULL CHECK(octet_length(observation_digest)=32), + observed_at timestamptz NOT NULL, UNIQUE(program_id,application_id,platform,application_version), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,application_id,platform) REFERENCES billing_migration_program_scopes(program_id,application_id,platform) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_v2_sync_observations ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, application_id text NOT NULL, + platform text NOT NULL CHECK(platform IN ('ios','android')), + app_version text NOT NULL CHECK(btrim(app_version)<>'' AND length(app_version)<=64 AND app_version !~ '[[:cntrl:]]'), + sdk_version text NOT NULL CHECK(btrim(sdk_version)<>'' AND length(sdk_version)<=64 AND sdk_version !~ '[[:cntrl:]]'), + supported_contract_versions text[] NOT NULL CHECK(cardinality(supported_contract_versions) BETWEEN 1 AND 8 AND '2'=ANY(supported_contract_versions)), + authority_capabilities text[] NOT NULL CHECK(cardinality(authority_capabilities) BETWEEN 1 AND 4 AND authority_capabilities <@ ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting']::text[]), + CHECK(billing_migration_text_array_unique(supported_contract_versions)), + CHECK(billing_migration_text_array_unique(authority_capabilities)), + traffic_count bigint NOT NULL CHECK(traffic_count>=1), authority_epoch bigint NOT NULL CHECK(authority_epoch>=0), + sync_result text NOT NULL CHECK(sync_result IN ('accepted','rejected','unknown_authority')), + observation_digest bytea NOT NULL CHECK(octet_length(observation_digest)=32), observed_at timestamptz NOT NULL, + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,application_id,platform) REFERENCES billing_migration_program_scopes(program_id,application_id,platform) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_v2_sync_scope_version_idx ON billing_migration_v2_sync_observations(program_id,application_id,platform,app_version,observed_at DESC); +CREATE TABLE billing_migration_final_deltas ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, state_version bigint NOT NULL CHECK(state_version>=1), + manifest_digest bytea NOT NULL CHECK(octet_length(manifest_digest)=32), mapping_digest bytea NOT NULL CHECK(octet_length(mapping_digest)=32), + evidence_digest bytea NOT NULL CHECK(octet_length(evidence_digest)=32), final_watermark_digest bytea NOT NULL CHECK(octet_length(final_watermark_digest)=32), + source_watermark timestamptz NOT NULL, provider_watermark timestamptz NOT NULL, shadow_watermark timestamptz NOT NULL, + delta_digest bytea NOT NULL CHECK(octet_length(delta_digest)=32), completed_at timestamptz NOT NULL, + UNIQUE(program_id,delta_digest), FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_command_idempotency ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, command_kind text NOT NULL, + idempotency_key text NOT NULL, request_digest bytea NOT NULL CHECK(octet_length(request_digest)=32), + resource_id text NOT NULL, created_at timestamptz NOT NULL, UNIQUE(program_id,command_kind,idempotency_key), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_cutover_proposals ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, state_version bigint NOT NULL CHECK(state_version>=1), + command text NOT NULL CHECK(command IN ('cutover','rollback')), proposer_actor_id text NOT NULL, + reason text NOT NULL CHECK(btrim(reason)<>'' AND length(reason)<=500 AND reason !~ '[[:cntrl:]]'), + scope_digest bytea NOT NULL CHECK(octet_length(scope_digest)=32), manifest_digest bytea NOT NULL CHECK(octet_length(manifest_digest)=32), + mapping_digest bytea NOT NULL CHECK(octet_length(mapping_digest)=32), policy_digest bytea NOT NULL CHECK(octet_length(policy_digest)=32), + evidence_digest bytea NOT NULL CHECK(octet_length(evidence_digest)=32), readiness_digest bytea NOT NULL CHECK(octet_length(readiness_digest)=32), + final_watermark_digest bytea NOT NULL CHECK(octet_length(final_watermark_digest)=32), application_version_digest bytea NOT NULL CHECK(octet_length(application_version_digest)=32), + proposal_digest bytea NOT NULL CHECK(octet_length(proposal_digest)=32), status text NOT NULL CHECK(status IN ('pending','approved','expired','invalidated')), + proposed_at timestamptz NOT NULL, expires_at timestamptz NOT NULL, invalidated_at timestamptz, + UNIQUE(program_id,proposal_digest), UNIQUE(id,program_id,project_id), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + CHECK(expires_at>proposed_at), CHECK((status='invalidated')=(invalidated_at IS NOT NULL)) +); +CREATE TABLE billing_migration_approvals ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, proposal_id text NOT NULL, + state_version bigint NOT NULL CHECK(state_version>=1), command text NOT NULL CHECK(command IN ('cutover','rollback')), + proposer_actor_id text NOT NULL, approver_actor_id text NOT NULL, + approval_digest bytea NOT NULL CHECK(octet_length(approval_digest)=32), approved_at timestamptz NOT NULL, expires_at timestamptz NOT NULL, + UNIQUE(program_id,approval_digest), UNIQUE(proposal_id), UNIQUE(id,program_id,project_id), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(proposal_id,program_id,project_id) REFERENCES billing_migration_cutover_proposals(id,program_id,project_id) ON DELETE RESTRICT +); + +-- +goose StatementBegin +CREATE FUNCTION enforce_billing_migration_two_person() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE environment_mode text; +BEGIN + SELECT e.mode INTO environment_mode + FROM billing_migration_programs p JOIN environments e ON e.id=p.environment_id AND e.project_id=p.project_id + WHERE p.id=NEW.program_id AND p.project_id=NEW.project_id; + IF (TG_TABLE_NAME='billing_migration_source_access_exceptions' OR environment_mode='production') AND NEW.proposer_actor_id=NEW.approver_actor_id THEN + RAISE EXCEPTION 'production billing migration approval requires distinct actors' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER billing_migration_approvals_two_person BEFORE INSERT ON billing_migration_approvals FOR EACH ROW EXECUTE FUNCTION enforce_billing_migration_two_person(); + +CREATE TABLE billing_migration_checkpoints ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, state_version bigint NOT NULL CHECK(state_version>=1), + authority_epoch bigint NOT NULL CHECK(authority_epoch>=0), source_watermark timestamptz NOT NULL, provider_watermark timestamptz NOT NULL, shadow_watermark timestamptz NOT NULL, + scope_digest bytea NOT NULL CHECK(octet_length(scope_digest)=32), manifest_digest bytea NOT NULL CHECK(octet_length(manifest_digest)=32), + mapping_digest bytea NOT NULL CHECK(octet_length(mapping_digest)=32), policy_digest bytea NOT NULL CHECK(octet_length(policy_digest)=32), + evidence_digest bytea NOT NULL CHECK(octet_length(evidence_digest)=32), readiness_digest bytea NOT NULL CHECK(octet_length(readiness_digest)=32), + final_watermark_digest bytea NOT NULL CHECK(octet_length(final_watermark_digest)=32), application_version_digest bytea NOT NULL CHECK(octet_length(application_version_digest)=32), + approval_digest bytea NOT NULL CHECK(octet_length(approval_digest)=32), checkpoint_digest bytea NOT NULL CHECK(octet_length(checkpoint_digest)=32), created_at timestamptz NOT NULL, + UNIQUE(program_id,checkpoint_digest), UNIQUE(id,program_id,project_id), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_checkpoint_pointer_maps ( + id text PRIMARY KEY, checkpoint_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, + environment_id text NOT NULL, application_id text NOT NULL, platform text NOT NULL CHECK(platform IN ('ios','android')), + billing_customer_id text NOT NULL, pointer_role text NOT NULL CHECK(pointer_role IN ('rollback_baseline','prepared_activation')), + snapshot_id text, absent_current boolean NOT NULL DEFAULT false, pointer_digest bytea NOT NULL CHECK(octet_length(pointer_digest)=32), + UNIQUE(checkpoint_id,application_id,platform,billing_customer_id,pointer_role), + FOREIGN KEY(checkpoint_id,program_id,project_id) REFERENCES billing_migration_checkpoints(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,application_id,platform) REFERENCES billing_migration_program_scopes(program_id,application_id,platform) ON DELETE RESTRICT, + FOREIGN KEY(billing_customer_id,project_id) REFERENCES billing_customers(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(snapshot_id,project_id,environment_id,billing_customer_id) + REFERENCES customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id) ON DELETE RESTRICT, + CHECK((pointer_role='prepared_activation' AND snapshot_id IS NOT NULL AND NOT absent_current) OR + (pointer_role='rollback_baseline' AND absent_current=(snapshot_id IS NULL))) +); + +CREATE TABLE billing_migration_divergence_resolutions ( + id text PRIMARY KEY, divergence_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, + resolution text NOT NULL CHECK(resolution IN ('revalidated','mapped','accepted_exception','superseded')), + actor_id text NOT NULL, reason text NOT NULL CHECK(btrim(reason)<>''), resolution_digest bytea NOT NULL CHECK(octet_length(resolution_digest)=32), resolved_at timestamptz NOT NULL, + FOREIGN KEY(divergence_id) REFERENCES billing_migration_divergences(id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_cases ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, state_version bigint NOT NULL CHECK(state_version>=1), + classification text NOT NULL CHECK(classification IN ('critical','blocking','warning','informational')), + status text NOT NULL CHECK(status IN ('open','in_progress','resolved','dismissed')), + reason text NOT NULL CHECK(btrim(reason)<>'' AND length(reason)<=500 AND reason !~ '[[:cntrl:]]'), + case_digest bytea NOT NULL CHECK(octet_length(case_digest)=32), opened_at timestamptz NOT NULL, resolved_at timestamptz, + UNIQUE(id,program_id,project_id), FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_source_access_exceptions ( + id text PRIMARY KEY, case_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, + application_id text NOT NULL, platform text NOT NULL CHECK(platform IN ('ios','android')), + reason text NOT NULL CHECK(btrim(reason)<>'' AND length(reason)<=500 AND reason !~ '[[:cntrl:]]'), + affected_customer_count integer NOT NULL CHECK(affected_customer_count BETWEEN 1 AND 1000000), + rollback_treatment text NOT NULL CHECK(btrim(rollback_treatment)<>'' AND length(rollback_treatment)<=500 AND rollback_treatment !~ '[[:cntrl:]]'), + identity_ambiguity_count integer NOT NULL DEFAULT 0 CHECK(identity_ambiguity_count=0), + proposer_actor_id text NOT NULL, approver_actor_id text NOT NULL, approved_at timestamptz NOT NULL, expires_at timestamptz NOT NULL, + exception_digest bytea NOT NULL CHECK(octet_length(exception_digest)=32), UNIQUE(case_id), UNIQUE(id,program_id,project_id), + FOREIGN KEY(case_id,program_id,project_id) REFERENCES billing_migration_cases(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,application_id,platform) REFERENCES billing_migration_program_scopes(program_id,application_id,platform) ON DELETE RESTRICT, + CHECK(expires_at>approved_at) +); +CREATE TABLE billing_migration_source_access_exception_subjects ( + id text PRIMARY KEY, exception_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, + source_record_id text NOT NULL, billing_customer_id text NOT NULL, + subject_digest bytea NOT NULL CHECK(octet_length(subject_digest)=32), created_at timestamptz NOT NULL, + UNIQUE(exception_id,source_record_id), + FOREIGN KEY(exception_id,program_id,project_id) REFERENCES billing_migration_source_access_exceptions(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(source_record_id,program_id,project_id) REFERENCES billing_migration_source_records(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(billing_customer_id,project_id) REFERENCES billing_customers(id,project_id) ON DELETE RESTRICT +); +CREATE TRIGGER billing_migration_source_exceptions_two_person BEFORE INSERT ON billing_migration_source_access_exceptions FOR EACH ROW EXECUTE FUNCTION enforce_billing_migration_two_person(); +CREATE TABLE billing_migration_case_comments ( + id text PRIMARY KEY, case_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, actor_id text NOT NULL, + body text NOT NULL CHECK(btrim(body)<>'' AND length(body)<=2000), comment_digest bytea NOT NULL CHECK(octet_length(comment_digest)=32), created_at timestamptz NOT NULL, + FOREIGN KEY(case_id,program_id,project_id) REFERENCES billing_migration_cases(id,program_id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_case_actions ( + id text PRIMARY KEY, case_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, actor_id text NOT NULL, + action text NOT NULL, before_digest bytea NOT NULL CHECK(octet_length(before_digest)=32), after_digest bytea NOT NULL CHECK(octet_length(after_digest)=32), created_at timestamptz NOT NULL, + FOREIGN KEY(case_id,program_id,project_id) REFERENCES billing_migration_cases(id,program_id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_repair_previews ( + id text PRIMARY KEY, case_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, + repair_kind text NOT NULL CHECK(repair_kind IN ('provider_revalidate','projection_replay','attach_proven_alias','replace_mapping_set','retry_quarantined_record')), + scope_kind text NOT NULL CHECK(scope_kind IN ('provider_reference','fact_range','audited_alias','mapping_set','source_record')), + scope_references text[] NOT NULL CHECK(cardinality(scope_references) BETWEEN 1 AND 100), + affected_count integer NOT NULL CHECK(affected_count BETWEEN 0 AND 1000), before_digest bytea NOT NULL CHECK(octet_length(before_digest)=32), + after_digest bytea NOT NULL CHECK(octet_length(after_digest)=32), preview_digest bytea NOT NULL CHECK(octet_length(preview_digest)=32), created_by_actor_id text NOT NULL, created_at timestamptz NOT NULL, + UNIQUE(id,program_id,project_id), FOREIGN KEY(case_id,program_id,project_id) REFERENCES billing_migration_cases(id,program_id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_repair_executions ( + id text PRIMARY KEY, preview_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, idempotency_key text NOT NULL, + result text NOT NULL CHECK(result IN ('succeeded','failed','no_change')), result_digest bytea NOT NULL CHECK(octet_length(result_digest)=32), executed_by_actor_id text NOT NULL, executed_at timestamptz NOT NULL, + UNIQUE(program_id,idempotency_key), FOREIGN KEY(preview_id,program_id,project_id) REFERENCES billing_migration_repair_previews(id,program_id,project_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_completion_reports ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, state_version bigint NOT NULL CHECK(state_version>=1), + completed_at timestamptz NOT NULL, stabilization_ended_at timestamptz NOT NULL, rollback_window_ended_at timestamptz NOT NULL, + credential_removed boolean NOT NULL CHECK(credential_removed), credential_removed_at timestamptz NOT NULL, + legal_hold boolean NOT NULL, source_objects_delete_at timestamptz, + completion_digest bytea NOT NULL CHECK(octet_length(completion_digest)=32), + UNIQUE(program_id), FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT + ,CHECK(completed_at>=stabilization_ended_at AND completed_at>=rollback_window_ended_at) + ,CHECK(credential_removed_at>=rollback_window_ended_at) + ,CHECK((legal_hold AND source_objects_delete_at IS NULL) OR (NOT legal_hold AND source_objects_delete_at=completed_at+interval '30 days')) +); +CREATE TABLE billing_migration_retention_jobs ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, status text NOT NULL CHECK(status IN ('pending','running','completed','failed')), + legal_hold boolean NOT NULL DEFAULT false, due_at timestamptz NOT NULL, lease_owner text, lease_expires_at timestamptz, lease_generation bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + CHECK((status='running')=(lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)) +); +CREATE TABLE billing_migration_object_deletions ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, manifest_id text, + object_key_digest bytea NOT NULL CHECK(octet_length(object_key_digest)=32), deletion_result text NOT NULL CHECK(deletion_result IN ('deleted','not_found','failed','legal_hold')), + deletion_digest bytea NOT NULL CHECK(octet_length(deletion_digest)=32), deleted_at timestamptz NOT NULL, + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(manifest_id,program_id,project_id) REFERENCES billing_migration_source_manifests(id,program_id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_transition_outbox ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, authority_scope_id text NOT NULL, + transition_id text NOT NULL, event_kind text NOT NULL CHECK(event_kind IN ('authority_changed','rollback_changed')), + authority_epoch bigint NOT NULL CHECK(authority_epoch>=1), status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','completed','failed')), + attempt_count integer NOT NULL DEFAULT 0 CHECK(attempt_count>=0), lease_owner text, lease_expires_at timestamptz, + created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, + UNIQUE(transition_id,authority_scope_id), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(authority_scope_id,project_id) REFERENCES billing_migration_authority_scopes(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(transition_id) REFERENCES billing_migration_authority_transitions(id) ON DELETE RESTRICT, + CHECK((status='running')=(lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)) +); +CREATE INDEX billing_migration_transition_outbox_claim_idx ON billing_migration_transition_outbox(updated_at,id) WHERE status IN ('pending','running'); + +-- Immutable evidence/control records. Mutable workflow rows are deliberately excluded. +CREATE TRIGGER billing_migration_validation_attempts_immutable BEFORE UPDATE OR DELETE ON billing_migration_validation_attempts FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_import_attempts_immutable BEFORE UPDATE OR DELETE ON billing_migration_import_attempts FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_shadow_snapshots_immutable BEFORE UPDATE OR DELETE ON billing_migration_shadow_snapshots FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_authority_transitions_immutable BEFORE UPDATE OR DELETE ON billing_migration_authority_transitions FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_readiness_policies_immutable BEFORE UPDATE OR DELETE ON billing_migration_readiness_policies FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_readiness_policy_scopes_immutable BEFORE UPDATE OR DELETE ON billing_migration_readiness_policy_scopes FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_supported_versions_immutable BEFORE UPDATE OR DELETE ON billing_migration_supported_app_versions FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_sync_observations_immutable BEFORE UPDATE OR DELETE ON billing_migration_v2_sync_observations FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_final_deltas_immutable BEFORE UPDATE OR DELETE ON billing_migration_final_deltas FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_approvals_immutable BEFORE UPDATE OR DELETE ON billing_migration_approvals FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_checkpoints_immutable BEFORE UPDATE OR DELETE ON billing_migration_checkpoints FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_checkpoint_maps_immutable BEFORE UPDATE OR DELETE ON billing_migration_checkpoint_pointer_maps FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_divergence_resolutions_immutable BEFORE UPDATE OR DELETE ON billing_migration_divergence_resolutions FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_case_comments_immutable BEFORE UPDATE OR DELETE ON billing_migration_case_comments FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_case_actions_immutable BEFORE UPDATE OR DELETE ON billing_migration_case_actions FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_source_exceptions_immutable BEFORE UPDATE OR DELETE ON billing_migration_source_access_exceptions FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_source_exception_subjects_immutable BEFORE UPDATE OR DELETE ON billing_migration_source_access_exception_subjects FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_repair_previews_immutable BEFORE UPDATE OR DELETE ON billing_migration_repair_previews FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_repair_executions_immutable BEFORE UPDATE OR DELETE ON billing_migration_repair_executions FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_completion_reports_immutable BEFORE UPDATE OR DELETE ON billing_migration_completion_reports FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_object_deletions_immutable BEFORE UPDATE OR DELETE ON billing_migration_object_deletions FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); + +-- +goose Down +-- Cryptographic removal is intentionally irreversible. Restoring the pre-00053 +-- NOT NULL envelope shape would require fabricating ciphertext, so rollback is +-- refused while any retained credential metadata represents a removed secret. +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM billing_migration_credentials + WHERE removed_at IS NOT NULL OR nonce IS NULL OR ciphertext IS NULL + ) THEN + RAISE EXCEPTION 'cannot rollback migration 00053: cryptographically removed billing migration credentials cannot restore ciphertext' + USING ERRCODE='55000'; + END IF; +END; +$$; +-- +goose StatementEnd +DROP TRIGGER billing_migration_source_exceptions_two_person ON billing_migration_source_access_exceptions; +DROP TRIGGER billing_migration_approvals_two_person ON billing_migration_approvals; +DROP TRIGGER billing_migration_object_deletions_immutable ON billing_migration_object_deletions; +DROP TRIGGER billing_migration_completion_reports_immutable ON billing_migration_completion_reports; +DROP TRIGGER billing_migration_repair_executions_immutable ON billing_migration_repair_executions; +DROP TRIGGER billing_migration_repair_previews_immutable ON billing_migration_repair_previews; +DROP TRIGGER billing_migration_case_actions_immutable ON billing_migration_case_actions; +DROP TRIGGER billing_migration_case_comments_immutable ON billing_migration_case_comments; +DROP TRIGGER billing_migration_source_exceptions_immutable ON billing_migration_source_access_exceptions; +DROP TRIGGER billing_migration_source_exception_subjects_immutable ON billing_migration_source_access_exception_subjects; +DROP TRIGGER billing_migration_divergence_resolutions_immutable ON billing_migration_divergence_resolutions; +DROP TRIGGER billing_migration_checkpoint_maps_immutable ON billing_migration_checkpoint_pointer_maps; +DROP TRIGGER billing_migration_checkpoints_immutable ON billing_migration_checkpoints; +DROP TRIGGER billing_migration_approvals_immutable ON billing_migration_approvals; +DROP TRIGGER billing_migration_final_deltas_immutable ON billing_migration_final_deltas; +DROP TRIGGER billing_migration_sync_observations_immutable ON billing_migration_v2_sync_observations; +DROP TRIGGER billing_migration_supported_versions_immutable ON billing_migration_supported_app_versions; +DROP TRIGGER billing_migration_readiness_policies_immutable ON billing_migration_readiness_policies; +DROP TRIGGER billing_migration_readiness_policy_scopes_immutable ON billing_migration_readiness_policy_scopes; +DROP TRIGGER billing_migration_authority_transitions_immutable ON billing_migration_authority_transitions; +DROP TRIGGER billing_migration_shadow_snapshots_immutable ON billing_migration_shadow_snapshots; +DROP TRIGGER billing_migration_import_attempts_immutable ON billing_migration_import_attempts; +DROP TRIGGER billing_migration_validation_attempts_immutable ON billing_migration_validation_attempts; +DROP TABLE billing_migration_transition_outbox; +DROP TABLE billing_migration_object_deletions; +DROP TABLE billing_migration_retention_jobs; +DROP TABLE billing_migration_completion_reports; +DROP TABLE billing_migration_repair_executions; +DROP TABLE billing_migration_repair_previews; +DROP TABLE billing_migration_case_actions; +DROP TABLE billing_migration_case_comments; +DROP TABLE billing_migration_source_access_exception_subjects; +DROP TABLE billing_migration_source_access_exceptions; +DROP TABLE billing_migration_cases; +DROP TABLE billing_migration_divergence_resolutions; +DROP TABLE billing_migration_checkpoint_pointer_maps; +DROP TABLE billing_migration_checkpoints; +DROP TABLE billing_migration_approvals; +DROP TABLE billing_migration_cutover_proposals; +DROP TABLE billing_migration_command_idempotency; +DROP TABLE billing_migration_final_deltas; +DROP TABLE billing_migration_v2_sync_observations; +DROP TABLE billing_migration_supported_app_versions; +DROP TABLE billing_migration_readiness_policy_scopes; +DROP TABLE billing_migration_readiness_policies; +DROP TABLE billing_migration_authority_transitions; +DROP TABLE billing_migration_authority_scopes; +DROP TABLE billing_migration_scope_prepared_pointers; +DROP TABLE billing_migration_scope_current_pointers; +DROP TABLE billing_migration_shadow_snapshots; +DROP TABLE billing_migration_import_attempts; +DROP TABLE billing_migration_validation_attempts; +ALTER TABLE billing_migration_readiness_assessments + DROP CONSTRAINT billing_migration_readiness_authoritative_check, + DROP COLUMN application_version_digest, + DROP COLUMN warning_threshold, + DROP COLUMN source_capabilities_fresh, + DROP COLUMN authoritative, + ADD CONSTRAINT billing_migration_readiness_assessments_check CHECK (ready = ( + current_access_mapping_percent=100 AND current_access_evidence_percent=100 AND + critical_count=0 AND blocking_count=0 AND final_delta_completed AND + watermarks_fresh AND supported_versions_authority_aware + )); +ALTER TABLE customer_entitlement_snapshots DROP CONSTRAINT customer_entitlement_snapshots_scope_unique; +ALTER TABLE billing_migration_import_batches DROP CONSTRAINT billing_migration_import_batches_scope_unique; +ALTER TABLE billing_migration_source_records DROP CONSTRAINT billing_migration_source_records_scope_unique; +ALTER TABLE billing_migration_credentials + DROP CONSTRAINT billing_migration_credentials_secure_removal, + DROP COLUMN removal_digest, + DROP COLUMN removed_by_actor_id, + DROP COLUMN removed_at, + ALTER COLUMN ciphertext SET NOT NULL, + ALTER COLUMN nonce SET NOT NULL; +DROP FUNCTION enforce_billing_migration_two_person(); +DROP FUNCTION billing_migration_text_array_unique(text[]); diff --git a/apps/api/migrations/00054_phase_9c_execution_prerequisites.sql b/apps/api/migrations/00054_phase_9c_execution_prerequisites.sql new file mode 100644 index 00000000..7aaebee2 --- /dev/null +++ b/apps/api/migrations/00054_phase_9c_execution_prerequisites.sql @@ -0,0 +1,99 @@ +-- Phase 9C Stage 2E execution prerequisites only. No authority transition is executed here. + +-- +goose Up +ALTER TABLE billing_migration_readiness_policy_scopes + ADD COLUMN minimum_sdk_version text NOT NULL DEFAULT '0.0.0' CHECK(btrim(minimum_sdk_version)<>'' AND length(minimum_sdk_version)<=64), + ADD COLUMN required_capabilities text[] NOT NULL DEFAULT ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting']::text[] + CHECK(cardinality(required_capabilities) BETWEEN 1 AND 4 AND billing_migration_text_array_unique(required_capabilities) + AND required_capabilities <@ ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting']::text[]), + ADD COLUMN serving_requirements_digest bytea NOT NULL DEFAULT decode(repeat('00',32),'hex') CHECK(octet_length(serving_requirements_digest)=32); +ALTER TABLE billing_migration_readiness_policy_scopes DISABLE TRIGGER billing_migration_readiness_policy_scopes_immutable; +UPDATE billing_migration_readiness_policy_scopes +SET serving_requirements_digest=sha256(convert_to(concat_ws(chr(31),program_id,application_id,platform,minimum_sdk_version, + (SELECT string_agg(capability,chr(30) ORDER BY capability) FROM unnest(required_capabilities) capability)),'UTF8')); +ALTER TABLE billing_migration_readiness_policy_scopes ENABLE TRIGGER billing_migration_readiness_policy_scopes_immutable; +ALTER TABLE billing_migration_readiness_policy_scopes + ALTER COLUMN minimum_sdk_version DROP DEFAULT, + ALTER COLUMN required_capabilities DROP DEFAULT, + ALTER COLUMN serving_requirements_digest DROP DEFAULT, + ADD CONSTRAINT billing_migration_serving_requirements_nonzero CHECK(serving_requirements_digest<>decode(repeat('00',32),'hex')); + +CREATE TABLE billing_migration_final_delta_cohort_sets ( + id text PRIMARY KEY, final_delta_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, + customer_count integer NOT NULL CHECK(customer_count BETWEEN 1 AND 1000000), + cohort_digest bytea NOT NULL CHECK(octet_length(cohort_digest)=32), frozen_at timestamptz NOT NULL, + UNIQUE(final_delta_id), UNIQUE(program_id,cohort_digest), UNIQUE(id,program_id,project_id), + FOREIGN KEY(final_delta_id) REFERENCES billing_migration_final_deltas(id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); +CREATE TABLE billing_migration_final_delta_cohort_customers ( + cohort_set_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, billing_customer_id text NOT NULL, + customer_digest bytea NOT NULL CHECK(octet_length(customer_digest)=32), + PRIMARY KEY(cohort_set_id,billing_customer_id), + FOREIGN KEY(cohort_set_id,program_id,project_id) REFERENCES billing_migration_final_delta_cohort_sets(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(billing_customer_id,project_id) REFERENCES billing_customers(id,project_id) ON DELETE RESTRICT +); +ALTER TABLE billing_migration_checkpoints + ADD COLUMN cohort_digest bytea NOT NULL DEFAULT decode(repeat('00',32),'hex') CHECK(octet_length(cohort_digest)=32); +-- Pre-00054 checkpoints cannot recover a deleted cohort membership set. Bind a +-- deterministic non-placeholder legacy marker to the immutable checkpoint; all +-- newly created checkpoints use the exact final-delta cohort digest. +ALTER TABLE billing_migration_checkpoints DISABLE TRIGGER billing_migration_checkpoints_immutable; +UPDATE billing_migration_checkpoints SET cohort_digest=sha256(checkpoint_digest); +ALTER TABLE billing_migration_checkpoints ENABLE TRIGGER billing_migration_checkpoints_immutable; +ALTER TABLE billing_migration_checkpoints + ALTER COLUMN cohort_digest DROP DEFAULT, + ADD CONSTRAINT billing_migration_checkpoint_cohort_nonzero CHECK(cohort_digest<>decode(repeat('00',32),'hex')); + +CREATE TABLE billing_migration_rollback_proposal_bindings ( + proposal_id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, + checkpoint_id text NOT NULL, checkpoint_digest bytea NOT NULL CHECK(octet_length(checkpoint_digest)=32), + authority_digest bytea NOT NULL CHECK(octet_length(authority_digest)=32), + rollback_prerequisites_digest bytea NOT NULL CHECK(octet_length(rollback_prerequisites_digest)=32), + scope_digest bytea NOT NULL CHECK(octet_length(scope_digest)=32), + cutover_transition_id text NOT NULL, cutover_transition_digest bytea NOT NULL CHECK(octet_length(cutover_transition_digest)=32), + cutover_epoch bigint NOT NULL CHECK(cutover_epoch>=1), cutover_transitioned_at timestamptz NOT NULL, rollback_deadline timestamptz NOT NULL, + credential_id text NOT NULL, credential_status text NOT NULL CHECK(credential_status='active'), + credential_removed boolean NOT NULL CHECK(NOT credential_removed), credential_removed_at timestamptz CHECK(credential_removed_at IS NULL), + capability_assessment_id text NOT NULL, capability_assessment_digest bytea NOT NULL CHECK(octet_length(capability_assessment_digest)=32), capability_assessed_at timestamptz NOT NULL, + source_validation_id text NOT NULL, source_validation_digest bytea NOT NULL CHECK(octet_length(source_validation_digest)=32), source_validated_at timestamptz NOT NULL, + provider_validation_id text NOT NULL, provider_validation_digest bytea NOT NULL CHECK(octet_length(provider_validation_digest)=32), provider_validated_at timestamptz NOT NULL, + created_at timestamptz NOT NULL, + FOREIGN KEY(proposal_id,program_id,project_id) REFERENCES billing_migration_cutover_proposals(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(checkpoint_id,program_id,project_id) REFERENCES billing_migration_checkpoints(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(cutover_transition_id) REFERENCES billing_migration_authority_transitions(id) ON DELETE RESTRICT, + FOREIGN KEY(credential_id,project_id) REFERENCES billing_migration_credentials(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(capability_assessment_id) REFERENCES billing_migration_capability_assessments(id) ON DELETE RESTRICT, + FOREIGN KEY(source_validation_id,program_id,project_id) REFERENCES billing_migration_validation_attempts(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(provider_validation_id,program_id,project_id) REFERENCES billing_migration_validation_attempts(id,program_id,project_id) ON DELETE RESTRICT, + CHECK(rollback_deadline>cutover_transitioned_at) +); + +CREATE TRIGGER billing_migration_cohort_sets_immutable BEFORE UPDATE OR DELETE ON billing_migration_final_delta_cohort_sets FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_cohort_customers_immutable BEFORE UPDATE OR DELETE ON billing_migration_final_delta_cohort_customers FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_rollback_bindings_immutable BEFORE UPDATE OR DELETE ON billing_migration_rollback_proposal_bindings FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS(SELECT 1 FROM billing_migration_final_delta_cohort_sets) + OR EXISTS(SELECT 1 FROM billing_migration_final_delta_cohort_customers) + OR EXISTS(SELECT 1 FROM billing_migration_rollback_proposal_bindings) THEN + RAISE EXCEPTION 'migration 00054 rollback refused: immutable cohort or rollback proposal evidence exists' USING ERRCODE='55000'; + END IF; +END; +$$; +-- +goose StatementEnd +DROP TRIGGER billing_migration_rollback_bindings_immutable ON billing_migration_rollback_proposal_bindings; +DROP TRIGGER billing_migration_cohort_customers_immutable ON billing_migration_final_delta_cohort_customers; +DROP TRIGGER billing_migration_cohort_sets_immutable ON billing_migration_final_delta_cohort_sets; +DROP TABLE billing_migration_rollback_proposal_bindings; +ALTER TABLE billing_migration_checkpoints DROP CONSTRAINT billing_migration_checkpoint_cohort_nonzero, DROP COLUMN cohort_digest; +DROP TABLE billing_migration_final_delta_cohort_customers; +DROP TABLE billing_migration_final_delta_cohort_sets; +ALTER TABLE billing_migration_readiness_policy_scopes + DROP CONSTRAINT billing_migration_serving_requirements_nonzero, + DROP COLUMN serving_requirements_digest, + DROP COLUMN required_capabilities, + DROP COLUMN minimum_sdk_version; diff --git a/apps/api/migrations/00055_phase_9c_source_execution.sql b/apps/api/migrations/00055_phase_9c_source_execution.sql new file mode 100644 index 00000000..8021b1c4 --- /dev/null +++ b/apps/api/migrations/00055_phase_9c_source_execution.sql @@ -0,0 +1,254 @@ +-- Phase 9C Stage 2E Package A: encrypted source-object and bounded execution queues. +-- Source evidence remains separate from billing_transaction_facts and live entitlement pointers. + +-- +goose Up +CREATE TABLE billing_migration_source_objects ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + reservation_key text NOT NULL CHECK (btrim(reservation_key) <> '' AND length(reservation_key) <= 128), + reservation_digest bytea NOT NULL CHECK (octet_length(reservation_digest) = 32), + reservation_generation bigint NOT NULL DEFAULT 1 CHECK (reservation_generation >= 1), + write_token_digest bytea NOT NULL CHECK (octet_length(write_token_digest) = 32), + object_key text NOT NULL CHECK (btrim(object_key) <> '' AND length(object_key) <= 512), + source_channel text NOT NULL CHECK (source_channel IN ('revenuecat_api_v2','scheduled_export_evidence')), + adapter_version text NOT NULL CHECK (btrim(adapter_version) <> ''), + schema_version text NOT NULL CHECK (btrim(schema_version) <> ''), + state text NOT NULL CHECK (state IN ('reserved','verified','failed','deleted')), + envelope_version integer, + algorithm text, + key_id text, + nonce bytea, + chunk_size integer, + chunk_count integer, + aad_digest bytea, + plaintext_digest bytea, + plaintext_size_bytes bigint, + ciphertext_digest bytea, + ciphertext_size_bytes bigint, + error_code text CHECK (error_code IS NULL OR (btrim(error_code) <> '' AND length(error_code) <= 64 AND error_code !~ '[[:cntrl:]]')), + reserved_at timestamptz NOT NULL, + verified_at timestamptz, + failed_at timestamptz, + deleted_at timestamptz, + deletion_actor_id text, + deletion_digest bytea, + UNIQUE (id, program_id, project_id), + UNIQUE (program_id, reservation_key), + UNIQUE (object_key), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + CHECK (plaintext_size_bytes IS NULL OR plaintext_size_bytes BETWEEN 0 AND 104857600), + CHECK (ciphertext_size_bytes IS NULL OR ciphertext_size_bytes >= 0), + CHECK ((state = 'reserved' AND envelope_version IS NULL AND verified_at IS NULL AND failed_at IS NULL AND deleted_at IS NULL) + OR (state = 'verified' AND envelope_version = 1 AND algorithm = 'AES-256-GCM-CHUNKED' + AND key_id IS NOT NULL AND btrim(key_id) <> '' AND nonce IS NOT NULL AND octet_length(nonce) = 12 + AND chunk_size IS NOT NULL AND chunk_size BETWEEN 16384 AND 4194304 + AND chunk_count >= 0 AND octet_length(aad_digest) = 32 AND octet_length(plaintext_digest) = 32 + AND plaintext_size_bytes IS NOT NULL AND octet_length(ciphertext_digest) = 32 + AND ciphertext_size_bytes IS NOT NULL AND verified_at IS NOT NULL AND failed_at IS NULL AND deleted_at IS NULL) + OR (state = 'failed' AND error_code IS NOT NULL AND failed_at IS NOT NULL AND verified_at IS NULL AND deleted_at IS NULL) + OR (state = 'deleted' AND verified_at IS NOT NULL AND deleted_at IS NOT NULL AND deletion_actor_id IS NOT NULL + AND octet_length(deletion_digest) = 32)), + CHECK ((deleted_at IS NULL AND deletion_actor_id IS NULL AND deletion_digest IS NULL) + OR (deleted_at IS NOT NULL AND deletion_actor_id IS NOT NULL AND octet_length(deletion_digest) = 32)) +); +CREATE INDEX billing_migration_source_objects_program_state_idx + ON billing_migration_source_objects(program_id, state, reserved_at, id); + +CREATE TABLE billing_migration_source_object_manifests ( + source_object_id text PRIMARY KEY, + manifest_id text NOT NULL UNIQUE, + program_id text NOT NULL, + project_id text NOT NULL, + binding_digest bytea NOT NULL CHECK (octet_length(binding_digest) = 32), + bound_at timestamptz NOT NULL, + FOREIGN KEY (source_object_id, program_id, project_id) + REFERENCES billing_migration_source_objects(id, program_id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (manifest_id, program_id, project_id) + REFERENCES billing_migration_source_manifests(id, program_id, project_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_import_batch_records ( + import_batch_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, + source_record_id text NOT NULL, ordinal integer NOT NULL CHECK (ordinal BETWEEN 0 AND 999), + provider text NOT NULL CHECK (provider IN ('app_store','google_play')), + environment_id text NOT NULL, application_id text NOT NULL, + provider_reference text NOT NULL CHECK (octet_length(provider_reference) BETWEEN 1 AND 512 AND provider_reference !~ '[[:cntrl:]]'), + PRIMARY KEY(import_batch_id,source_record_id), UNIQUE(import_batch_id,ordinal), + FOREIGN KEY(import_batch_id,program_id,project_id) REFERENCES billing_migration_import_batches(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(source_record_id,program_id,project_id) REFERENCES billing_migration_source_records(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(environment_id,project_id) REFERENCES environments(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(application_id,project_id) REFERENCES applications(id,project_id) ON DELETE RESTRICT +); + +ALTER TABLE billing_migration_import_batches + ADD COLUMN due_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN max_attempts integer NOT NULL DEFAULT 8 CHECK (max_attempts BETWEEN 1 AND 20), + ADD COLUMN last_error_code text CHECK (last_error_code IS NULL OR (btrim(last_error_code) <> '' AND length(last_error_code) <= 64 AND last_error_code !~ '[[:cntrl:]]')); +ALTER TABLE billing_migration_import_batches ALTER COLUMN due_at DROP DEFAULT, ALTER COLUMN max_attempts DROP DEFAULT; +DROP INDEX billing_migration_import_batches_claim_idx; +CREATE INDEX billing_migration_import_batches_claim_idx + ON billing_migration_import_batches(due_at, updated_at, id) + WHERE status = 'pending' OR status = 'running'; + +ALTER TABLE billing_migration_run_jobs + ADD COLUMN due_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN max_attempts integer NOT NULL DEFAULT 8 CHECK (max_attempts BETWEEN 1 AND 20), + ADD COLUMN last_error_code text CHECK (last_error_code IS NULL OR (btrim(last_error_code) <> '' AND length(last_error_code) <= 64 AND last_error_code !~ '[[:cntrl:]]')); +ALTER TABLE billing_migration_run_jobs ALTER COLUMN due_at DROP DEFAULT, ALTER COLUMN max_attempts DROP DEFAULT; +DROP INDEX billing_migration_run_jobs_claim_idx; +CREATE INDEX billing_migration_run_jobs_claim_idx + ON billing_migration_run_jobs(due_at, updated_at, id) + WHERE status = 'pending' OR status = 'running'; + +CREATE TABLE billing_migration_final_delta_jobs ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + idempotency_key text NOT NULL CHECK (btrim(idempotency_key) <> '' AND length(idempotency_key) <= 128), + request_digest bytea NOT NULL CHECK (octet_length(request_digest) = 32), + expected_program_state_version bigint NOT NULL CHECK (expected_program_state_version >= 1), + manifest_digest bytea NOT NULL CHECK (octet_length(manifest_digest) = 32), + mapping_digest bytea NOT NULL CHECK (octet_length(mapping_digest) = 32), + evidence_digest bytea NOT NULL CHECK (octet_length(evidence_digest) = 32), + status text NOT NULL CHECK (status IN ('pending','running','completed','failed')), + result_final_delta_id text, + due_at timestamptz NOT NULL, + lease_owner text, + lease_expires_at timestamptz, + lease_generation bigint NOT NULL DEFAULT 0 CHECK (lease_generation >= 0), + attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + max_attempts integer NOT NULL CHECK (max_attempts BETWEEN 1 AND 20), + last_error_code text CHECK (last_error_code IS NULL OR (btrim(last_error_code) <> '' AND length(last_error_code) <= 64 AND last_error_code !~ '[[:cntrl:]]')), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + UNIQUE (id, program_id, project_id), + UNIQUE (program_id, idempotency_key), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (result_final_delta_id) REFERENCES billing_migration_final_deltas(id) ON DELETE RESTRICT, + CHECK ((status = 'running') = (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)), + CHECK ((status = 'completed') = (result_final_delta_id IS NOT NULL)) +); +CREATE INDEX billing_migration_final_delta_jobs_claim_idx + ON billing_migration_final_delta_jobs(due_at, updated_at, id) + WHERE status = 'pending' OR status = 'running'; + +CREATE TABLE billing_migration_final_delta_prepared_pointers ( + final_delta_job_id text NOT NULL, lease_generation bigint NOT NULL CHECK(lease_generation>=1), + program_id text NOT NULL, project_id text NOT NULL, environment_id text NOT NULL, + application_id text NOT NULL, platform text NOT NULL CHECK(platform IN ('ios','android')), + billing_customer_id text NOT NULL, prepared_snapshot_id text NOT NULL, + prepared_digest bytea NOT NULL CHECK(octet_length(prepared_digest)=32), prepared_at timestamptz NOT NULL, + PRIMARY KEY(final_delta_job_id,application_id,platform,billing_customer_id), + FOREIGN KEY(final_delta_job_id,program_id,project_id) REFERENCES billing_migration_final_delta_jobs(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,application_id,platform) REFERENCES billing_migration_program_scopes(program_id,application_id,platform) ON DELETE RESTRICT, + FOREIGN KEY(prepared_snapshot_id,project_id,environment_id,billing_customer_id) REFERENCES customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_execution_attempts ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + job_kind text NOT NULL CHECK (job_kind IN ('import','dry_run','shadow','final_delta')), + job_id text NOT NULL, + lease_owner text NOT NULL, + lease_generation bigint NOT NULL CHECK (lease_generation >= 1), + attempt_phase text NOT NULL CHECK (attempt_phase IN ('started','completed','failed')), + started_attempt_id text, + result_digest bytea NOT NULL CHECK (octet_length(result_digest) = 32), + error_code text CHECK (error_code IS NULL OR (btrim(error_code) <> '' AND length(error_code) <= 64 AND error_code !~ '[[:cntrl:]]')), + recorded_at timestamptz NOT NULL, + UNIQUE (job_kind, job_id, lease_generation, attempt_phase), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (started_attempt_id) REFERENCES billing_migration_execution_attempts(id) ON DELETE RESTRICT, + CHECK ((attempt_phase = 'started' AND started_attempt_id IS NULL AND error_code IS NULL) + OR (attempt_phase = 'completed' AND started_attempt_id IS NOT NULL AND error_code IS NULL) + OR (attempt_phase = 'failed' AND started_attempt_id IS NOT NULL AND error_code IS NOT NULL)) +); +CREATE INDEX billing_migration_execution_attempts_job_idx + ON billing_migration_execution_attempts(job_kind, job_id, lease_generation, recorded_at); + +-- +goose StatementBegin +CREATE FUNCTION protect_billing_migration_verified_source_object() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'billing migration source objects cannot be deleted from the ledger' USING ERRCODE = '55000'; + END IF; + IF OLD.state = 'verified' AND NOT ( + NEW.state = 'deleted' AND NEW.id = OLD.id AND NEW.program_id = OLD.program_id AND NEW.project_id = OLD.project_id + AND NEW.reservation_key = OLD.reservation_key AND NEW.reservation_digest = OLD.reservation_digest + AND NEW.reservation_generation = OLD.reservation_generation AND NEW.write_token_digest = OLD.write_token_digest + AND NEW.object_key = OLD.object_key AND NEW.source_channel = OLD.source_channel + AND NEW.adapter_version = OLD.adapter_version AND NEW.schema_version = OLD.schema_version + AND NEW.envelope_version = OLD.envelope_version AND NEW.algorithm = OLD.algorithm AND NEW.key_id = OLD.key_id + AND NEW.nonce = OLD.nonce AND NEW.chunk_size = OLD.chunk_size AND NEW.chunk_count = OLD.chunk_count + AND NEW.aad_digest = OLD.aad_digest AND NEW.plaintext_digest = OLD.plaintext_digest + AND NEW.plaintext_size_bytes = OLD.plaintext_size_bytes AND NEW.ciphertext_digest = OLD.ciphertext_digest + AND NEW.ciphertext_size_bytes = OLD.ciphertext_size_bytes AND NEW.verified_at = OLD.verified_at + AND NEW.reserved_at = OLD.reserved_at + AND NEW.failed_at IS NULL AND NEW.error_code IS NOT DISTINCT FROM OLD.error_code + ) THEN + RAISE EXCEPTION 'verified billing migration source evidence is immutable' USING ERRCODE = '55000'; + END IF; + IF OLD.state IN ('failed','deleted') THEN + RAISE EXCEPTION 'terminal billing migration source object is immutable' USING ERRCODE = '55000'; + END IF; + IF OLD.state = 'reserved' AND ( + NEW.state NOT IN ('verified','failed') OR NEW.id <> OLD.id OR NEW.program_id <> OLD.program_id + OR NEW.project_id <> OLD.project_id OR NEW.reservation_key <> OLD.reservation_key + OR NEW.reservation_digest <> OLD.reservation_digest OR NEW.object_key <> OLD.object_key + OR NEW.reservation_generation <> OLD.reservation_generation OR NEW.write_token_digest <> OLD.write_token_digest + OR NEW.source_channel <> OLD.source_channel OR NEW.adapter_version <> OLD.adapter_version + OR NEW.schema_version <> OLD.schema_version OR NEW.reserved_at <> OLD.reserved_at + ) THEN + RAISE EXCEPTION 'reserved billing migration source object permits only verification or failure' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER billing_migration_source_objects_protected + BEFORE UPDATE OR DELETE ON billing_migration_source_objects + FOR EACH ROW EXECUTE FUNCTION protect_billing_migration_verified_source_object(); +CREATE TRIGGER billing_migration_source_object_manifests_immutable + BEFORE UPDATE OR DELETE ON billing_migration_source_object_manifests + FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_execution_attempts_immutable + BEFORE UPDATE OR DELETE ON billing_migration_execution_attempts + FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_import_batch_records_immutable BEFORE UPDATE OR DELETE ON billing_migration_import_batch_records FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_final_delta_prepared_immutable BEFORE UPDATE OR DELETE ON billing_migration_final_delta_prepared_pointers FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM billing_migration_source_objects WHERE state IN ('verified','deleted')) + OR EXISTS (SELECT 1 FROM billing_migration_source_object_manifests) + OR EXISTS (SELECT 1 FROM billing_migration_execution_attempts) + OR EXISTS (SELECT 1 FROM billing_migration_import_batch_records) + OR EXISTS (SELECT 1 FROM billing_migration_final_delta_prepared_pointers) + OR EXISTS (SELECT 1 FROM billing_migration_final_delta_jobs) THEN + RAISE EXCEPTION 'migration 00055 rollback refused: immutable source or execution evidence exists' USING ERRCODE = '55000'; + END IF; +END; +$$; +-- +goose StatementEnd +DROP TRIGGER billing_migration_execution_attempts_immutable ON billing_migration_execution_attempts; +DROP TRIGGER billing_migration_final_delta_prepared_immutable ON billing_migration_final_delta_prepared_pointers; +DROP TRIGGER billing_migration_import_batch_records_immutable ON billing_migration_import_batch_records; +DROP TRIGGER billing_migration_source_object_manifests_immutable ON billing_migration_source_object_manifests; +DROP TRIGGER billing_migration_source_objects_protected ON billing_migration_source_objects; +DROP FUNCTION protect_billing_migration_verified_source_object(); +DROP TABLE billing_migration_execution_attempts; +DROP TABLE billing_migration_final_delta_prepared_pointers; +DROP TABLE billing_migration_final_delta_jobs; +DROP INDEX billing_migration_run_jobs_claim_idx; +ALTER TABLE billing_migration_run_jobs DROP COLUMN last_error_code, DROP COLUMN max_attempts, DROP COLUMN due_at; +CREATE INDEX billing_migration_run_jobs_claim_idx ON billing_migration_run_jobs(updated_at, id) WHERE status IN ('pending','running'); +DROP INDEX billing_migration_import_batches_claim_idx; +ALTER TABLE billing_migration_import_batches DROP COLUMN last_error_code, DROP COLUMN max_attempts, DROP COLUMN due_at; +CREATE INDEX billing_migration_import_batches_claim_idx ON billing_migration_import_batches(updated_at, id) WHERE status IN ('pending','running'); +DROP TABLE billing_migration_source_object_manifests; +DROP TABLE billing_migration_import_batch_records; +DROP TABLE billing_migration_source_objects; diff --git a/apps/api/migrations/00056_phase_9c_webhook_v2.sql b/apps/api/migrations/00056_phase_9c_webhook_v2.sql new file mode 100644 index 00000000..6fc0d1e0 --- /dev/null +++ b/apps/api/migrations/00056_phase_9c_webhook_v2.sql @@ -0,0 +1,315 @@ +-- Phase 9C Stage 2E Package B: authority-transition notifications, Billing +-- State Webhook v2, and stable-event migration redelivery. + +-- +goose Up +ALTER TABLE webhook_destinations + ADD COLUMN contract_version integer NOT NULL DEFAULT 1 + CHECK (contract_version IN (1, 2)), + ADD COLUMN last_successful_test_at timestamptz; + +ALTER TABLE webhook_deliveries + ADD COLUMN leased_destination_config_digest bytea + CHECK (leased_destination_config_digest IS NULL OR octet_length(leased_destination_config_digest)=32); + +-- +goose StatementBegin +CREATE FUNCTION webhook_destination_config_digest(destination_id text, owner_project_id text) RETURNS bytea LANGUAGE sql STABLE AS $$ + SELECT sha256(convert_to(jsonb_build_object( + 'url',d.url, + 'status',d.status, + 'eventTypes',d.event_types, + 'contractVersion',d.contract_version, + 'updatedAt',d.updated_at, + 'signingSecrets',COALESCE(( + SELECT jsonb_agg(jsonb_build_array( + s.id,s.status,encode(s.fingerprint,'hex'),s.honored_until + ) ORDER BY s.id) + FROM webhook_signing_secrets s + WHERE s.webhook_destination_id=d.id AND s.project_id=d.project_id + ),'[]'::jsonb) + )::text,'UTF8')) + FROM webhook_destinations d + WHERE d.id=destination_id AND d.project_id=owner_project_id +$$; +-- +goose StatementEnd + +ALTER TABLE webhook_events + DROP CONSTRAINT webhook_events_event_type_check, + DROP CONSTRAINT webhook_events_customer_entitlement_snapshot_id_event_type_key, + DROP CONSTRAINT webhook_events_customer_entitlement_snapshot_id_project_id_fkey, + DROP CONSTRAINT webhook_events_snapshot_version_check, + ALTER COLUMN customer_entitlement_snapshot_id DROP NOT NULL, + ADD COLUMN contract_version integer NOT NULL DEFAULT 1 + CHECK (contract_version IN (1, 2)), + ADD COLUMN payload_bytes bytea, + ADD COLUMN payload_digest bytea, + ADD COLUMN authority_scope_id text, + ADD COLUMN authority_epoch bigint, + ADD COLUMN authority_kind text, + ADD COLUMN transition_state text, + ADD COLUMN correlation_id text, + ADD COLUMN snapshot_authority_digest bytea, + ADD COLUMN transition_outbox_id text, + ADD CONSTRAINT webhook_events_event_type_check CHECK (event_type IN ( + 'customer.entitlements.changed', 'authority.cutover.pending', + 'authority.cutover.completed', 'authority.rollback.completed', + 'authority.stabilization.completed')), + ADD CONSTRAINT webhook_events_snapshot_version_check CHECK (snapshot_version >= 0), + ADD CONSTRAINT webhook_events_snapshot_fk FOREIGN KEY + (customer_entitlement_snapshot_id, project_id) + REFERENCES customer_entitlement_snapshots(id, project_id) ON DELETE RESTRICT, + ADD CONSTRAINT webhook_events_authority_scope_fk FOREIGN KEY + (authority_scope_id, project_id) + REFERENCES billing_migration_authority_scopes(id, project_id) ON DELETE RESTRICT; + +UPDATE webhook_events +SET payload_bytes = convert_to(payload::text, 'UTF8'), + payload_digest = sha256(convert_to(payload::text, 'UTF8')); + +-- +goose StatementBegin +CREATE FUNCTION normalize_webhook_event_payload_bytes() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.payload_bytes IS NULL THEN + NEW.payload_bytes := convert_to(NEW.payload::text, 'UTF8'); + END IF; + IF NEW.payload_digest IS NULL THEN + NEW.payload_digest := sha256(NEW.payload_bytes); + END IF; + RETURN NEW; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER webhook_events_payload_bytes +BEFORE INSERT ON webhook_events +FOR EACH ROW EXECUTE FUNCTION normalize_webhook_event_payload_bytes(); + +ALTER TABLE webhook_events + ALTER COLUMN payload_bytes SET NOT NULL, + ALTER COLUMN payload_digest SET NOT NULL, + ADD CONSTRAINT webhook_events_payload_bytes_size + CHECK (octet_length(payload_bytes) BETWEEN 2 AND 65536), + ADD CONSTRAINT webhook_events_payload_digest_size + CHECK (octet_length(payload_digest) = 32), + ADD CONSTRAINT webhook_events_payload_digest_matches + CHECK (payload_digest = sha256(payload_bytes)), + ADD CONSTRAINT webhook_events_contract_shape CHECK ( + (contract_version = 1 + AND event_type = 'customer.entitlements.changed' + AND customer_entitlement_snapshot_id IS NOT NULL + AND snapshot_version >= 1 + AND authority_scope_id IS NULL AND authority_epoch IS NULL + AND authority_kind IS NULL AND transition_state IS NULL + AND correlation_id IS NULL AND snapshot_authority_digest IS NULL + AND transition_outbox_id IS NULL) + OR + (contract_version = 2 + AND authority_scope_id IS NOT NULL AND authority_epoch IS NOT NULL + AND authority_epoch >= 0 + AND authority_kind IN ('source','mosaic','source_rollback') + AND transition_state IN ('stable','cutover_pending','stabilizing','rolled_back') + AND btrim(correlation_id) <> '' + AND octet_length(snapshot_authority_digest) = 32 + AND ((snapshot_version = 0 AND customer_entitlement_snapshot_id IS NULL) + OR (snapshot_version >= 1 AND customer_entitlement_snapshot_id IS NOT NULL)) + AND ( + (event_type = 'customer.entitlements.changed') OR + (event_type = 'authority.cutover.pending' AND authority_kind = 'source' AND transition_state = 'cutover_pending') OR + (event_type = 'authority.cutover.completed' AND authority_kind = 'mosaic' AND transition_state = 'stabilizing') OR + (event_type = 'authority.rollback.completed' AND authority_kind = 'source_rollback' AND transition_state = 'rolled_back') OR + (event_type = 'authority.stabilization.completed' AND authority_kind = 'mosaic' AND transition_state = 'stable') + )) + ); +CREATE UNIQUE INDEX webhook_events_snapshot_event_unique + ON webhook_events(customer_entitlement_snapshot_id,event_type); +CREATE UNIQUE INDEX webhook_events_transition_customer_unique + ON webhook_events(transition_outbox_id,billing_customer_id) + WHERE transition_outbox_id IS NOT NULL; + +ALTER TABLE billing_migration_transition_outbox + DROP CONSTRAINT billing_migration_transition_outbox_event_kind_check, + DROP CONSTRAINT billing_migration_transition_outbox_authority_epoch_check, + DROP CONSTRAINT billing_migration_transition_outbox_transition_id_fkey, + ALTER COLUMN transition_id DROP NOT NULL, + ADD COLUMN checkpoint_id text, + ADD COLUMN completion_report_id text, + ADD COLUMN correlation_id text, + ADD COLUMN due_at timestamptz, + ADD COLUMN lease_generation bigint NOT NULL DEFAULT 0 CHECK (lease_generation >= 0), + ADD COLUMN max_attempts integer NOT NULL DEFAULT 8 CHECK (max_attempts BETWEEN 1 AND 32), + ADD COLUMN last_error_code text, + ADD COLUMN legacy_event_kind boolean NOT NULL DEFAULT false, + ADD CONSTRAINT billing_migration_transition_outbox_event_kind_check CHECK (event_kind IN ( + 'authority_changed','rollback_changed','cutover_pending','cutover_completed', + 'rollback_completed','stabilization_completed')), + ADD CONSTRAINT billing_migration_transition_outbox_authority_epoch_check CHECK (authority_epoch >= 0), + ADD CONSTRAINT billing_migration_transition_outbox_checkpoint_fk FOREIGN KEY + (checkpoint_id, program_id, project_id) + REFERENCES billing_migration_checkpoints(id, program_id, project_id) ON DELETE RESTRICT, + ADD CONSTRAINT billing_migration_transition_outbox_transition_fk FOREIGN KEY + (transition_id) REFERENCES billing_migration_authority_transitions(id) ON DELETE RESTRICT, + ADD CONSTRAINT billing_migration_transition_outbox_completion_fk FOREIGN KEY + (completion_report_id) REFERENCES billing_migration_completion_reports(id) ON DELETE RESTRICT; + +UPDATE billing_migration_transition_outbox outbox +SET checkpoint_id = (SELECT c.id FROM billing_migration_checkpoints c + WHERE c.program_id = outbox.program_id AND c.project_id = outbox.project_id + ORDER BY c.created_at DESC, c.id DESC LIMIT 1), + correlation_id = outbox.transition_id, + due_at = outbox.updated_at, + legacy_event_kind = true; + +-- +goose StatementBegin +CREATE FUNCTION normalize_billing_migration_transition_outbox() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.event_kind = 'authority_changed' THEN + NEW.legacy_event_kind := true; + ELSIF NEW.event_kind = 'rollback_changed' THEN + NEW.legacy_event_kind := true; + END IF; + IF NEW.checkpoint_id IS NULL THEN + SELECT c.id INTO NEW.checkpoint_id FROM billing_migration_checkpoints c + WHERE c.program_id=NEW.program_id AND c.project_id=NEW.project_id + ORDER BY c.created_at DESC,c.id DESC LIMIT 1; + END IF; + NEW.correlation_id := COALESCE(NEW.correlation_id, NEW.transition_id, NEW.completion_report_id, NEW.checkpoint_id); + NEW.due_at := COALESCE(NEW.due_at, NEW.updated_at, NEW.created_at); + RETURN NEW; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER billing_migration_transition_outbox_normalize +BEFORE INSERT ON billing_migration_transition_outbox +FOR EACH ROW EXECUTE FUNCTION normalize_billing_migration_transition_outbox(); + +ALTER TABLE billing_migration_transition_outbox + ALTER COLUMN checkpoint_id SET NOT NULL, + ALTER COLUMN correlation_id SET NOT NULL, + ALTER COLUMN due_at SET NOT NULL, + ADD CONSTRAINT billing_migration_transition_outbox_correlation_shape CHECK ( + (event_kind = 'cutover_pending' AND transition_id IS NULL AND completion_report_id IS NULL) OR + (event_kind IN ('authority_changed','rollback_changed','cutover_completed','rollback_completed') AND transition_id IS NOT NULL AND completion_report_id IS NULL) OR + (event_kind = 'stabilization_completed' AND transition_id IS NULL AND completion_report_id IS NOT NULL)), + ADD CONSTRAINT billing_migration_transition_outbox_running_lease CHECK ( + (status = 'running') = (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)), + ADD CONSTRAINT billing_migration_transition_outbox_logical_identity UNIQUE + (program_id, authority_scope_id, event_kind, correlation_id); + +DROP INDEX billing_migration_transition_outbox_claim_idx; +CREATE INDEX billing_migration_transition_outbox_claim_idx + ON billing_migration_transition_outbox(due_at,id) + WHERE status IN ('pending','running'); + +ALTER TABLE webhook_events + ADD CONSTRAINT webhook_events_transition_outbox_fk FOREIGN KEY (transition_outbox_id) + REFERENCES billing_migration_transition_outbox(id) ON DELETE RESTRICT; + +CREATE TABLE billing_migration_webhook_redeliveries ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + webhook_event_id text NOT NULL, + webhook_destination_id text NOT NULL, + webhook_delivery_id text NOT NULL, + idempotency_key text NOT NULL CHECK (btrim(idempotency_key) <> ''), + request_digest bytea NOT NULL CHECK (octet_length(request_digest) = 32), + expected_state_version bigint NOT NULL CHECK (expected_state_version >= 1), + expected_event_digest bytea NOT NULL CHECK (octet_length(expected_event_digest) = 32), + reason text NOT NULL CHECK (btrim(reason) <> '' AND length(reason) <= 500 AND reason !~ '[[:cntrl:]]'), + actor_id text NOT NULL, + created_at timestamptz NOT NULL, + UNIQUE(program_id,idempotency_key), + UNIQUE(id,program_id,project_id), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(webhook_event_id,project_id) REFERENCES webhook_events(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(webhook_destination_id,project_id) REFERENCES webhook_destinations(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(webhook_delivery_id,project_id) REFERENCES webhook_deliveries(id,project_id) ON DELETE RESTRICT +); +CREATE TRIGGER billing_migration_webhook_redeliveries_immutable +BEFORE UPDATE OR DELETE ON billing_migration_webhook_redeliveries +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM webhook_destinations WHERE contract_version = 2 OR last_successful_test_at IS NOT NULL) + OR EXISTS (SELECT 1 FROM webhook_events WHERE contract_version = 2) + OR EXISTS (SELECT 1 FROM billing_migration_transition_outbox WHERE NOT legacy_event_kind) + OR EXISTS (SELECT 1 FROM billing_migration_webhook_redeliveries) THEN + RAISE EXCEPTION 'cannot downgrade: Billing State Webhook v2 evidence exists' USING ERRCODE='55000'; + END IF; +END; +$$; +-- +goose StatementEnd + +DROP TRIGGER billing_migration_webhook_redeliveries_immutable ON billing_migration_webhook_redeliveries; +DROP TABLE billing_migration_webhook_redeliveries; +ALTER TABLE webhook_deliveries DROP COLUMN leased_destination_config_digest; +DROP FUNCTION webhook_destination_config_digest(text,text); +ALTER TABLE webhook_events DROP CONSTRAINT webhook_events_transition_outbox_fk; +DROP INDEX webhook_events_transition_customer_unique; +DROP INDEX webhook_events_snapshot_event_unique; +DROP TRIGGER webhook_events_payload_bytes ON webhook_events; +DROP FUNCTION normalize_webhook_event_payload_bytes(); +DROP INDEX billing_migration_transition_outbox_claim_idx; +ALTER TABLE billing_migration_transition_outbox + DROP CONSTRAINT billing_migration_transition_outbox_logical_identity, + DROP CONSTRAINT billing_migration_transition_outbox_running_lease, + DROP CONSTRAINT billing_migration_transition_outbox_correlation_shape, + ALTER COLUMN checkpoint_id DROP NOT NULL, + ALTER COLUMN correlation_id DROP NOT NULL, + ALTER COLUMN due_at DROP NOT NULL; +DROP TRIGGER billing_migration_transition_outbox_normalize ON billing_migration_transition_outbox; +DROP FUNCTION normalize_billing_migration_transition_outbox(); +ALTER TABLE billing_migration_transition_outbox + DROP CONSTRAINT billing_migration_transition_outbox_completion_fk, + DROP CONSTRAINT billing_migration_transition_outbox_transition_fk, + DROP CONSTRAINT billing_migration_transition_outbox_checkpoint_fk, + DROP CONSTRAINT billing_migration_transition_outbox_event_kind_check, + DROP CONSTRAINT billing_migration_transition_outbox_authority_epoch_check, + DROP COLUMN legacy_event_kind, + DROP COLUMN last_error_code, + DROP COLUMN max_attempts, + DROP COLUMN lease_generation, + DROP COLUMN due_at, + DROP COLUMN correlation_id, + DROP COLUMN completion_report_id, + DROP COLUMN checkpoint_id, + ALTER COLUMN transition_id SET NOT NULL, + ADD CONSTRAINT billing_migration_transition_outbox_event_kind_check + CHECK(event_kind IN ('authority_changed','rollback_changed')), + ADD CONSTRAINT billing_migration_transition_outbox_authority_epoch_check CHECK (authority_epoch >= 1), + ADD CONSTRAINT billing_migration_transition_outbox_transition_id_fkey + FOREIGN KEY(transition_id) REFERENCES billing_migration_authority_transitions(id) ON DELETE RESTRICT; +CREATE INDEX billing_migration_transition_outbox_claim_idx + ON billing_migration_transition_outbox(updated_at,id) WHERE status IN ('pending','running'); + +ALTER TABLE webhook_events + DROP CONSTRAINT webhook_events_contract_shape, + DROP CONSTRAINT webhook_events_payload_digest_matches, + DROP CONSTRAINT webhook_events_payload_digest_size, + DROP CONSTRAINT webhook_events_payload_bytes_size, + DROP CONSTRAINT webhook_events_authority_scope_fk, + DROP CONSTRAINT webhook_events_snapshot_fk, + DROP CONSTRAINT webhook_events_snapshot_version_check, + DROP CONSTRAINT webhook_events_event_type_check, + DROP COLUMN transition_outbox_id, + DROP COLUMN snapshot_authority_digest, + DROP COLUMN correlation_id, + DROP COLUMN transition_state, + DROP COLUMN authority_kind, + DROP COLUMN authority_epoch, + DROP COLUMN authority_scope_id, + DROP COLUMN payload_digest, + DROP COLUMN payload_bytes, + DROP COLUMN contract_version, + ALTER COLUMN customer_entitlement_snapshot_id SET NOT NULL, + ADD CONSTRAINT webhook_events_event_type_check CHECK (event_type IN ('customer.entitlements.changed')), + ADD CONSTRAINT webhook_events_snapshot_version_check CHECK (snapshot_version >= 1), + ADD CONSTRAINT webhook_events_customer_entitlement_snapshot_id_project_id_fkey FOREIGN KEY + (customer_entitlement_snapshot_id, project_id) + REFERENCES customer_entitlement_snapshots(id, project_id) ON DELETE RESTRICT, + ADD CONSTRAINT webhook_events_customer_entitlement_snapshot_id_event_type_key + UNIQUE (customer_entitlement_snapshot_id,event_type); +ALTER TABLE webhook_destinations + DROP COLUMN last_successful_test_at, + DROP COLUMN contract_version; diff --git a/apps/api/migrations/00057_phase_9c_repairs_completion_retention.sql b/apps/api/migrations/00057_phase_9c_repairs_completion_retention.sql new file mode 100644 index 00000000..51633749 --- /dev/null +++ b/apps/api/migrations/00057_phase_9c_repairs_completion_retention.sql @@ -0,0 +1,300 @@ +-- Phase 9C Stage 2E Package C: bounded repairs, credential destruction, +-- completion, legal holds, and raw-source retention. + +-- +goose Up +ALTER TABLE billing_migration_divergences ADD CONSTRAINT billing_migration_divergences_scope_unique UNIQUE(id,program_id,project_id); +ALTER TABLE billing_migration_cases + ADD COLUMN updated_at timestamptz, + ADD COLUMN linked_divergence_id text, + ADD COLUMN linked_source_record_id text, + ADD CONSTRAINT billing_migration_cases_resolution_shape CHECK ( + (status IN ('open','in_progress') AND resolved_at IS NULL) OR + (status IN ('resolved','dismissed') AND resolved_at IS NOT NULL)), + ADD CONSTRAINT billing_migration_cases_divergence_fk FOREIGN KEY (linked_divergence_id,program_id,project_id) + REFERENCES billing_migration_divergences(id,program_id,project_id) ON DELETE RESTRICT, + ADD CONSTRAINT billing_migration_cases_source_record_fk FOREIGN KEY + (linked_source_record_id,program_id,project_id) + REFERENCES billing_migration_source_records(id,program_id,project_id) ON DELETE RESTRICT; +UPDATE billing_migration_cases SET updated_at=COALESCE(resolved_at,opened_at); +ALTER TABLE billing_migration_cases ALTER COLUMN updated_at SET DEFAULT now(), ALTER COLUMN updated_at SET NOT NULL; + +ALTER TABLE billing_migration_repair_previews + DROP CONSTRAINT billing_migration_repair_previews_repair_kind_check, + ADD CONSTRAINT billing_migration_repair_previews_repair_kind_check CHECK(repair_kind IN ( + 'provider_revalidate','projection_replay','attach_proven_alias', + 'replace_mapping_set','retry_quarantined_record')), + ADD COLUMN expected_program_state_version bigint, + ADD COLUMN expected_case_digest bytea, + ADD COLUMN expected_policy_digest bytea, + ADD COLUMN expected_scope_digest bytea, + ADD COLUMN reason text, + ADD COLUMN expires_at timestamptz; +UPDATE billing_migration_repair_previews p SET + expected_program_state_version=(SELECT state_version FROM billing_migration_programs WHERE id=p.program_id), + expected_case_digest=(SELECT case_digest FROM billing_migration_cases WHERE id=p.case_id), + expected_policy_digest=(SELECT policy_digest FROM billing_migration_programs WHERE id=p.program_id), + expected_scope_digest=(SELECT scope_digest FROM billing_migration_programs WHERE id=p.program_id), + reason='legacy repair preview', + expires_at=p.created_at+interval '1 hour'; +ALTER TABLE billing_migration_repair_previews + ALTER COLUMN expected_program_state_version SET NOT NULL, + ALTER COLUMN expected_case_digest SET NOT NULL, + ALTER COLUMN expected_policy_digest SET NOT NULL, + ALTER COLUMN expected_scope_digest SET NOT NULL, + ALTER COLUMN reason SET NOT NULL, + ALTER COLUMN expires_at SET NOT NULL, + ADD CONSTRAINT billing_migration_repair_previews_state_version CHECK(expected_program_state_version>=1), + ADD CONSTRAINT billing_migration_repair_previews_case_digest CHECK(octet_length(expected_case_digest)=32), + ADD CONSTRAINT billing_migration_repair_previews_policy_digest CHECK(octet_length(expected_policy_digest)=32), + ADD CONSTRAINT billing_migration_repair_previews_scope_digest CHECK(octet_length(expected_scope_digest)=32), + ADD CONSTRAINT billing_migration_repair_previews_reason CHECK(btrim(reason)<>'' AND length(reason)<=500 AND reason !~ '[[:cntrl:]]'), + ADD CONSTRAINT billing_migration_repair_previews_expiry CHECK(expires_at>created_at); + +ALTER TABLE billing_migration_repair_executions + ADD COLUMN attempt_number integer, + ADD COLUMN request_digest bytea, + ADD COLUMN actual_before_digest bytea, + ADD COLUMN actual_after_digest bytea, + ADD COLUMN error_code text; +WITH numbered AS ( + SELECT id,row_number() OVER(PARTITION BY preview_id ORDER BY executed_at,id) AS n + FROM billing_migration_repair_executions +) UPDATE billing_migration_repair_executions e SET + attempt_number=n.n, + request_digest=sha256(convert_to(e.idempotency_key,'UTF8')), + actual_before_digest=p.before_digest, + actual_after_digest=p.after_digest +FROM numbered n,billing_migration_repair_previews p +WHERE e.id=n.id AND p.id=e.preview_id; +ALTER TABLE billing_migration_repair_executions + ALTER COLUMN attempt_number SET NOT NULL, + ALTER COLUMN request_digest SET NOT NULL, + ALTER COLUMN actual_before_digest SET NOT NULL, + ALTER COLUMN actual_after_digest SET NOT NULL, + ADD CONSTRAINT billing_migration_repair_executions_attempt CHECK(attempt_number BETWEEN 1 AND 16), + ADD CONSTRAINT billing_migration_repair_executions_request_digest CHECK(octet_length(request_digest)=32), + ADD CONSTRAINT billing_migration_repair_executions_before_digest CHECK(octet_length(actual_before_digest)=32), + ADD CONSTRAINT billing_migration_repair_executions_after_digest CHECK(octet_length(actual_after_digest)=32), + ADD CONSTRAINT billing_migration_repair_executions_error_shape CHECK( + (result='failed' AND btrim(error_code)<>'') OR (result<>'failed' AND error_code IS NULL)), + ADD CONSTRAINT billing_migration_repair_executions_preview_attempt_unique UNIQUE(preview_id,attempt_number); + +CREATE TABLE billing_migration_repair_reservations ( + id text PRIMARY KEY, preview_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, + idempotency_key text NOT NULL CHECK(btrim(idempotency_key)<>''), request_digest bytea NOT NULL CHECK(octet_length(request_digest)=32), + attempt_number integer NOT NULL CHECK(attempt_number BETWEEN 1 AND 16), expected_program_state_version bigint NOT NULL CHECK(expected_program_state_version>=1), actor_id text NOT NULL, + reserved_at timestamptz NOT NULL, settled_at timestamptz, + UNIQUE(program_id,idempotency_key), UNIQUE(preview_id,attempt_number), + FOREIGN KEY(preview_id,program_id,project_id) REFERENCES billing_migration_repair_previews(id,program_id,project_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_repair_invalidations ( + id text PRIMARY KEY, execution_id text NOT NULL, program_id text NOT NULL, project_id text NOT NULL, + invalidation_kind text NOT NULL CHECK(invalidation_kind IN ('dry_run','shadow','readiness','checkpoint','approval')), + invalidated_reference_id text NOT NULL CHECK(btrim(invalidated_reference_id)<>''), + invalidation_digest bytea NOT NULL CHECK(octet_length(invalidation_digest)=32), invalidated_at timestamptz NOT NULL, + UNIQUE(execution_id,invalidation_kind,invalidated_reference_id), + FOREIGN KEY(execution_id) REFERENCES billing_migration_repair_executions(id) ON DELETE RESTRICT, + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_credential_removals ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, credential_id text NOT NULL, + idempotency_key text NOT NULL CHECK(btrim(idempotency_key)<>''), expected_state_version bigint NOT NULL CHECK(expected_state_version>=1), + reason text NOT NULL CHECK(btrim(reason)<>'' AND length(reason)<=500 AND reason !~ '[[:cntrl:]]'), + early_removal boolean NOT NULL, irreversible_acknowledged boolean NOT NULL CHECK(irreversible_acknowledged), + actor_id text NOT NULL, envelope_digest bytea NOT NULL CHECK(octet_length(envelope_digest)=32), + removal_digest bytea NOT NULL CHECK(octet_length(removal_digest)=32), removed_at timestamptz NOT NULL, + UNIQUE(program_id,idempotency_key), UNIQUE(credential_id), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(credential_id,project_id) REFERENCES billing_migration_credentials(id,project_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_legal_hold_proposals ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, + command text NOT NULL CHECK(command IN ('set','release')), + reason text NOT NULL CHECK(btrim(reason)<>'' AND length(reason)<=500 AND reason !~ '[[:cntrl:]]'), + external_compliance_reference text NOT NULL CHECK(btrim(external_compliance_reference)<>'' AND length(external_compliance_reference)<=256), + proposer_actor_id text NOT NULL, expected_previous_command_digest bytea, + proposal_digest bytea NOT NULL CHECK(octet_length(proposal_digest)=32), idempotency_key text NOT NULL, + request_digest bytea NOT NULL CHECK(octet_length(request_digest)=32), status text NOT NULL CHECK(status IN('pending','approved','expired','invalidated')), + proposed_at timestamptz NOT NULL, expires_at timestamptz NOT NULL CHECK(expires_at>proposed_at), + UNIQUE(program_id,idempotency_key), UNIQUE(id,program_id,project_id), UNIQUE(program_id,proposal_digest), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + CHECK(expected_previous_command_digest IS NULL OR octet_length(expected_previous_command_digest)=32) +); +CREATE TABLE billing_migration_legal_hold_commands ( + id text PRIMARY KEY, program_id text NOT NULL, project_id text NOT NULL, + proposal_id text NOT NULL, + command text NOT NULL CHECK(command IN ('set','release')), + reason text NOT NULL CHECK(btrim(reason)<>'' AND length(reason)<=500 AND reason !~ '[[:cntrl:]]'), + external_compliance_reference text NOT NULL CHECK(btrim(external_compliance_reference)<>'' AND length(external_compliance_reference)<=256), + proposer_actor_id text NOT NULL, approver_actor_id text NOT NULL, production boolean NOT NULL, + previous_command_id text, command_digest bytea NOT NULL CHECK(octet_length(command_digest)=32), commanded_at timestamptz NOT NULL, + UNIQUE(id,program_id,project_id), + UNIQUE(proposal_id), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(proposal_id,program_id,project_id) REFERENCES billing_migration_legal_hold_proposals(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY(previous_command_id,program_id,project_id) REFERENCES billing_migration_legal_hold_commands(id,program_id,project_id) ON DELETE RESTRICT, + CHECK((production AND proposer_actor_id<>approver_actor_id) OR (NOT production AND proposer_actor_id=approver_actor_id)) +); +CREATE UNIQUE INDEX billing_migration_legal_hold_one_root + ON billing_migration_legal_hold_commands(program_id) WHERE previous_command_id IS NULL; +CREATE UNIQUE INDEX billing_migration_legal_hold_one_successor + ON billing_migration_legal_hold_commands(previous_command_id) WHERE previous_command_id IS NOT NULL; +-- +goose StatementBegin +CREATE FUNCTION enforce_billing_migration_legal_hold_proposal_update() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.id<>OLD.id OR NEW.program_id<>OLD.program_id OR NEW.project_id<>OLD.project_id OR NEW.command<>OLD.command + OR NEW.reason<>OLD.reason OR NEW.external_compliance_reference<>OLD.external_compliance_reference + OR NEW.proposer_actor_id<>OLD.proposer_actor_id OR NEW.expected_previous_command_digest IS DISTINCT FROM OLD.expected_previous_command_digest + OR NEW.proposal_digest<>OLD.proposal_digest OR NEW.idempotency_key<>OLD.idempotency_key OR NEW.request_digest<>OLD.request_digest + OR NEW.proposed_at<>OLD.proposed_at OR NEW.expires_at<>OLD.expires_at + OR NOT (OLD.status='pending' AND NEW.status IN('approved','expired','invalidated')) THEN + RAISE EXCEPTION 'billing migration legal hold proposal consent is immutable' USING ERRCODE='55000'; + END IF; + RETURN NEW; +END $$; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE FUNCTION enforce_billing_migration_legal_hold_environment() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + environment_mode text; + proposal billing_migration_legal_hold_proposals%ROWTYPE; +BEGIN + SELECT e.mode INTO STRICT environment_mode + FROM billing_migration_programs mp + JOIN environments e ON e.id=mp.environment_id AND e.project_id=mp.project_id + WHERE mp.id=NEW.program_id AND mp.project_id=NEW.project_id; + + SELECT * INTO STRICT proposal + FROM billing_migration_legal_hold_proposals + WHERE id=NEW.proposal_id AND program_id=NEW.program_id AND project_id=NEW.project_id; + + IF NEW.production IS DISTINCT FROM (environment_mode='production') THEN + RAISE EXCEPTION 'billing migration legal hold production flag does not match environment' USING ERRCODE='23514'; + END IF; + IF (environment_mode='production' AND NEW.proposer_actor_id=NEW.approver_actor_id) + OR (environment_mode<>'production' AND NEW.proposer_actor_id<>NEW.approver_actor_id) THEN + RAISE EXCEPTION 'billing migration legal hold approval actors do not match environment policy' USING ERRCODE='23514'; + END IF; + IF proposal.status<>'pending' OR NEW.commanded_at>proposal.expires_at + OR NEW.command<>proposal.command OR NEW.reason<>proposal.reason + OR NEW.external_compliance_reference<>proposal.external_compliance_reference + OR NEW.proposer_actor_id<>proposal.proposer_actor_id THEN + RAISE EXCEPTION 'billing migration legal hold command does not match pending proposal consent' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$; +-- +goose StatementEnd + +ALTER TABLE billing_migration_completion_reports + ADD COLUMN authority_digest bytea, + ADD COLUMN stability_evidence_digest bytea, + ADD COLUMN completion_policy_digest bytea; +-- +goose StatementBegin +DO $$ DECLARE constraint_name text; BEGIN + SELECT c.conname INTO constraint_name FROM pg_constraint c + WHERE c.conrelid='billing_migration_completion_reports'::regclass + AND c.contype='c' AND pg_get_constraintdef(c.oid) LIKE '%credential_removed_at >= rollback_window_ended_at%'; + IF constraint_name IS NOT NULL THEN EXECUTE format('ALTER TABLE billing_migration_completion_reports DROP CONSTRAINT %I',constraint_name); END IF; +END $$; +-- +goose StatementEnd +UPDATE billing_migration_completion_reports r SET + authority_digest=sha256(r.completion_digest||convert_to('authority','UTF8')), + stability_evidence_digest=sha256(r.completion_digest||convert_to('stability','UTF8')), + completion_policy_digest=sha256(r.completion_digest||convert_to('policy','UTF8')); +ALTER TABLE billing_migration_completion_reports + ALTER COLUMN authority_digest SET NOT NULL, + ALTER COLUMN stability_evidence_digest SET NOT NULL, + ALTER COLUMN completion_policy_digest SET NOT NULL, + ADD CONSTRAINT billing_migration_completion_authority_digest CHECK(octet_length(authority_digest)=32), + ADD CONSTRAINT billing_migration_completion_stability_digest CHECK(octet_length(stability_evidence_digest)=32), + ADD CONSTRAINT billing_migration_completion_policy_digest CHECK(octet_length(completion_policy_digest)=32); +ALTER TABLE billing_migration_completion_reports + ADD CONSTRAINT billing_migration_completion_credential_removed_before_completion CHECK(credential_removed_at<=completed_at); + +ALTER TABLE billing_migration_retention_jobs + ADD COLUMN completion_report_id text, + ADD COLUMN attempt_count integer NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 16), + ADD COLUMN max_attempts integer NOT NULL DEFAULT 8 CHECK(max_attempts BETWEEN 1 AND 16), + ADD COLUMN last_error_code text, + ADD COLUMN deletion_identity bytea; +UPDATE billing_migration_retention_jobs j SET + completion_report_id=(SELECT id FROM billing_migration_completion_reports WHERE program_id=j.program_id), + deletion_identity=sha256(convert_to(j.program_id||chr(31)||j.id,'UTF8')); +ALTER TABLE billing_migration_retention_jobs + ALTER COLUMN completion_report_id SET NOT NULL, + ALTER COLUMN deletion_identity SET NOT NULL, + ADD CONSTRAINT billing_migration_retention_completion_fk FOREIGN KEY(completion_report_id) + REFERENCES billing_migration_completion_reports(id) ON DELETE RESTRICT, + ADD CONSTRAINT billing_migration_retention_identity_digest CHECK(octet_length(deletion_identity)=32), + ADD CONSTRAINT billing_migration_retention_due CHECK(due_at>=created_at), + ADD CONSTRAINT billing_migration_retention_program_unique UNIQUE(program_id), + ADD CONSTRAINT billing_migration_retention_error_shape CHECK( + (status='failed' AND btrim(last_error_code)<>'') OR status<>'failed'); +ALTER TABLE billing_migration_retention_jobs ADD COLUMN original_due_at timestamptz; +UPDATE billing_migration_retention_jobs SET original_due_at=due_at; +ALTER TABLE billing_migration_retention_jobs ALTER COLUMN original_due_at SET NOT NULL; + +ALTER TABLE billing_migration_object_deletions + DROP CONSTRAINT billing_migration_object_deletions_deletion_result_check, + ADD COLUMN retention_job_id text, + ADD COLUMN attempt_number integer, + ADD CONSTRAINT billing_migration_object_deletions_deletion_result_check CHECK(deletion_result IN ('deleted','not_found','retryable_failure','legal_hold')); +UPDATE billing_migration_object_deletions d SET + retention_job_id=(SELECT id FROM billing_migration_retention_jobs WHERE program_id=d.program_id), + attempt_number=1; +ALTER TABLE billing_migration_object_deletions + ALTER COLUMN retention_job_id SET NOT NULL, + ALTER COLUMN attempt_number SET NOT NULL, + ADD CONSTRAINT billing_migration_object_deletions_job_fk FOREIGN KEY(retention_job_id) + REFERENCES billing_migration_retention_jobs(id) ON DELETE RESTRICT, + ADD CONSTRAINT billing_migration_object_deletions_attempt CHECK(attempt_number BETWEEN 1 AND 16), + ADD CONSTRAINT billing_migration_object_deletions_identity_unique UNIQUE(retention_job_id,object_key_digest,attempt_number); + +CREATE TRIGGER billing_migration_repair_invalidations_immutable BEFORE UPDATE OR DELETE ON billing_migration_repair_invalidations FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_credential_removals_immutable BEFORE UPDATE OR DELETE ON billing_migration_credential_removals FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_legal_holds_immutable BEFORE UPDATE OR DELETE ON billing_migration_legal_hold_commands FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_legal_hold_proposals_immutable_delete BEFORE DELETE ON billing_migration_legal_hold_proposals FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_legal_hold_proposals_update BEFORE UPDATE ON billing_migration_legal_hold_proposals FOR EACH ROW EXECUTE FUNCTION enforce_billing_migration_legal_hold_proposal_update(); +CREATE TRIGGER billing_migration_legal_hold_environment BEFORE INSERT OR UPDATE ON billing_migration_legal_hold_commands FOR EACH ROW EXECUTE FUNCTION enforce_billing_migration_legal_hold_environment(); + +-- +goose Down +-- +goose StatementBegin +DO $$ BEGIN + IF EXISTS(SELECT 1 FROM billing_migration_repair_invalidations) + OR EXISTS(SELECT 1 FROM billing_migration_credential_removals) + OR EXISTS(SELECT 1 FROM billing_migration_legal_hold_commands) + OR EXISTS(SELECT 1 FROM billing_migration_legal_hold_proposals) + OR EXISTS(SELECT 1 FROM billing_migration_completion_reports) + OR EXISTS(SELECT 1 FROM billing_migration_retention_jobs) + OR EXISTS(SELECT 1 FROM billing_migration_object_deletions) + OR EXISTS(SELECT 1 FROM billing_migration_repair_previews) + OR EXISTS(SELECT 1 FROM billing_migration_repair_executions) + OR EXISTS(SELECT 1 FROM billing_migration_repair_reservations) THEN + RAISE EXCEPTION 'cannot downgrade: Phase 9C repair, completion, or retention evidence exists' USING ERRCODE='55000'; + END IF; +END $$; +-- +goose StatementEnd +DROP TRIGGER billing_migration_legal_holds_immutable ON billing_migration_legal_hold_commands; +DROP TRIGGER billing_migration_legal_hold_environment ON billing_migration_legal_hold_commands; +DROP TRIGGER billing_migration_legal_hold_proposals_immutable_delete ON billing_migration_legal_hold_proposals; +DROP TRIGGER billing_migration_legal_hold_proposals_update ON billing_migration_legal_hold_proposals; +DROP TRIGGER billing_migration_credential_removals_immutable ON billing_migration_credential_removals; +DROP TRIGGER billing_migration_repair_invalidations_immutable ON billing_migration_repair_invalidations; +DROP TABLE billing_migration_legal_hold_commands; +DROP TABLE billing_migration_legal_hold_proposals; +DROP FUNCTION enforce_billing_migration_legal_hold_environment(); +DROP FUNCTION enforce_billing_migration_legal_hold_proposal_update(); +DROP TABLE billing_migration_credential_removals; +DROP TABLE billing_migration_repair_invalidations; +DROP TABLE billing_migration_repair_reservations; +ALTER TABLE billing_migration_object_deletions DROP CONSTRAINT billing_migration_object_deletions_identity_unique, DROP CONSTRAINT billing_migration_object_deletions_attempt, DROP CONSTRAINT billing_migration_object_deletions_job_fk, DROP COLUMN attempt_number, DROP COLUMN retention_job_id, DROP CONSTRAINT billing_migration_object_deletions_deletion_result_check, ADD CONSTRAINT billing_migration_object_deletions_deletion_result_check CHECK(deletion_result IN ('deleted','not_found','failed','legal_hold')); +ALTER TABLE billing_migration_retention_jobs DROP COLUMN original_due_at, DROP CONSTRAINT billing_migration_retention_error_shape, DROP CONSTRAINT billing_migration_retention_program_unique, DROP CONSTRAINT billing_migration_retention_due, DROP CONSTRAINT billing_migration_retention_identity_digest, DROP CONSTRAINT billing_migration_retention_completion_fk, DROP COLUMN deletion_identity, DROP COLUMN last_error_code, DROP COLUMN max_attempts, DROP COLUMN attempt_count, DROP COLUMN completion_report_id; +ALTER TABLE billing_migration_completion_reports DROP CONSTRAINT billing_migration_completion_credential_removed_before_completion, DROP CONSTRAINT billing_migration_completion_policy_digest, DROP CONSTRAINT billing_migration_completion_stability_digest, DROP CONSTRAINT billing_migration_completion_authority_digest, DROP COLUMN completion_policy_digest, DROP COLUMN stability_evidence_digest, DROP COLUMN authority_digest, ADD CHECK(credential_removed_at>=rollback_window_ended_at); +ALTER TABLE billing_migration_repair_executions DROP CONSTRAINT billing_migration_repair_executions_preview_attempt_unique, DROP CONSTRAINT billing_migration_repair_executions_error_shape, DROP CONSTRAINT billing_migration_repair_executions_after_digest, DROP CONSTRAINT billing_migration_repair_executions_before_digest, DROP CONSTRAINT billing_migration_repair_executions_request_digest, DROP CONSTRAINT billing_migration_repair_executions_attempt, DROP COLUMN error_code, DROP COLUMN actual_after_digest, DROP COLUMN actual_before_digest, DROP COLUMN request_digest, DROP COLUMN attempt_number; +ALTER TABLE billing_migration_repair_previews DROP CONSTRAINT billing_migration_repair_previews_expiry, DROP CONSTRAINT billing_migration_repair_previews_reason, DROP CONSTRAINT billing_migration_repair_previews_scope_digest, DROP CONSTRAINT billing_migration_repair_previews_policy_digest, DROP CONSTRAINT billing_migration_repair_previews_case_digest, DROP CONSTRAINT billing_migration_repair_previews_state_version, DROP COLUMN expires_at, DROP COLUMN reason, DROP COLUMN expected_scope_digest, DROP COLUMN expected_policy_digest, DROP COLUMN expected_case_digest, DROP COLUMN expected_program_state_version, DROP CONSTRAINT billing_migration_repair_previews_repair_kind_check, ADD CONSTRAINT billing_migration_repair_previews_repair_kind_check CHECK(repair_kind IN ('provider_revalidate','projection_replay','attach_proven_alias','replace_mapping_set','retry_quarantined_record')); +ALTER TABLE billing_migration_cases DROP CONSTRAINT billing_migration_cases_source_record_fk, DROP CONSTRAINT billing_migration_cases_divergence_fk, DROP CONSTRAINT billing_migration_cases_resolution_shape, DROP COLUMN linked_source_record_id, DROP COLUMN linked_divergence_id, DROP COLUMN updated_at; +ALTER TABLE billing_migration_divergences DROP CONSTRAINT billing_migration_divergences_scope_unique; diff --git a/apps/api/migrations/00058_phase_9c_source_pull.sql b/apps/api/migrations/00058_phase_9c_source_pull.sql new file mode 100644 index 00000000..81079029 --- /dev/null +++ b/apps/api/migrations/00058_phase_9c_source_pull.sql @@ -0,0 +1,147 @@ +-- Phase 9C Stage 2E Package D: durable RevenueCat v2 source pulls and normalized relationships. + +-- +goose Up +ALTER TABLE billing_migration_source_records DROP CONSTRAINT billing_migration_source_records_source_kind_check; +ALTER TABLE billing_migration_source_records ADD CONSTRAINT billing_migration_source_records_source_kind_check + CHECK (source_kind IN ('customer','alias','subscription','product','entitlement','transaction','transfer')); + +ALTER TABLE billing_migration_import_batch_records + ADD COLUMN reference_kind text; +-- RevenueCat v2 exposes an Apple transaction id or a Google order id here, +-- never a Google purchase token. Bearer-grade purchase tokens must remain in +-- encrypted Raw Inputs/source objects and must not enter this plaintext table. +UPDATE billing_migration_import_batch_records +SET reference_kind = CASE provider + WHEN 'app_store' THEN 'app_store_transaction_id' + WHEN 'google_play' THEN 'google_play_order_id' +END; +ALTER TABLE billing_migration_import_batch_records + ALTER COLUMN reference_kind SET NOT NULL, + ADD CONSTRAINT billing_migration_import_records_reference_kind_check + CHECK (reference_kind IN ('app_store_transaction_id','google_play_order_id')), + ADD CONSTRAINT billing_migration_import_records_reference_provider_check + CHECK ((provider='app_store' AND reference_kind='app_store_transaction_id') + OR (provider='google_play' AND reference_kind='google_play_order_id')); +ALTER TABLE billing_migration_import_batch_records + ADD COLUMN source_product_identifier text, + ADD COLUMN mosaic_product_id text, + ADD COLUMN expected_store_product_identifier text, + ADD CONSTRAINT billing_migration_import_records_product_mapping_fkey FOREIGN KEY(mosaic_product_id,project_id) REFERENCES products(id,project_id) ON DELETE RESTRICT, + ADD CONSTRAINT billing_migration_import_records_product_binding_check CHECK ((source_product_identifier IS NULL AND mosaic_product_id IS NULL AND expected_store_product_identifier IS NULL) OR (btrim(source_product_identifier)<>'' AND mosaic_product_id IS NOT NULL AND btrim(expected_store_product_identifier)<>'')); + +CREATE TABLE billing_migration_source_pull_jobs ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + intent text NOT NULL CHECK (intent IN ('snapshot','delta','final_delta')), + idempotency_key text NOT NULL CHECK (btrim(idempotency_key) <> '' AND length(idempotency_key) <= 128), + request_digest bytea NOT NULL CHECK (octet_length(request_digest) = 32), + expected_program_state_version bigint NOT NULL CHECK (expected_program_state_version >= 1), + starting_cursor text NOT NULL DEFAULT '', + starting_watermark text NOT NULL DEFAULT '', + starting_watermark_digest bytea CHECK (starting_watermark_digest IS NULL OR octet_length(starting_watermark_digest)=32), + predecessor_pull_job_id text, + mapping_set_id text NOT NULL, + status text NOT NULL CHECK (status IN ('pending','running','completed','failed')), + result_source_object_id text, + result_manifest_id text, + result_import_batch_id text, + result_final_delta_job_id text, + resume_cursor text NOT NULL DEFAULT '', + final_watermark text NOT NULL DEFAULT '', + evidence_digest bytea CHECK (evidence_digest IS NULL OR octet_length(evidence_digest) = 32), + record_count integer NOT NULL DEFAULT 0 CHECK (record_count >= 0), + current_access_count integer NOT NULL DEFAULT 0 CHECK (current_access_count BETWEEN 0 AND record_count), + import_record_count integer NOT NULL DEFAULT 0 CHECK (import_record_count BETWEEN 0 AND record_count), + due_at timestamptz NOT NULL, + lease_owner text, + lease_expires_at timestamptz, + lease_generation bigint NOT NULL DEFAULT 0 CHECK (lease_generation >= 0), + attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + max_attempts integer NOT NULL CHECK (max_attempts BETWEEN 1 AND 20), + last_error_code text CHECK (last_error_code IS NULL OR (btrim(last_error_code) <> '' AND length(last_error_code) <= 64 AND last_error_code !~ '[[:cntrl:]]')), + created_by_actor_id text NOT NULL, + started_at timestamptz, + completed_at timestamptz, + failed_at timestamptz, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + UNIQUE (id, program_id, project_id), + UNIQUE (program_id, idempotency_key), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (predecessor_pull_job_id, program_id, project_id) REFERENCES billing_migration_source_pull_jobs(id, program_id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (mapping_set_id, program_id, project_id) REFERENCES billing_migration_mapping_sets(id, program_id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (result_source_object_id, program_id, project_id) REFERENCES billing_migration_source_objects(id, program_id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (result_manifest_id, program_id, project_id) REFERENCES billing_migration_source_manifests(id, program_id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (result_import_batch_id, program_id, project_id) REFERENCES billing_migration_import_batches(id, program_id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (result_final_delta_job_id, program_id, project_id) REFERENCES billing_migration_final_delta_jobs(id, program_id, project_id) ON DELETE RESTRICT, + CHECK ((status = 'running') = (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)), + CHECK ((status = 'completed') = (result_source_object_id IS NOT NULL AND result_manifest_id IS NOT NULL AND result_import_batch_id IS NOT NULL AND evidence_digest IS NOT NULL)), + CHECK ((intent='snapshot' AND predecessor_pull_job_id IS NULL AND starting_cursor='' AND starting_watermark='' AND starting_watermark_digest IS NULL) OR (intent IN ('delta','final_delta') AND predecessor_pull_job_id IS NOT NULL AND octet_length(starting_cursor) BETWEEN 0 AND 512 AND starting_cursor !~ '[[:cntrl:]]' AND octet_length(starting_watermark) BETWEEN 1 AND 512 AND starting_watermark !~ '[[:cntrl:]]' AND octet_length(starting_watermark_digest)=32)), + CHECK ((status='pending' AND started_at IS NULL AND completed_at IS NULL AND failed_at IS NULL) OR (status='running' AND started_at IS NOT NULL AND completed_at IS NULL AND failed_at IS NULL) OR (status='completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL AND failed_at IS NULL) OR (status='failed' AND started_at IS NOT NULL AND completed_at IS NULL AND failed_at IS NOT NULL)) +); +CREATE INDEX billing_migration_source_pull_jobs_claim_idx ON billing_migration_source_pull_jobs(due_at,updated_at,id) + WHERE status='pending' OR status='running'; +CREATE UNIQUE INDEX billing_migration_source_pull_jobs_import_idx ON billing_migration_source_pull_jobs(result_import_batch_id) + WHERE result_import_batch_id IS NOT NULL; +CREATE UNIQUE INDEX billing_migration_source_pull_jobs_predecessor_idx ON billing_migration_source_pull_jobs(predecessor_pull_job_id) + WHERE predecessor_pull_job_id IS NOT NULL AND status <> 'failed'; + +CREATE TABLE billing_migration_source_record_relationships ( + source_record_id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + customer_source_identifier text, + product_source_identifier text, + entitlement_source_identifiers text[] NOT NULL DEFAULT '{}', + external_application_id text, + store text, + provider_environment text, + store_identifier text, + mosaic_product_id text, + ownership jsonb, + ownership_digest bytea CHECK (ownership_digest IS NULL OR octet_length(ownership_digest)=32), + quarantine_reason text CHECK (quarantine_reason IS NULL OR quarantine_reason IN ('unsupported_store','unsupported_environment','environment_mismatch','ambiguous_application','missing_application_binding','missing_provider_reference')), + relationship_digest bytea NOT NULL CHECK (octet_length(relationship_digest)=32), + created_at timestamptz NOT NULL, + FOREIGN KEY (source_record_id,program_id,project_id) REFERENCES billing_migration_source_records(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY (program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY (mosaic_product_id,project_id) REFERENCES products(id,project_id) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_source_record_relationships_customer_idx ON billing_migration_source_record_relationships(program_id,customer_source_identifier,source_record_id); +CREATE INDEX billing_migration_source_record_relationships_product_idx ON billing_migration_source_record_relationships(program_id,product_source_identifier,source_record_id); + +CREATE TRIGGER billing_migration_source_record_relationships_immutable BEFORE UPDATE OR DELETE ON billing_migration_source_record_relationships +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM billing_migration_source_pull_jobs) + OR EXISTS (SELECT 1 FROM billing_migration_source_record_relationships) + OR EXISTS (SELECT 1 FROM billing_migration_source_records WHERE source_kind IN ('product','entitlement')) + OR EXISTS (SELECT 1 FROM billing_migration_import_batch_records) THEN + RAISE EXCEPTION 'cannot rollback migration 00058: durable source-pull evidence exists' USING ERRCODE='55000'; + END IF; +END; +$$; +-- +goose StatementEnd +DROP TRIGGER billing_migration_source_record_relationships_immutable ON billing_migration_source_record_relationships; +DROP TABLE billing_migration_source_record_relationships; +DROP INDEX billing_migration_source_pull_jobs_import_idx; +DROP INDEX billing_migration_source_pull_jobs_predecessor_idx; +DROP INDEX billing_migration_source_pull_jobs_claim_idx; +DROP TABLE billing_migration_source_pull_jobs; +ALTER TABLE billing_migration_import_batch_records + DROP CONSTRAINT billing_migration_import_records_product_mapping_fkey, + DROP CONSTRAINT billing_migration_import_records_product_binding_check, + DROP CONSTRAINT billing_migration_import_records_reference_provider_check, + DROP CONSTRAINT billing_migration_import_records_reference_kind_check, + DROP COLUMN reference_kind, + DROP COLUMN source_product_identifier, + DROP COLUMN mosaic_product_id, + DROP COLUMN expected_store_product_identifier; +ALTER TABLE billing_migration_source_records DROP CONSTRAINT billing_migration_source_records_source_kind_check; +ALTER TABLE billing_migration_source_records ADD CONSTRAINT billing_migration_source_records_source_kind_check + CHECK (source_kind IN ('customer','alias','subscription','transaction','transfer')); diff --git a/apps/api/migrations/00059_phase_9c_migration_validation_binding.sql b/apps/api/migrations/00059_phase_9c_migration_validation_binding.sql new file mode 100644 index 00000000..fbf73fbe --- /dev/null +++ b/apps/api/migrations/00059_phase_9c_migration_validation_binding.sql @@ -0,0 +1,98 @@ +-- Phase 9C Package E: bind known provider-reference validation to the exact +-- Application and frozen Product mapping selected by the migration program. + +-- +goose Up +ALTER TABLE billing_raw_inputs DROP CONSTRAINT billing_raw_inputs_source_check; +ALTER TABLE billing_raw_inputs ADD CONSTRAINT billing_raw_inputs_source_check CHECK (source IN ( + 'apple_notification', 'apple_notification_history', 'apple_transaction_history', + 'google_rtdn', 'google_token_requery', 'client_observation', + 'trusted_server_observation', 'migration_known_reference' +)); +ALTER TABLE billing_migration_import_batch_records + ADD COLUMN expected_store_environment text NOT NULL + CHECK (expected_store_environment IN ('production','sandbox')); + +CREATE TABLE billing_migration_validation_bindings ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + environment_id text NOT NULL, + raw_input_id text NOT NULL, + provider text NOT NULL CHECK (provider IN ('app_store','google_play')), + reference_kind text NOT NULL CHECK (reference_kind IN ( + 'app_store_transaction_id','google_play_purchase_token','google_play_order_id' + )), + reference_digest bytea NOT NULL CHECK (octet_length(reference_digest)=32), + expected_application_id text NOT NULL, + expected_store_product_identifier text NOT NULL CHECK (btrim(expected_store_product_identifier)<>''), + expected_store_environment text NOT NULL CHECK (expected_store_environment IN ('production','sandbox')), + expected_mosaic_product_id text NOT NULL, + status text NOT NULL CHECK (status IN ('accepted','validated','quarantined')), + diagnostic_code text CHECK (diagnostic_code IS NULL OR ( + btrim(diagnostic_code)<>'' AND length(diagnostic_code)<=128 AND diagnostic_code !~ '[[:cntrl:]]' + )), + validation_attempt_id text, + evidence_digest bytea CHECK (evidence_digest IS NULL OR octet_length(evidence_digest)=32), + provider_watermark timestamptz, + accepted_at timestamptz NOT NULL, + completed_at timestamptz, + UNIQUE (program_id, provider, reference_kind, reference_digest), + UNIQUE (raw_input_id), + UNIQUE (id, project_id), + FOREIGN KEY (program_id, project_id) REFERENCES billing_migration_programs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (raw_input_id, project_id) REFERENCES billing_raw_inputs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (environment_id, project_id) REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (expected_application_id, project_id) REFERENCES applications(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (expected_mosaic_product_id, project_id) REFERENCES products(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (validation_attempt_id, project_id) REFERENCES billing_validation_attempts(id, project_id) ON DELETE RESTRICT, + CHECK ((status='accepted' AND diagnostic_code IS NULL AND validation_attempt_id IS NULL AND evidence_digest IS NULL AND provider_watermark IS NULL AND completed_at IS NULL) + OR (status IN ('validated','quarantined') AND validation_attempt_id IS NOT NULL AND evidence_digest IS NOT NULL AND provider_watermark IS NOT NULL AND completed_at IS NOT NULL)) +); +CREATE INDEX billing_migration_validation_bindings_outcome_idx + ON billing_migration_validation_bindings(program_id,status,id); + +-- Only the validation-attempt transaction may complete a binding. Identity, +-- scope, and frozen expectations remain immutable for the lifetime of the row. +-- +goose StatementBegin +CREATE FUNCTION protect_billing_migration_validation_binding() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.status <> 'accepted' OR NEW.status NOT IN ('validated','quarantined') OR + NEW.id IS DISTINCT FROM OLD.id OR NEW.program_id IS DISTINCT FROM OLD.program_id OR + NEW.project_id IS DISTINCT FROM OLD.project_id OR NEW.environment_id IS DISTINCT FROM OLD.environment_id OR + NEW.raw_input_id IS DISTINCT FROM OLD.raw_input_id OR NEW.provider IS DISTINCT FROM OLD.provider OR + NEW.reference_kind IS DISTINCT FROM OLD.reference_kind OR NEW.reference_digest IS DISTINCT FROM OLD.reference_digest OR + NEW.expected_application_id IS DISTINCT FROM OLD.expected_application_id OR + NEW.expected_store_product_identifier IS DISTINCT FROM OLD.expected_store_product_identifier OR + NEW.expected_store_environment IS DISTINCT FROM OLD.expected_store_environment OR + NEW.expected_mosaic_product_id IS DISTINCT FROM OLD.expected_mosaic_product_id OR + NEW.accepted_at IS DISTINCT FROM OLD.accepted_at THEN + RAISE EXCEPTION 'migration validation binding is immutable outside terminal completion' USING ERRCODE='55000'; + END IF; + RETURN NEW; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER billing_migration_validation_bindings_protected +BEFORE UPDATE OR DELETE ON billing_migration_validation_bindings +FOR EACH ROW EXECUTE FUNCTION protect_billing_migration_validation_binding(); + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS(SELECT 1 FROM billing_migration_validation_bindings) + OR EXISTS(SELECT 1 FROM billing_migration_import_batch_records) THEN + RAISE EXCEPTION 'migration 00059 rollback refused: immutable migration validation evidence exists' USING ERRCODE='55000'; + END IF; +END; +$$; +-- +goose StatementEnd +DROP TRIGGER billing_migration_validation_bindings_protected ON billing_migration_validation_bindings; +DROP FUNCTION protect_billing_migration_validation_binding; +DROP TABLE billing_migration_validation_bindings; +ALTER TABLE billing_migration_import_batch_records DROP COLUMN expected_store_environment; +ALTER TABLE billing_raw_inputs DROP CONSTRAINT billing_raw_inputs_source_check; +ALTER TABLE billing_raw_inputs ADD CONSTRAINT billing_raw_inputs_source_check CHECK (source IN ( + 'apple_notification', 'apple_notification_history', 'apple_transaction_history', + 'google_rtdn', 'google_token_requery', 'client_observation', 'trusted_server_observation' +)); diff --git a/apps/api/migrations/00060_phase_9c_candidate_evaluation.sql b/apps/api/migrations/00060_phase_9c_candidate_evaluation.sql new file mode 100644 index 00000000..78a968d4 --- /dev/null +++ b/apps/api/migrations/00060_phase_9c_candidate_evaluation.sql @@ -0,0 +1,93 @@ +-- Phase 9C Package F: immutable migration-owned projection candidates. +-- Candidate snapshots are ordinary append-only snapshots so existing cutover +-- pointer FKs can name them, but these bindings prove that they were computed +-- from one frozen program epoch and never make them live. + +-- +goose Up +ALTER TABLE billing_migration_divergences DROP CONSTRAINT billing_migration_divergences_reason_check; +ALTER TABLE billing_migration_divergences ADD CONSTRAINT billing_migration_divergences_reason_check CHECK (reason IN ( + 'source_grants_mosaic_denies','mosaic_grants_source_denies','identity_conflict','authority_scope_conflict','mapping_missing', + 'provider_validation_missing','watermark_stale','unsupported_application_version', + 'historical_mismatch','provider_timing_lag','normalization_difference' +)); + +CREATE TABLE billing_migration_candidate_evaluations ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + job_id text NOT NULL, + job_kind text NOT NULL CHECK (job_kind IN ('dry_run','shadow','final_delta')), + state_version bigint NOT NULL CHECK (state_version >= 1), + authority_epoch bigint NOT NULL CHECK (authority_epoch >= 0), + manifest_digest bytea NOT NULL CHECK (octet_length(manifest_digest)=32), + mapping_digest bytea NOT NULL CHECK (octet_length(mapping_digest)=32), + policy_digest bytea NOT NULL CHECK (octet_length(policy_digest)=32), + evidence_digest bytea NOT NULL CHECK (octet_length(evidence_digest)=32), + source_watermark timestamptz NOT NULL, + provider_watermark timestamptz NOT NULL, + shadow_watermark timestamptz NOT NULL, + cohort_digest bytea NOT NULL CHECK (octet_length(cohort_digest)=32), + evaluation_digest bytea NOT NULL CHECK (octet_length(evaluation_digest)=32), + evaluated_at timestamptz NOT NULL, + UNIQUE (program_id, job_id), + UNIQUE (id, program_id, project_id), + FOREIGN KEY (program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_candidate_snapshots ( + id text PRIMARY KEY, + evaluation_id text NOT NULL, + program_id text NOT NULL, + project_id text NOT NULL, + environment_id text NOT NULL, + application_id text NOT NULL, + platform text NOT NULL CHECK (platform IN ('ios','android')), + billing_customer_id text NOT NULL, + snapshot_id text NOT NULL, + source_evidence_digest bytea NOT NULL CHECK (octet_length(source_evidence_digest)=32), + candidate_digest bytea NOT NULL CHECK (octet_length(candidate_digest)=32), + comparison_digest bytea NOT NULL CHECK (octet_length(comparison_digest)=32), + source_current_access boolean NOT NULL, + mosaic_current_access boolean NOT NULL, + created_at timestamptz NOT NULL, + UNIQUE (evaluation_id,application_id,platform,billing_customer_id), + UNIQUE (snapshot_id), + FOREIGN KEY (evaluation_id,program_id,project_id) + REFERENCES billing_migration_candidate_evaluations(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY (program_id,application_id,platform) + REFERENCES billing_migration_program_scopes(program_id,application_id,platform) ON DELETE RESTRICT, + FOREIGN KEY (snapshot_id,project_id,environment_id,billing_customer_id) + REFERENCES customer_entitlement_snapshots(id,project_id,environment_id,billing_customer_id) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_candidate_snapshots_scope_idx + ON billing_migration_candidate_snapshots(program_id,application_id,platform,billing_customer_id); + +CREATE TRIGGER billing_migration_candidate_evaluations_immutable +BEFORE UPDATE OR DELETE ON billing_migration_candidate_evaluations +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_candidate_snapshots_immutable +BEFORE UPDATE OR DELETE ON billing_migration_candidate_snapshots +FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM billing_migration_candidate_evaluations) + OR EXISTS (SELECT 1 FROM billing_migration_candidate_snapshots) THEN + RAISE EXCEPTION 'immutable candidate evaluation evidence exists; migration 00060 cannot be reversed' + USING ERRCODE='55000'; + END IF; +END; +$$; +-- +goose StatementEnd +DROP TRIGGER billing_migration_candidate_snapshots_immutable ON billing_migration_candidate_snapshots; +DROP TRIGGER billing_migration_candidate_evaluations_immutable ON billing_migration_candidate_evaluations; +DROP TABLE billing_migration_candidate_snapshots; +DROP TABLE billing_migration_candidate_evaluations; +ALTER TABLE billing_migration_divergences DROP CONSTRAINT billing_migration_divergences_reason_check; +ALTER TABLE billing_migration_divergences ADD CONSTRAINT billing_migration_divergences_reason_check CHECK (reason IN ( + 'source_grants_mosaic_denies','identity_conflict','authority_scope_conflict','mapping_missing', + 'provider_validation_missing','watermark_stale','unsupported_application_version', + 'historical_mismatch','provider_timing_lag','normalization_difference' +)); diff --git a/apps/api/migrations/00061_phase_9c_stabilization_rollback_readiness.sql b/apps/api/migrations/00061_phase_9c_stabilization_rollback_readiness.sql new file mode 100644 index 00000000..edfbd76f --- /dev/null +++ b/apps/api/migrations/00061_phase_9c_stabilization_rollback_readiness.sql @@ -0,0 +1,167 @@ +-- Phase 9C Work Packages 17-18: frozen stabilization monitoring and immutable rollback-readiness checkpoints. +-- These records observe and attest the existing authority path; they never mutate authority or execute rollback. + +-- +goose Up +ALTER TABLE billing_migration_final_deltas + ADD CONSTRAINT billing_migration_final_deltas_scope_unique UNIQUE(id,program_id,project_id); +CREATE TABLE billing_migration_stabilization_policies ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + state_version bigint NOT NULL CHECK (state_version >= 1), + authority_mismatch_max bigint NOT NULL CHECK (authority_mismatch_max >= 0), + access_api_error_max bigint NOT NULL CHECK (access_api_error_max >= 0), + sdk_sync_failure_max bigint NOT NULL CHECK (sdk_sync_failure_max >= 0), + divergence_max bigint NOT NULL CHECK (divergence_max >= 0), + validation_backlog_max bigint NOT NULL CHECK (validation_backlog_max >= 0), + source_delta_lag_max_seconds bigint NOT NULL CHECK (source_delta_lag_max_seconds >= 1), + webhook_failure_max bigint NOT NULL CHECK (webhook_failure_max >= 0), + webhook_freshness_max_seconds bigint NOT NULL CHECK (webhook_freshness_max_seconds >= 1), + quarantine_max bigint NOT NULL CHECK (quarantine_max >= 0), + support_case_max bigint NOT NULL CHECK (support_case_max >= 0), + old_app_version_max bigint NOT NULL CHECK (old_app_version_max >= 0), + worker_unhealthy_max bigint NOT NULL CHECK (worker_unhealthy_max >= 0), + policy_digest bytea NOT NULL CHECK (octet_length(policy_digest)=32), + frozen_by_actor_id text NOT NULL, + frozen_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + UNIQUE (program_id), UNIQUE (program_id,policy_digest), UNIQUE(id,program_id,project_id), + FOREIGN KEY (program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT +); + +CREATE TABLE billing_migration_stabilization_observations ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + policy_id text NOT NULL, + state_version bigint NOT NULL CHECK (state_version >= 1), + authority_epoch bigint NOT NULL CHECK (authority_epoch >= 1), + authority_mismatches bigint NOT NULL CHECK (authority_mismatches >= 0), + access_api_errors bigint NOT NULL CHECK (access_api_errors >= 0), + sdk_sync_failures bigint NOT NULL CHECK (sdk_sync_failures >= 0), + divergences bigint NOT NULL CHECK (divergences >= 0), + validation_backlog bigint NOT NULL CHECK (validation_backlog >= 0), + source_delta_lag_seconds bigint NOT NULL CHECK (source_delta_lag_seconds >= 0), + webhook_failures bigint NOT NULL CHECK (webhook_failures >= 0), + webhook_age_seconds bigint NOT NULL CHECK (webhook_age_seconds >= 0), + quarantined_records bigint NOT NULL CHECK (quarantined_records >= 0), + support_cases bigint NOT NULL CHECK (support_cases >= 0), + old_app_versions bigint NOT NULL CHECK (old_app_versions >= 0), + unhealthy_workers bigint NOT NULL CHECK (unhealthy_workers >= 0), + source_watermark timestamptz NOT NULL, + webhook_last_success_at timestamptz NOT NULL, + breach_codes text[] NOT NULL, + healthy boolean NOT NULL, + evidence_digest bytea NOT NULL CHECK (octet_length(evidence_digest)=32), + observed_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + UNIQUE (program_id,evidence_digest), UNIQUE (id,program_id,project_id), + FOREIGN KEY (program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY (policy_id,program_id,project_id) REFERENCES billing_migration_stabilization_policies(id,program_id,project_id) ON DELETE RESTRICT, + CHECK (healthy = (cardinality(breach_codes)=0)), + CHECK (source_watermark <= observed_at AND webhook_last_success_at <= observed_at) +); +CREATE INDEX billing_migration_stabilization_observations_cursor_idx + ON billing_migration_stabilization_observations(program_id,observed_at DESC,id DESC); + +CREATE TABLE billing_migration_access_api_signal_windows ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + window_started_at timestamptz NOT NULL, + window_ended_at timestamptz NOT NULL, + request_count bigint NOT NULL CHECK (request_count >= 0), + error_count bigint NOT NULL CHECK (error_count >= 0 AND error_count <= request_count), + evidence_digest bytea NOT NULL CHECK (octet_length(evidence_digest)=32), + recorded_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + UNIQUE(program_id,evidence_digest), + FOREIGN KEY(program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + CHECK(window_ended_at > window_started_at AND window_ended_at <= recorded_at) +); +CREATE INDEX billing_migration_access_api_signals_cursor_idx ON billing_migration_access_api_signal_windows(program_id,window_ended_at DESC,id DESC); + +CREATE TABLE billing_migration_rollback_readiness_assessments ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + observation_id text NOT NULL, + state_version bigint NOT NULL CHECK (state_version >= 1), + source_support_available boolean NOT NULL, + source_healthy boolean NOT NULL, + source_health_digest bytea NOT NULL CHECK (octet_length(source_health_digest)=32), + source_current_access_digest bytea NOT NULL CHECK (octet_length(source_current_access_digest)=32), + source_current_access_at timestamptz NOT NULL, + latest_delta_id text NOT NULL, + latest_delta_digest bytea NOT NULL CHECK (octet_length(latest_delta_digest)=32), + customer_impact_count bigint NOT NULL CHECK (customer_impact_count >= 0), + customer_impact_digest bytea NOT NULL CHECK (octet_length(customer_impact_digest)=32), + application_compatible boolean NOT NULL, + application_compatibility_digest bytea NOT NULL CHECK (octet_length(application_compatibility_digest)=32), + limitations_blocking boolean NOT NULL, + limitation_report_digest bytea NOT NULL CHECK (octet_length(limitation_report_digest)=32), + audit_digest bytea NOT NULL CHECK (octet_length(audit_digest)=32), + stabilization_healthy boolean NOT NULL, + ready boolean NOT NULL, + readiness_digest bytea NOT NULL CHECK (octet_length(readiness_digest)=32), + assessed_by_actor_id text NOT NULL, + assessed_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + UNIQUE (program_id,readiness_digest), UNIQUE (id,program_id,project_id), UNIQUE(id,program_id,project_id,ready), + FOREIGN KEY (program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY (observation_id,program_id,project_id) REFERENCES billing_migration_stabilization_observations(id,program_id,project_id) ON DELETE RESTRICT, + FOREIGN KEY (latest_delta_id,program_id,project_id) REFERENCES billing_migration_final_deltas(id,program_id,project_id) ON DELETE RESTRICT, + CHECK (ready = (stabilization_healthy AND source_support_available AND source_healthy AND application_compatible AND NOT limitations_blocking)) +); +CREATE INDEX billing_migration_rollback_readiness_cursor_idx + ON billing_migration_rollback_readiness_assessments(program_id,assessed_at DESC,id DESC); + +CREATE TABLE billing_migration_rollback_readiness_checkpoints ( + id text PRIMARY KEY, + program_id text NOT NULL, + project_id text NOT NULL, + assessment_id text NOT NULL, + assessment_ready boolean NOT NULL CHECK (assessment_ready), + state_version bigint NOT NULL CHECK (state_version >= 1), + authority_epoch bigint NOT NULL CHECK (authority_epoch >= 1), + authority_digest bytea NOT NULL CHECK (octet_length(authority_digest)=32), + policy_digest bytea NOT NULL CHECK (octet_length(policy_digest)=32), + evidence_digest bytea NOT NULL CHECK (octet_length(evidence_digest)=32), + readiness_digest bytea NOT NULL CHECK (octet_length(readiness_digest)=32), + checkpoint_digest bytea NOT NULL CHECK (octet_length(checkpoint_digest)=32), + created_by_actor_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + UNIQUE (program_id,checkpoint_digest), UNIQUE (id,program_id,project_id), + FOREIGN KEY (program_id,project_id) REFERENCES billing_migration_programs(id,project_id) ON DELETE RESTRICT, + FOREIGN KEY (assessment_id,program_id,project_id,assessment_ready) REFERENCES billing_migration_rollback_readiness_assessments(id,program_id,project_id,ready) ON DELETE RESTRICT +); +CREATE INDEX billing_migration_rollback_checkpoints_cursor_idx + ON billing_migration_rollback_readiness_checkpoints(program_id,created_at DESC,id DESC); + +CREATE TRIGGER billing_migration_stabilization_policies_immutable BEFORE UPDATE OR DELETE ON billing_migration_stabilization_policies FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_stabilization_observations_immutable BEFORE UPDATE OR DELETE ON billing_migration_stabilization_observations FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_access_api_signals_immutable BEFORE UPDATE OR DELETE ON billing_migration_access_api_signal_windows FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_rollback_readiness_immutable BEFORE UPDATE OR DELETE ON billing_migration_rollback_readiness_assessments FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); +CREATE TRIGGER billing_migration_rollback_checkpoints_immutable BEFORE UPDATE OR DELETE ON billing_migration_rollback_readiness_checkpoints FOR EACH ROW EXECUTE FUNCTION reject_billing_migration_immutable_change(); + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM billing_migration_stabilization_policies) + OR EXISTS (SELECT 1 FROM billing_migration_stabilization_observations) + OR EXISTS (SELECT 1 FROM billing_migration_access_api_signal_windows) + OR EXISTS (SELECT 1 FROM billing_migration_rollback_readiness_assessments) + OR EXISTS (SELECT 1 FROM billing_migration_rollback_readiness_checkpoints) THEN + RAISE EXCEPTION 'cannot rollback migration 00061: immutable stabilization or rollback-readiness evidence exists' USING ERRCODE='55000'; + END IF; +END; +$$; +-- +goose StatementEnd +DROP TRIGGER billing_migration_rollback_checkpoints_immutable ON billing_migration_rollback_readiness_checkpoints; +DROP TRIGGER billing_migration_rollback_readiness_immutable ON billing_migration_rollback_readiness_assessments; +DROP TRIGGER billing_migration_stabilization_observations_immutable ON billing_migration_stabilization_observations; +DROP TRIGGER billing_migration_access_api_signals_immutable ON billing_migration_access_api_signal_windows; +DROP TRIGGER billing_migration_stabilization_policies_immutable ON billing_migration_stabilization_policies; +DROP TABLE billing_migration_rollback_readiness_checkpoints; +DROP TABLE billing_migration_rollback_readiness_assessments; +DROP TABLE billing_migration_access_api_signal_windows; +DROP TABLE billing_migration_stabilization_observations; +DROP TABLE billing_migration_stabilization_policies; +ALTER TABLE billing_migration_final_deltas DROP CONSTRAINT billing_migration_final_deltas_scope_unique; diff --git a/apps/dashboard/src/components/navigation/nav-main.tsx b/apps/dashboard/src/components/navigation/nav-main.tsx index 1655f9f0..8d574f1d 100644 --- a/apps/dashboard/src/components/navigation/nav-main.tsx +++ b/apps/dashboard/src/components/navigation/nav-main.tsx @@ -40,7 +40,11 @@ export function NavMain({ items, label }: NavMainProps) { subItem.to === pathname)} + defaultOpen={item.subItems.some( + (subItem) => + subItem.to === pathname || + (subItem.to ? pathname.startsWith(`${subItem.to}/`) : false), + )} className="group/collapsible" > diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 00000000..5b301d85 --- /dev/null +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,125 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "@phosphor-icons/react/dist/ssr/X" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/apps/dashboard/src/components/ui/select.tsx b/apps/dashboard/src/components/ui/select.tsx new file mode 100644 index 00000000..b5556da3 --- /dev/null +++ b/apps/dashboard/src/components/ui/select.tsx @@ -0,0 +1,180 @@ +"use client" + +import * as React from "react" +import { Select as SelectPrimitive } from "@base-ui/react/select" + +import { cn } from "@/lib/utils" +import { CaretUpDownIcon } from "@phosphor-icons/react/dist/ssr/CaretUpDown" +import { CheckIcon } from "@phosphor-icons/react/dist/ssr/Check" + +/** + * Base UI resolves the trigger's label from `items`, not from the rendered + * `SelectItem` children, so a caller whose option text differs from its value + * builds the list once and passes it to both. + */ +export interface SelectOption { + label: string + value: Value +} + +type SelectChangeDetails = Parameters< + NonNullable["onValueChange"]> +>[1] + +/** + * Base UI reports `null` only when an item whose own value is `null` is chosen, + * which is how it models a clearable select. Mosaic spells "nothing chosen" as + * an empty-string item instead, so the callback is narrowed here rather than at + * every call site. Introducing a null-valued item means widening this again. + */ +export type SelectRootProps = Omit< + SelectPrimitive.Root.Props, + "onValueChange" +> & { + onValueChange?: ( + value: Multiple extends true ? Value[] : Value, + eventDetails: SelectChangeDetails, + ) => void +} + +function Select({ + onValueChange, + ...props +}: SelectRootProps) { + return ( + ["onValueChange"]} + {...props} + /> + ) +} + +function SelectGroup({ ...props }: SelectPrimitive.Group.Props) { + return +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ) +} + +function SelectTrigger({ + className, + children, + size = "default", + ...props +}: SelectPrimitive.Trigger.Props & { size?: "default" | "sm" }) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + sideOffset = 4, + ...props +}: SelectPrimitive.Popup.Props & { + sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"] +}) { + return ( + + + + {children} + + + + ) +} + +function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { + return ( + + + {children} + + + + + + ) +} + +function SelectSeparator({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/apps/dashboard/src/features/analytics/components/analytics-filters.tsx b/apps/dashboard/src/features/analytics/components/analytics-filters.tsx index 7fb7b343..679f9d18 100644 --- a/apps/dashboard/src/features/analytics/components/analytics-filters.tsx +++ b/apps/dashboard/src/features/analytics/components/analytics-filters.tsx @@ -1,6 +1,28 @@ import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import type { AnalyticsFilters as Filters } from "../types/analytics" +const TIMEZONE_OPTIONS = [ + { label: "UTC", value: "UTC" }, + { label: "America/Los Angeles", value: "America/Los_Angeles" }, + { label: "America/New York", value: "America/New_York" }, + { label: "Europe/London", value: "Europe/London" }, + { label: "Africa/Lagos", value: "Africa/Lagos" }, + { label: "Asia/Tokyo", value: "Asia/Tokyo" }, +] + +const PLATFORM_OPTIONS = [ + { label: "All platforms", value: "" }, + { label: "iOS", value: "ios" }, + { label: "Android", value: "android" }, +] + interface Props { filters: Filters onChange: (filters: Filters) => void @@ -29,25 +51,36 @@ export function AnalyticsFilters({ filters, onChange }: Props) { > - - +
+ + +
+
+ + +
+ {/* The Environment is chosen once, in the sidebar switcher. */}
- Single-Environment reporting · event-count basis @@ -116,9 +91,9 @@ export function AnalyticsWorkspace({ : "text-muted-foreground hover:bg-muted hover:text-foreground", )} key={tab.surface} - params={{ environmentId, organizationId, projectId, surface: tab.surface }} + params={(prev) => ({ ...prev, surface: tab.surface })} search={filters} - to="/organizations/$organizationId/projects/$projectId/analytics/$environmentId/$surface" + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/analytics/$surface" > {tab.label} @@ -130,10 +105,10 @@ export function AnalyticsWorkspace({ filters={filters} onChange={(next) => void navigate({ - params: { environmentId, organizationId, projectId, surface }, + params: (prev) => ({ ...prev, surface }), replace: true, search: next, - to: "/organizations/$organizationId/projects/$projectId/analytics/$environmentId/$surface", + to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey/analytics/$surface", }) } /> diff --git a/apps/dashboard/src/features/analytics/components/data-privacy-panel.tsx b/apps/dashboard/src/features/analytics/components/data-privacy-panel.tsx index e1a9588b..307941f0 100644 --- a/apps/dashboard/src/features/analytics/components/data-privacy-panel.tsx +++ b/apps/dashboard/src/features/analytics/components/data-privacy-panel.tsx @@ -3,8 +3,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useState } from "react" import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Input } from "@/components/ui/input" -import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { WorkflowPanel } from "@/features/orgs/components/workspace-page" import type { AnalyticsAdapter } from "../api/analytics-adapter" import { analyticsKeys, jobQueryOptions, settingsQueryOptions } from "../queries/analytics-queries" import type { @@ -16,6 +23,16 @@ import type { } from "../types/analytics" import { AnalyticsQueryResult } from "./query-result" +const RETENTION_OPTIONS = [30, 60, 90, 180, 365, 730].map((days) => ({ + label: `${days} days${days === 180 ? " (default)" : ""}`, + value: String(days), +})) + +const IDENTITY_SCOPE_OPTIONS = [ + { label: "Application user", value: "application_user" }, + { label: "Installation", value: "installation" }, +] + export function DataPrivacyPanel({ adapter, role, @@ -88,21 +105,26 @@ function CollectionSettingsPanel({ - + + + + + {RETENTION_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +

Exact aggregates and privacy audit metadata are retained for 24 months. Retention changes apply to raw events received after the setting is saved. @@ -240,19 +262,25 @@ function IdentityOperationsPanel({ > {(field) => ( - + + + + + {IDENTITY_SCOPE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +

)} diff --git a/apps/dashboard/src/features/analytics/components/issues-panel.tsx b/apps/dashboard/src/features/analytics/components/issues-panel.tsx index 596c57a1..39ce98c3 100644 --- a/apps/dashboard/src/features/analytics/components/issues-panel.tsx +++ b/apps/dashboard/src/features/analytics/components/issues-panel.tsx @@ -63,10 +63,10 @@ export function IssuesPanel({ function IssueTableRow({ issue, scope }: { issue: IssueRow; scope: AnalyticsScope }) { const href = issue.kind === "provider" - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/catalog/providers?environmentId=${scope.environmentId}` + ? `/orgs/${scope.organizationId}/projects/${scope.projectId}/catalog/providers?environmentId=${scope.environmentId}` : issue.kind === "product" - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/catalog/products` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}/monetization/${scope.environmentId}/placements${issue.recoveryId ? `/${issue.recoveryId}` : ""}` + ? `/orgs/${scope.organizationId}/projects/${scope.projectId}/catalog/products` + : `/orgs/${scope.organizationId}/projects/${scope.projectId}/monetization/${scope.environmentId}/placements${issue.recoveryId ? `/${issue.recoveryId}` : ""}` return ( diff --git a/apps/dashboard/src/features/api-keys/components/api-keys-page.tsx b/apps/dashboard/src/features/api-keys/components/api-keys-page.tsx index ed570c5d..de27f32e 100644 --- a/apps/dashboard/src/features/api-keys/components/api-keys-page.tsx +++ b/apps/dashboard/src/features/api-keys/components/api-keys-page.tsx @@ -4,6 +4,13 @@ import { useNavigate } from "@tanstack/react-router" import { useState } from "react" import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Sheet, SheetContent, @@ -27,8 +34,8 @@ import { } from "@/features/api-keys/mutations/api-key-secret-cache" import { apiKeysQueryOptions } from "@/features/api-keys/queries/api-keys-query" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" import { applicationsQueryOptions } from "@/features/projects/queries/projects-query" import type { ApiKey, ApiKeySecretResult } from "@/generated/api" @@ -57,6 +64,17 @@ export function ApiKeysPage({ environmentId, organizationId, projectId }: ApiKey environments.data?.items.find((item) => item.id === environmentId) ?? environments.data?.items.find((item) => item.key === "development") ?? environments.data?.items[0] + const environmentOptions = (environments.data?.items ?? []).map((environment) => ({ + label: environment.name, + value: environment.id, + })) + const applicationOptions = [ + { label: "Select an Application", value: "" }, + ...(applications.data?.items ?? []).map((application) => ({ + label: `${application.name} · ${application.platform}`, + value: application.id, + })), + ] const keys = useQuery({ ...apiKeysQueryOptions(selectedEnvironment?.id ?? ""), enabled: scopeReady && Boolean(selectedEnvironment), @@ -129,28 +147,33 @@ export function ApiKeysPage({ environmentId, organizationId, projectId }: ApiKey title="API keys" > - + + + + + {environmentOptions.map((option) => ( + + {option.label} + + ))} + + +
{revealed ? : null} @@ -276,25 +299,29 @@ export function ApiKeysPage({ environmentId, organizationId, projectId }: ApiKey title="Create API key" >
- +
- + + + + + {applicationOptions.map((option) => ( + + {option.label} + + ))} + + +
- + + + + + {productOptions.map((option) => ( + + {option.label} + + ))} + + +
- + + + + + {resolutionOptions.map((option) => ( + + {option.label} + + ))} + + + - + + + + + {pageSizeOptions.map((option) => ( + + {option.label} + + ))} + + +
diff --git a/apps/dashboard/src/features/billing-ledger/components/transaction-ledger-page.tsx b/apps/dashboard/src/features/billing-ledger/components/transaction-ledger-page.tsx index 7164647d..e61f1970 100644 --- a/apps/dashboard/src/features/billing-ledger/components/transaction-ledger-page.tsx +++ b/apps/dashboard/src/features/billing-ledger/components/transaction-ledger-page.tsx @@ -21,8 +21,8 @@ import { import { billingHealthQueryOptions } from "@/features/billing-operations/queries/billing-health-queries" import { productsQueryOptions } from "@/features/catalog/queries/catalog-query" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" import { applicationsQueryOptions } from "@/features/projects/queries/projects-query" import { storeConnectionsHref } from "@/lib/routing/workspace-hrefs" @@ -93,7 +93,7 @@ export function TransactionLedgerPage({ } const connectionsHref = storeConnectionsHref({ organizationId, projectId }) ?? "#" - const base = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` + const base = `/orgs/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` return ( { + it("clears the secret after success and suppresses a double submission with one command key", async () => { + let resolve!: (value: BillingMigrationProgram) => void + const commandKeys: string[] = [] + const onCreate = vi.fn((command: { idempotencyKey: string }) => { + commandKeys.push(command.idempotencyKey) + return new Promise((done) => { + resolve = done + }) + }) + render( + , + ) + await fillRequiredFields() + fireEvent.click(screen.getByRole("button", { name: "Create and check source" })) + fireEvent.click(screen.getByRole("button", { name: "Create and check source" })) + await waitFor(() => expect(onCreate).toHaveBeenCalledOnce()) + expect(commandKeys).toHaveLength(1) + expect(commandKeys[0]).toBeTruthy() + resolve(program) + await waitFor(() => + expect(screen.getByLabelText("RevenueCat migration API key")).toHaveValue(""), + ) + }) + + it("clears the secret after failure and renders Mosaic-owned recovery copy", async () => { + render( + Promise.reject(new Error("raw provider stack"))} + onCreated={vi.fn()} + resetMutation={vi.fn()} + />, + ) + await fillRequiredFields() + fireEvent.click(screen.getByRole("button", { name: "Create and check source" })) + expect(await screen.findByRole("alert")).toHaveTextContent("Mosaic could not check this source") + expect(screen.getByRole("alert")).not.toHaveTextContent("raw provider stack") + expect(screen.getByLabelText("RevenueCat migration API key")).toHaveValue("") + }) +}) diff --git a/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.tsx b/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.tsx new file mode 100644 index 00000000..e1312477 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.tsx @@ -0,0 +1,212 @@ +import { useForm } from "@tanstack/react-form" +import { useRef, useState } from "react" + +import { Button } from "@/components/ui/button" +import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { createMigrationCommandKey } from "@/features/billing-migrations/mutations/migration-mutations" +import { migrationErrorCopy } from "@/features/billing-migrations/types/migration-operations" +import type { + Application, + BillingMigrationProgram, + CreateBillingMigrationProgramRequestWritable, + Environment, +} from "@/generated/api" + +interface Props { + applications: readonly Application[] + environments: readonly Environment[] + isPending: boolean + onCreate: (command: { + body: CreateBillingMigrationProgramRequestWritable + idempotencyKey: string + }) => Promise + onCreated: (program: BillingMigrationProgram) => void + resetMutation: () => void +} + +export function CreateMigrationProgramForm({ + applications, + environments, + isPending, + onCreate, + onCreated, + resetMutation, +}: Props) { + const environmentOptions = [ + { label: "Choose an Environment", value: "" }, + ...environments.map((item) => ({ label: item.name, value: item.id })), + ] + const submitting = useRef(false) + const [submitError, setSubmitError] = useState(null) + const form = useForm({ + defaultValues: { + applicationIds: [] as string[], + environmentId: "", + revenueCatApiKey: "", + revenueCatProjectId: "", + rollbackWindowDays: 7, + stabilizationDays: 7, + }, + onSubmit: async ({ value }) => { + if (submitting.current) return + submitting.current = true + setSubmitError(null) + try { + const program = await onCreate({ + body: { + applications: value.applicationIds.map((applicationId) => { + const application = applications.find((item) => item.id === applicationId) + if (!application) throw new Error("invalid_scope") + return { applicationId, platform: application.platform } + }), + environmentId: value.environmentId, + revenueCatApiKey: value.revenueCatApiKey, + revenueCatProjectId: value.revenueCatProjectId.trim(), + rollbackWindowDays: value.rollbackWindowDays, + stabilizationDays: value.stabilizationDays, + }, + idempotencyKey: createMigrationCommandKey(), + }) + form.reset() + onCreated(program) + } catch (error) { + setSubmitError(migrationErrorCopy(error, "create")) + } finally { + form.setFieldValue("revenueCatApiKey", "") + resetMutation() + submitting.current = false + } + }, + }) + + return ( +
{ + event.preventDefault() + void form.handleSubmit() + }} + > + (value ? undefined : "Choose an Environment.") }} + > + {(field) => ( + 0}> + Environment + + ({ message }))} /> + + )} + + (value.trim() ? undefined : "Enter the RevenueCat Project ID."), + }} + > + {(field) => ( + 0}> + RevenueCat Project ID + field.handleChange(event.target.value)} + value={field.state.value} + /> + ({ message }))} /> + + )} + + (value ? undefined : "Enter a least-privilege migration key."), + }} + > + {(field) => ( + 0}> + RevenueCat migration API key + field.handleChange(event.target.value)} + type="password" + value={field.state.value} + /> + Cleared after every submission and never read back. + ({ message }))} /> + + )} + + + value.length ? undefined : "Select at least one Application/platform scope.", + }} + > + {(field) => ( + 0}> + Application/platform scope + + Choose every Application and platform explicitly. Mosaic does not infer or wildcard + this scope. + +
+ {applications.map((application) => ( + + ))} +
+ ({ message }))} /> +
+ )} +
+ {submitError ? ( +

+ {submitError} +

+ ) : null} +
+ +
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.test.tsx b/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.test.tsx new file mode 100644 index 00000000..e2dda041 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { FreezeMappingAction } from "@/features/billing-migrations/components/freeze-mapping-action" +import type { BillingMigrationMappingSet } from "@/generated/api" + +function mapping(id: string, version: number, digest: string): BillingMigrationMappingSet { + return { + entries: [], + mappingDigest: digest, + mappingSetId: id, + programId: "program_1", + stateVersion: 1, + status: "draft", + version, + } +} + +describe("FreezeMappingAction", () => { + it("binds confirmation to the exact selected mapping ID, version, and digest", () => { + const onFreeze = vi.fn().mockResolvedValue(undefined) + render( + <> + {[mapping("mapping_1", 1, "sha256:one"), mapping("mapping_2", 2, "sha256:two")].map( + (item) => ( + + ), + )} + , + ) + expect(screen.getByRole("button", { name: "Freeze version 1" })).toBeDisabled() + fireEvent.click(screen.getByLabelText("Confirm freeze version 2")) + const button = screen.getByRole("button", { name: "Freeze version 2" }) + expect(button).toHaveAttribute("data-confirmation", "mapping_2:2:sha256:two") + fireEvent.click(button) + expect(onFreeze).toHaveBeenCalledOnce() + expect(onFreeze).toHaveBeenCalledWith("mapping_2") + }) +}) diff --git a/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.tsx b/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.tsx new file mode 100644 index 00000000..47c83f26 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.tsx @@ -0,0 +1,47 @@ +import { useState } from "react" + +import { Button } from "@/components/ui/button" +import type { BillingMigrationMappingSet } from "@/generated/api" + +export function FreezeMappingAction({ + isPending, + mapping, + onFreeze, +}: { + isPending: boolean + mapping: BillingMigrationMappingSet + onFreeze: (mappingSetId: string) => Promise +}) { + const [confirmed, setConfirmed] = useState(false) + const confirmation = `${mapping.mappingSetId}:${mapping.version}:${mapping.mappingDigest}` + return ( +
+

Freeze version {mapping.version}

+

+ Impact: mapping set {mapping.mappingSetId} with digest{" "} + {mapping.mappingDigest} becomes immutable and the Program + advances to Import. +

+ + +
+ ) +} diff --git a/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.test.tsx b/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.test.tsx new file mode 100644 index 00000000..0a234bdf --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.test.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { GuidedMappingForm } from "@/features/billing-migrations/components/guided-mapping-form" + +describe("GuidedMappingForm", () => { + it("validates structured rows and presents an exact pre-submit review", () => { + const onCreate = vi.fn().mockResolvedValue(undefined) + render() + expect(screen.getByRole("button", { name: "Review mapping set" })).toBeDisabled() + expect(screen.getByText("Enter the exact source identifier.")).toBeInTheDocument() + fireEvent.change(screen.getByRole("textbox", { name: /Source identifier/ }), { + target: { value: "source.product.pro" }, + }) + fireEvent.change(screen.getByRole("textbox", { name: /Mosaic target ID/ }), { + target: { value: "product_pro" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Review mapping set" })) + expect(screen.getByRole("heading", { name: "Review before creating" })).toBeInTheDocument() + expect(screen.getByText(/source\.product\.pro/)).toBeInTheDocument() + expect(screen.getByText(/product_pro/)).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Create reviewed draft" })) + expect(onCreate).toHaveBeenCalledWith([ + { + matchKind: "exact", + sourceIdentifier: "source.product.pro", + sourceKind: "product", + targetId: "product_pro", + }, + ]) + }) +}) diff --git a/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.tsx b/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.tsx new file mode 100644 index 00000000..d6613929 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.tsx @@ -0,0 +1,225 @@ +import { useMemo, useState } from "react" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import type { BillingMigrationMappingEntry } from "@/generated/api" + +type MappingEntry = BillingMigrationMappingEntry +const sourceKinds: MappingEntry["sourceKind"][] = [ + "customer_id", + "original_customer_id", + "audited_alias", + "product", + "entitlement", +] +const sourceKindOptions = sourceKinds.map((kind) => ({ + label: kind.replaceAll("_", " "), + value: kind, +})) + +const MATCH_KIND_OPTIONS = [ + { label: "Exact", value: "exact" }, + { label: "Audited alias", value: "audited_alias" }, +] +const emptyRow = (): MappingEntry => ({ + matchKind: "exact", + sourceIdentifier: "", + sourceKind: "product", + targetId: "", +}) + +interface Props { + isPending: boolean + onCreate: (entries: MappingEntry[]) => Promise +} + +export function GuidedMappingForm({ isPending, onCreate }: Props) { + const [entries, setEntries] = useState([emptyRow()]) + const [reviewing, setReviewing] = useState(false) + const errors = useMemo( + () => + entries.map((entry) => ({ + source: entry.sourceIdentifier.trim() ? "" : "Enter the exact source identifier.", + target: entry.targetId.trim() ? "" : "Enter the Mosaic target ID.", + })), + [entries], + ) + const valid = errors.every((entry) => !entry.source && !entry.target) + + function update(index: number, patch: Partial) { + setReviewing(false) + setEntries((current) => + current.map((entry, entryIndex) => (entryIndex === index ? { ...entry, ...patch } : entry)), + ) + } + + return ( +
+

+ Enter source identifiers from normalized, assessed source evidence only. Source-row browsing + and Mosaic target pickers are deferred until verified manifest ingestion and list APIs are + available; do not infer or transform identifiers here. +

+
+ {entries.map((entry, index) => ( +
+ Mapping {index + 1} +
+
+ Source kind + +
+ +
+ Match + +
+ +
+ {entries.length > 1 ? ( + + ) : null} +
+ ))} +
+
+ + +
+ {reviewing ? ( +
+

+ Review before creating +

+

+ This draft contains {entries.length} explicit mapping(s). Review every source and target + before creating an immutable version later. +

+
    + {entries.map((entry, index) => ( +
  • + {entry.sourceKind.replaceAll("_", " ")} {entry.sourceIdentifier} →{" "} + {entry.targetId} ({entry.matchKind.replaceAll("_", " ")}) +
  • + ))} +
+ +
+ ) : null} +
+ ) +} diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-command-safety.test.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-command-safety.test.tsx new file mode 100644 index 00000000..c1040f41 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/migration-command-safety.test.tsx @@ -0,0 +1,109 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { MigrationImpactReviewAction } from "@/features/billing-migrations/components/migration-impact-review-action" +import { useMigrationCommand } from "@/features/billing-migrations/hooks/use-migration-command" +import { ApiError } from "@/lib/api/errors" + +function StaleHarness({ onStale }: { onStale: () => Promise }) { + const command = useMigrationCommand(onStale) + return ( + <> + + {command.error ?

{command.error}

: null} + + ) +} + +describe("migration command components", () => { + it("keeps disabled prerequisite copy programmatically associated with the command", () => { + render( + , + ) + const button = screen.getByRole("button", { name: "Queue import" }) + expect(button).toBeDisabled() + expect(button).toHaveAccessibleDescription("Freeze a reviewed mapping set before importing.") + expect( + screen.getByText(/does not change billing authority or customer access/), + ).toBeInTheDocument() + }) + + it("requires confirmation for the exact reviewed command inputs", () => { + const onConfirm = vi.fn() + const { rerender } = render( + , + ) + + const button = screen.getByRole("button", { name: "Queue import" }) + expect(button).toBeDisabled() + expect(button).toHaveAttribute("data-impact-binding", "import:4:manifest_1:mapping_1:100") + fireEvent.click(screen.getByRole("checkbox", { name: "Confirm Queue import" })) + fireEvent.click(button) + expect(onConfirm).toHaveBeenCalledOnce() + + rerender( + , + ) + expect(screen.getByRole("button", { name: "Queue import" })).toBeDisabled() + expect(screen.getByRole("checkbox", { name: "Confirm Queue import" })).not.toBeChecked() + }) + + it("refetches stale state and shows safe review-before-retry copy", async () => { + const onStale = vi.fn().mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByRole("button", { name: "Freeze" })) + await waitFor(() => expect(onStale).toHaveBeenCalledOnce()) + expect(screen.getByRole("alert")).toHaveTextContent("Mosaic refreshed the latest state") + expect(screen.getByRole("alert")).not.toHaveTextContent("raw conflict") + }) +}) diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-impact-review-action.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-impact-review-action.tsx new file mode 100644 index 00000000..ac7513c6 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/migration-impact-review-action.tsx @@ -0,0 +1,80 @@ +import { useState } from "react" + +import { Button } from "@/components/ui/button" + +interface ReviewFact { + label: string + value: string +} + +interface Props { + actionLabel: string + binding: string + disabledReason: string | null + facts: readonly ReviewFact[] + impactSummary?: string + confirmationCopy?: string + isPending: boolean + onConfirm: () => void + pendingLabel: string + title: string + variant?: "default" | "outline" +} + +export function MigrationImpactReviewAction({ + actionLabel, + binding, + disabledReason, + facts, + impactSummary = "This queues durable migration evidence work. It does not change billing authority or customer access.", + confirmationCopy = "I reviewed these exact inputs and understand this queues evidence work only.", + isPending, + onConfirm, + pendingLabel, + title, + variant = "default", +}: Props) { + const [confirmedBinding, setConfirmedBinding] = useState(null) + const confirmed = confirmedBinding === binding + const explanationId = `${binding.replaceAll(/[^a-zA-Z0-9_-]/g, "-")}-explanation` + return ( +
+

{title}

+

Impact: {impactSummary}

+
+ {facts.map((fact) => ( +
+
{fact.label}
+
{fact.value}
+
+ ))} +
+ + + {disabledReason ? ( +

+ {disabledReason} +

+ ) : null} +
+ ) +} diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.test.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.test.tsx new file mode 100644 index 00000000..ae89fd0d --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { MigrationJourneyCockpit } from "@/features/billing-migrations/components/migration-journey-cockpit" +import type { BillingMigrationProgram } from "@/generated/api" + +function terminalProgram(state: "failed" | "cancelled"): BillingMigrationProgram { + return { + authorityEpochBefore: 0, + programId: `program_${state}`, + rollbackWindowDays: 7, + scope: { + applications: [{ applicationId: "app_1", platform: "ios" }], + environmentId: "env_1", + projectId: "project_1", + }, + source: { + adapter: "revenuecat", + adapterVersion: "v2", + credentialReference: "credential_1", + }, + stabilizationDays: 7, + state, + stateVersion: 4, + } +} + +describe("MigrationJourneyCockpit terminal recovery", () => { + it("shows failed recovery without falling back to Connect source", () => { + render( + , + ) + expect( + screen.getByRole("heading", { name: "Migration stopped after a failure" }), + ).toBeInTheDocument() + expect(screen.getByText(/evidence remain available/)).toBeInTheDocument() + expect(screen.queryByText("Connect source")).not.toBeInTheDocument() + }) + + it("shows cancelled recovery without implying the Program can resume", () => { + render( + , + ) + expect(screen.getByRole("heading", { name: "Migration cancelled" })).toBeInTheDocument() + expect(screen.getByText(/read-only and cannot resume/)).toBeInTheDocument() + expect(screen.queryByText("Connect source")).not.toBeInTheDocument() + }) +}) diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.tsx new file mode 100644 index 00000000..c0acc53a --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.tsx @@ -0,0 +1,99 @@ +import type { BillingMigrationProgram } from "@/generated/api" + +const steps = [ + { key: "connect", label: "Connect source", tab: "overview" }, + { key: "map", label: "Map", tab: "mappings" }, + { key: "import", label: "Import", tab: "imports" }, + { key: "compare", label: "Compare", tab: "compare" }, + { key: "readiness", label: "Readiness", tab: "readiness" }, + { key: "lifecycle", label: "Cutover & operations", tab: "lifecycle" }, +] as const + +function activeStep(state: BillingMigrationProgram["state"]) { + if (state === "mapping") return 1 + if (state === "importing") return 2 + if (state === "dry_run") return 3 + if (state === "shadowing") return 4 + if (["ready", "cutover_pending", "stabilizing", "completed", "rolled_back"].includes(state)) + return 5 + return 0 +} + +export function MigrationJourneyCockpit({ + baseHref, + program, +}: { + baseHref: string + program: BillingMigrationProgram +}) { + if (program.state === "failed" || program.state === "cancelled") { + const failed = program.state === "failed" + return ( +
+

+ Migration {failed ? "stopped after a failure" : "cancelled"} +

+

+ {failed + ? "The Program did not advance. Its source, mappings, and evidence remain available for inspection. Review the evidence and ask an Organization owner to decide whether to create a new Program." + : "This Program is read-only and cannot resume. Review its retained evidence, then ask an Organization owner to create a new Program when migration work should restart."} +

+ + Review retained evidence + +
+ ) + } + const active = activeStep(program.state) + const nextCopy = [ + "Confirm the connected source and explicit Application/platform scope.", + "Create and freeze an exact mapping set.", + "Import a bounded batch using the frozen mappings.", + "Run an isolated dry run, then compare the shadow view.", + "Create an evidence-derived readiness assessment.", + "Use two-person authority controls, stabilization evidence, rollback, and completion operations.", + ][active] + return ( +
+
+
+

+ Migration journey +

+

Next: {nextCopy}

+
+ + Program state {program.state.replaceAll("_", " ")} + +
+
    + {steps.map((step, index) => ( +
  1. + + + {index < active ? "Complete" : index === active ? "Current" : "Later"} + + {step.label} + +
  2. + ))} +
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-lifecycle-operations.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-lifecycle-operations.tsx new file mode 100644 index 00000000..df3a4931 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/migration-lifecycle-operations.tsx @@ -0,0 +1,826 @@ +import { useMutation, type QueryClient } from "@tanstack/react-query" +import { useState } from "react" + +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { MigrationImpactReviewAction } from "@/features/billing-migrations/components/migration-impact-review-action" +import { + createMigrationCommandKey, + migrationLifecycleMutationOptions, +} from "@/features/billing-migrations/mutations/migration-mutations" +import { + canRunMigrationCommand, + migrationCompletionBlockers, + type MigrationCommandCapability, + type MigrationProgramView, +} from "@/features/billing-migrations/types/migration-operations" +import type { + BillingMigrationApproval, + BillingMigrationAuthorityExecution, + BillingMigrationCase, + BillingMigrationCheckpoint, + BillingMigrationCompletionPrerequisites, + BillingMigrationCompletionReport, + BillingMigrationCredentialRemoval, + BillingMigrationDigestSet, + BillingMigrationLegalHold, + BillingMigrationLegalHoldProposal, + BillingMigrationProposal, + BillingMigrationRepairExecution, + BillingMigrationRepairPreview, + BillingMigrationRepairPreviewRequest, + BillingMigrationRollbackReadinessAssessment, + BillingMigrationStabilizationObservation, + BillingMigrationWebhookRedelivery, +} from "@/generated/api" + +type LifecycleRecord = + | BillingMigrationApproval + | BillingMigrationAuthorityExecution + | BillingMigrationCase + | BillingMigrationCheckpoint + | BillingMigrationCompletionReport + | BillingMigrationCredentialRemoval + | BillingMigrationLegalHold + | BillingMigrationLegalHoldProposal + | BillingMigrationProposal + | BillingMigrationRepairExecution + | BillingMigrationRepairPreview + | BillingMigrationRollbackReadinessAssessment + | BillingMigrationStabilizationObservation + | BillingMigrationWebhookRedelivery +type RepairKind = BillingMigrationRepairPreviewRequest["repairKind"] + +interface LifecycleData { + approvals: BillingMigrationApproval[] + cases: BillingMigrationCase[] + checkpoints: BillingMigrationCheckpoint[] + completion: BillingMigrationCompletionPrerequisites | undefined + executions: BillingMigrationAuthorityExecution[] + holdProposals: BillingMigrationLegalHoldProposal[] + holds: BillingMigrationLegalHold[] + observations: BillingMigrationStabilizationObservation[] + proposals: BillingMigrationProposal[] + removals: BillingMigrationCredentialRemoval[] + repairExecutions: BillingMigrationRepairExecution[] + repairPreviews: BillingMigrationRepairPreview[] + reports: BillingMigrationCompletionReport[] + rollbackAssessments: BillingMigrationRollbackReadinessAssessment[] + webhookRedeliveries: BillingMigrationWebhookRedelivery[] +} +type CommandName = + | "propose_cutover" + | "approve_cutover" + | "checkpoint" + | "execute_cutover" + | "observe_stabilization" + | "propose_rollback" + | "approve_rollback" + | "execute_rollback" + | "preview_repair" + | "execute_repair" + | "redeliver_webhook" + | "remove_credential" + | "propose_legal_hold" + | "approve_legal_hold" + | "complete" + +const commandCapability: Record = { + approve_cutover: "approve-cutover", + approve_legal_hold: "manage-legal-hold", + approve_rollback: "execute-rollback", + checkpoint: "execute-cutover", + complete: "complete-migration", + execute_cutover: "execute-cutover", + execute_repair: "execute-repair", + execute_rollback: "execute-rollback", + observe_stabilization: "execute-rollback", + preview_repair: "execute-repair", + propose_cutover: "propose-cutover", + propose_legal_hold: "manage-legal-hold", + propose_rollback: "execute-rollback", + redeliver_webhook: "execute-repair", + remove_credential: "remove-credential", +} + +const digestNames = [ + "scope", + "manifest", + "mapping", + "policy", + "evidence", + "readiness", + "finalWatermark", + "applicationVersion", +] as const + +const emptyDigests = Object.fromEntries( + digestNames.map((name) => [name, ""]), +) as BillingMigrationDigestSet + +const impact: Record = { + approve_cutover: + "Attests to another human's production proposal; it does not change authority by itself.", + approve_legal_hold: + "Applies an approved retention hold command and changes deletion eligibility.", + approve_rollback: + "Attests to another human's rollback proposal; execution creates a newer source-authority epoch.", + checkpoint: "Freezes the approved cohort and rollback baseline; stale evidence invalidates it.", + complete: "Closes stabilization only when the server confirms every completion prerequisite.", + execute_cutover: + "Atomically changes access authority from the source to Mosaic and increments the authority epoch.", + execute_repair: + "Executes only the allowlisted, bounded repair represented by the unexpired preview.", + execute_rollback: + "Creates a newer source-rollback authority epoch; it never deletes Mosaic evidence.", + observe_stabilization: + "Records server-derived health evidence; caller-supplied metrics are not accepted.", + preview_repair: + "Creates a bounded impact preview. It cannot edit facts, snapshots, or access directly.", + propose_cutover: + "Creates an expiring proposal bound to the exact evidence digests; authority is unchanged.", + propose_legal_hold: + "Proposes changing evidence retention; production requires a distinct approver.", + propose_rollback: + "Creates an expiring rollback proposal bound to checkpoint and prerequisite digests.", + redeliver_webhook: + "Redelivers one stored event to one stored destination without accepting payloads or secrets.", + remove_credential: + "Irreversibly destroys migration credential material and may make rollback unhealthy.", +} + +function repairKind(value: string): RepairKind | undefined { + const values: RepairKind[] = [ + "provider_revalidate", + "projection_replay", + "attach_proven_alias", + "replace_mapping_set", + "retry_quarantined_record", + ] + return values.find((candidate) => candidate === value) +} + +function requireRepairKind(value: string): RepairKind { + const parsed = repairKind(value) + if (!parsed) throw new Error("Select a supported repair kind.") + return parsed +} + +function timelineRecord(record: LifecycleRecord) { + if ("approvalId" in record) + return { id: record.approvalId, title: `${record.command} approval`, time: record.approvedAt } + if ("classification" in record) + return { + id: record.caseId, + reason: record.reason, + status: record.status, + title: `${record.classification} reconciliation case`, + time: record.updatedAt, + } + if ("checkpointId" in record) + return { id: record.checkpointId, title: "migration checkpoint", time: record.createdAt } + if ("executionId" in record && "command" in record) + return { + id: record.executionId, + status: record.state, + title: `${record.command} authority execution`, + time: record.executedAt, + } + if ("holdId" in record) + return { + id: record.holdId, + reason: record.reason, + title: `${record.command} legal hold`, + time: record.commandedAt, + } + if ("observationId" in record && "metrics" in record) + return { + id: record.observationId, + status: record.healthy ? "healthy" : "breached", + title: "stabilization observation", + time: record.observedAt, + } + if ("removalId" in record) + return { + id: record.removalId, + reason: record.reason, + title: "credential removal", + time: record.removedAt, + } + if ("previewId" in record && "repairKind" in record) + return { + id: record.previewId, + reason: record.reason, + title: `${record.repairKind.replaceAll("_", " ")} preview`, + time: record.createdAt, + } + if ("executionId" in record) + return { + id: record.executionId, + status: record.executionStatus === "completed" ? record.result : "pending", + title: "repair execution", + time: record.executionStatus === "completed" ? record.executedAt : record.reservedAt, + } + if ("reportId" in record) + return { id: record.reportId, title: "migration completion", time: record.completedAt } + if ("assessmentId" in record) + return { + id: record.assessmentId, + status: record.ready ? "ready" : "blocked", + title: "rollback readiness assessment", + time: record.assessedAt, + } + if ("redeliveryId" in record) + return { + id: record.redeliveryId, + reason: record.reason, + title: "webhook redelivery", + time: record.createdAt, + } + if ("externalComplianceReference" in record) + return { + id: record.proposalId, + reason: record.reason, + status: record.status, + title: `${record.command} legal hold proposal`, + time: record.proposedAt, + } + return { + id: record.proposalId, + reason: record.reason, + status: record.status, + title: `${record.command} proposal`, + time: record.proposedAt, + } +} + +function Timeline({ records }: { records: LifecycleRecord[] }) { + if (records.length === 0) return

No records yet.

+ return ( +
    + {records.map((record) => { + const item = timelineRecord(record) + return ( +
  • +
    +

    {item.title}

    +

    + {item.id} · {new Date(item.time).toLocaleString()} +

    + {item.reason ?

    {item.reason}

    : null} +
    + +
  • + ) + })} +
+ ) +} + +export function MigrationLifecycleOperations({ + data, + detail, + organizationRole, + projectId, + programId, + queryClient, +}: { + data: LifecycleData | undefined + detail: MigrationProgramView + organizationRole?: string + projectId: string + programId: string + queryClient: QueryClient +}) { + const program = detail.program + const mutation = useMutation(migrationLifecycleMutationOptions(projectId, programId, queryClient)) + const [name, setName] = useState("propose_cutover") + const [reason, setReason] = useState("") + const [referenceId, setReferenceId] = useState("") + const [secondaryId, setSecondaryId] = useState("") + const [expectedDigest, setExpectedDigest] = useState("") + const [authorityDigest, setAuthorityDigest] = useState("") + const [prerequisiteDigest, setPrerequisiteDigest] = useState("") + const [approvalDigest, setApprovalDigest] = useState("") + const [caseDigest, setCaseDigest] = useState("") + const [scopeKind, setScopeKind] = useState("case") + const [digests, setDigests] = useState(emptyDigests) + const [acknowledged, setAcknowledged] = useState(false) + const [expiresAt, setExpiresAt] = useState("") + const commandOptions = Object.keys(commandCapability).map((item) => ({ + label: item.replaceAll("_", " "), + value: item, + })) + const capability = commandCapability[name] + const completionBlockers = migrationCompletionBlockers(data?.completion) + const granted = canRunMigrationCommand(detail, capability) + const allDigestsPresent = digestNames.every((digestName) => digests[digestName].trim()) + const boundInputsPresent = (() => { + switch (name) { + case "propose_cutover": + return allDigestsPresent + case "approve_cutover": + case "approve_rollback": + return Boolean(referenceId) + case "checkpoint": + return Boolean(referenceId && expectedDigest && approvalDigest && allDigestsPresent) + case "execute_cutover": + return Boolean(referenceId && secondaryId && approvalDigest && allDigestsPresent) + case "observe_stabilization": + return Boolean(digests.policy) + case "propose_rollback": + return Boolean(referenceId && expectedDigest && authorityDigest && prerequisiteDigest) + case "execute_rollback": + return Boolean( + referenceId && + secondaryId && + expectedDigest && + authorityDigest && + prerequisiteDigest && + approvalDigest, + ) + case "preview_repair": + return Boolean( + referenceId && + secondaryId && + expectedDigest && + caseDigest && + digests.policy && + digests.scope, + ) + case "execute_repair": + return Boolean( + referenceId && expectedDigest && caseDigest && digests.policy && digests.scope, + ) + case "redeliver_webhook": + return Boolean(referenceId && secondaryId && expectedDigest) + case "remove_credential": + return true + case "propose_legal_hold": + return Boolean(referenceId) + case "approve_legal_hold": + return Boolean(referenceId && expectedDigest) + case "complete": + return Boolean(authorityDigest && digests.policy && expectedDigest) + } + })() + const needsReason = ![ + "approve_cutover", + "approve_rollback", + "checkpoint", + "execute_repair", + "approve_legal_hold", + "complete", + "observe_stabilization", + ].includes(name) + const disabledReason = !granted + ? `The server has not granted ${capability}. Organization role ${organizationRole ?? "unknown"} is explanatory only.` + : needsReason && reason.trim().length < 8 + ? "Enter a specific operational reason of at least 8 characters." + : !boundInputsPresent + ? "Enter every reference and expected digest required to bind this command to reviewed server state." + : name === "complete" && completionBlockers.length > 0 + ? `Completion is blocked: ${completionBlockers.join(", ")}` + : [ + "propose_cutover", + "propose_rollback", + "preview_repair", + "propose_legal_hold", + ].includes(name) && !expiresAt + ? "Enter the explicit approval or preview expiry time." + : name === "remove_credential" && !acknowledged + ? "Acknowledge that credential removal is irreversible and can prevent rollback." + : null + + async function submit() { + const expectedStateVersion = program.stateVersion + const expiration = expiresAt ? new Date(expiresAt).toISOString() : "" + const scope = { + applications: program.scope.applications, + environmentId: program.scope.environmentId, + } + const approvalCommand = { + body: { expectedStateVersion }, + kind: "approve_proposal" as const, + proposalId: referenceId, + } + const selectedRepairKind = + name === "preview_repair" ? requireRepairKind(secondaryId) : "provider_revalidate" + const command = (() => { + switch (name) { + case "propose_cutover": + return { + kind: name, + body: { + expectedDigests: digests, + expectedStateVersion, + expiresAt: expiration, + reason: reason.trim(), + }, + } + case "approve_cutover": + case "approve_rollback": + return approvalCommand + case "checkpoint": + return { + kind: "create_checkpoint" as const, + body: { + approvalDigest, + approvalId: referenceId, + cohortDigest: expectedDigest, + expectedDigests: digests, + expectedStateVersion, + }, + } + case "execute_cutover": + return { + kind: name, + body: { + approvalDigest, + approvalId: secondaryId, + checkpointId: referenceId, + expectedAuthorityEpoch: program.authorityEpochBefore, + expectedDigests: digests, + expectedStateVersion, + reason: reason.trim(), + scope, + }, + } + case "observe_stabilization": + return { + kind: name, + body: { + expectedAuthorityEpoch: program.authorityEpochAfter ?? program.authorityEpochBefore, + expectedPolicyDigest: digests.policy, + expectedStateVersion, + }, + } + case "propose_rollback": + return { + kind: name, + body: { + checkpointId: referenceId, + expectedAuthorityDigest: authorityDigest, + expectedCheckpointDigest: expectedDigest, + expectedRollbackPrerequisitesDigest: prerequisiteDigest, + expectedStateVersion, + expiresAt: expiration, + reason: reason.trim(), + }, + } + case "execute_rollback": + return { + kind: name, + body: { + approvalId: secondaryId, + checkpointId: referenceId, + expectedApprovalDigest: approvalDigest, + expectedAuthorityDigest: authorityDigest, + expectedAuthorityEpoch: program.authorityEpochAfter ?? program.authorityEpochBefore, + expectedCheckpointDigest: expectedDigest, + expectedRollbackPrerequisitesDigest: prerequisiteDigest, + expectedStateVersion, + reason: reason.trim(), + scope, + }, + } + case "preview_repair": + return { + kind: name, + body: { + caseId: referenceId, + expectedCaseDigest: caseDigest, + expectedPolicyDigest: digests.policy, + expectedScopeDigest: digests.scope, + expectedStateVersion, + expiresAt: expiration, + reason: reason.trim(), + repairKind: selectedRepairKind, + scopeKind, + scopeReferences: expectedDigest + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + }, + } + case "execute_repair": + return { + kind: name, + body: { + expectedCaseDigest: caseDigest, + expectedPolicyDigest: digests.policy, + expectedPreviewDigest: expectedDigest, + expectedScopeDigest: digests.scope, + expectedStateVersion, + previewId: referenceId, + }, + } + case "redeliver_webhook": + return { + kind: name, + body: { + destinationId: secondaryId, + eventId: referenceId, + expectedEventDigest: expectedDigest, + expectedStateVersion, + reason: reason.trim(), + }, + } + case "remove_credential": + return { + kind: name, + body: { + expectedStateVersion, + irreversibleAcknowledged: true as const, + reason: reason.trim(), + }, + } + case "propose_legal_hold": + return { + kind: name, + body: { + command: secondaryId === "release" ? ("release" as const) : ("set" as const), + expiresAt: expiration, + externalComplianceReference: referenceId, + reason: reason.trim(), + }, + } + case "approve_legal_hold": + return { + kind: name, + proposalId: referenceId, + body: { expectedProposalDigest: expectedDigest }, + } + case "complete": + return { + kind: name, + body: { + expectedAuthorityDigest: authorityDigest, + expectedPolicyDigest: digests.policy, + expectedStabilityEvidenceDigest: expectedDigest, + expectedStateVersion, + }, + } + } + })() + await mutation.mutateAsync({ command, idempotencyKey: createMigrationCommandKey() }) + setReason("") + setAcknowledged(false) + } + + const facts = [ + { label: "Program state", value: `${program.state} · version ${program.stateVersion}` }, + { + label: "Authority epoch", + value: String(program.authorityEpochAfter ?? program.authorityEpochBefore), + }, + { label: "Primary reference", value: referenceId || "Not supplied" }, + { label: "Expected digest", value: expectedDigest || "See bound digest fields" }, + { label: "Authority consequence", value: impact[name] }, + ] + + return ( +
+
+

Lifecycle command builder

+

+ Commands are compare-and-swap operations. A 409 refreshes Program and operational state + and requires a new review. Production proposal and approval must be performed by distinct + humans; a proposer cannot approve their own command. +

+
+
+ + +
+ + + + + + + + + {digestNames.map((digestName) => ( + + ))} + + +
+ {name === "remove_credential" ? ( + + ) : null} +
+ void submit()} + pendingLabel="Submitting command…" + title="Review dangerous command" + /> +
+ {mutation.isError ? ( +

+ The command failed. If state or evidence was stale, Mosaic refreshed it; review every + value before retrying. +

+ ) : null} +
+ +
+
+

Proposals, approvals, and checkpoints

+

+ Approval records remain separate from proposals so two-person production control is + visible. +

+ +
+
+

Authority executions

+

+ Cutover and rollback always create monotonic authority epochs. +

+ +
+
+

Stabilization and rollback readiness

+

+ The rollback window is {program.rollbackWindowDays} days. Completion stays blocked until + the window and server-derived health policy are satisfied. +

+ +
+
+

Reconciliation cases and evidence

+ +
+
+

Approved repair previews and executions

+ +
+
+

Webhook redelivery

+ +
+
+

Credential removal and legal hold

+

+ Only redacted metadata is returned. Secrets are never redisplayed. +

+ +
+
+

Completion reports and audit history

+ {data?.completion ? ( +
+ {completionBlockers.length ? ( + <> +

Completion blocked

+
    + {completionBlockers.map((blocker) => ( +
  • {blocker}
  • + ))} +
+ + ) : ( +

+ Server-derived completion prerequisites are satisfied. +

+ )} +
+ ) : ( +

+ Completion inspection is unavailable or has not produced a report. +

+ )} + +
+
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-program-detail-page.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-program-detail-page.tsx new file mode 100644 index 00000000..38405e34 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/migration-program-detail-page.tsx @@ -0,0 +1,768 @@ +import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query" +import { useCallback, useMemo, useState } from "react" + +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { FreezeMappingAction } from "@/features/billing-migrations/components/freeze-mapping-action" +import { GuidedMappingForm } from "@/features/billing-migrations/components/guided-mapping-form" +import { MigrationImpactReviewAction } from "@/features/billing-migrations/components/migration-impact-review-action" +import { MigrationJourneyCockpit } from "@/features/billing-migrations/components/migration-journey-cockpit" +import { MigrationLifecycleOperations } from "@/features/billing-migrations/components/migration-lifecycle-operations" +import { useMigrationCommand } from "@/features/billing-migrations/hooks/use-migration-command" +import { + assessMigrationReadinessMutationOptions, + createMigrationCommandKey, + createMigrationBatchMutationOptions, + createMigrationMappingMutationOptions, + freezeMigrationMappingMutationOptions, + queueMigrationRunMutationOptions, +} from "@/features/billing-migrations/mutations/migration-mutations" +import { + migrationBatchQueryOptions, + migrationBatchesQueryOptions, + migrationDivergencesQueryOptions, + migrationManifestsQueryOptions, + migrationMappingsQueryOptions, + migrationKeys, + migrationLifecycleQueryOptions, + migrationProgramQueryOptions, + migrationReadinessQueryOptions, + migrationRunQueryOptions, +} from "@/features/billing-migrations/queries/migration-queries" +import { + canRunMigrationCommand, + dryRunAuthorityNotice, + laterLifecycleNotice, + migrationCommandJourney, +} from "@/features/billing-migrations/types/migration-operations" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { useOrganizationAccess } from "@/hooks/use-organization-access" +import { ApiError } from "@/lib/api/errors" + +type DetailTab = + "overview" | "evidence" | "mappings" | "imports" | "compare" | "readiness" | "lifecycle" +const tabs: DetailTab[] = [ + "overview", + "evidence", + "mappings", + "imports", + "compare", + "readiness", + "lifecycle", +] +const CLASSIFICATION_OPTIONS = [ + { label: "All", value: "all" }, + { label: "Critical", value: "critical" }, + { label: "Blocking", value: "blocking" }, + { label: "Warning", value: "warning" }, + { label: "Informational", value: "informational" }, +] + +interface Props { + batchId?: string + classification: string + onSearchChange: (next: { + batchId?: string + classification?: string + runJobId?: string + tab?: DetailTab + }) => void + organizationId: string + programId: string + projectId: string + runJobId?: string + tab: DetailTab +} + +export function MigrationProgramDetailPage({ + batchId = "", + classification, + onSearchChange, + organizationId, + programId, + projectId, + runJobId = "", + tab, +}: Props) { + const queryClient = useQueryClient() + const access = useOrganizationAccess(organizationId) + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const [importCount, setImportCount] = useState(1000) + const [cursorBefore, setCursorBefore] = useState("") + + const [program, manifests, mappings, batches, divergences, readiness, batch, run, lifecycle] = + useQueries({ + queries: [ + { ...migrationProgramQueryOptions(projectId, programId), enabled: scopeReady }, + { ...migrationManifestsQueryOptions(projectId, programId), enabled: scopeReady }, + { ...migrationMappingsQueryOptions(projectId, programId), enabled: scopeReady }, + { ...migrationBatchesQueryOptions(projectId, programId), enabled: scopeReady }, + { + ...migrationDivergencesQueryOptions(projectId, programId, classification), + enabled: scopeReady, + }, + { ...migrationReadinessQueryOptions(projectId, programId), enabled: scopeReady }, + { + ...migrationBatchQueryOptions(projectId, programId, batchId), + enabled: scopeReady && batchId.length > 0, + }, + { + ...migrationRunQueryOptions(projectId, programId, runJobId), + enabled: scopeReady && runJobId.length > 0, + }, + { ...migrationLifecycleQueryOptions(projectId, programId), enabled: scopeReady }, + ], + }) + const refetchProgram = useCallback( + () => queryClient.refetchQueries({ queryKey: migrationKeys.program(projectId, programId) }), + [programId, projectId, queryClient], + ) + const command = useMigrationCommand(refetchProgram) + + const createMapping = useMutation( + createMigrationMappingMutationOptions(projectId, programId, queryClient), + ) + const freezeMapping = useMutation( + freezeMigrationMappingMutationOptions(projectId, programId, queryClient), + ) + const createBatch = useMutation( + createMigrationBatchMutationOptions(projectId, programId, queryClient), + ) + const dryRun = useMutation( + queueMigrationRunMutationOptions(projectId, programId, "dry_run", queryClient), + ) + const shadowRun = useMutation( + queueMigrationRunMutationOptions(projectId, programId, "shadow", queryClient), + ) + const assess = useMutation( + assessMigrationReadinessMutationOptions(projectId, programId, queryClient), + ) + + const detail = program.data + const current = detail?.program + const latestManifest = manifests.data?.at(0) + const latestMapping = mappings.data?.at(0) + const divergenceCounts = useMemo( + () => + (divergences.data ?? []).reduce( + (counts, item) => ({ ...counts, [item.classification]: counts[item.classification] + 1 }), + { blocking: 0, critical: 0, informational: 0, warning: 0 }, + ), + [divergences.data], + ) + const journey = current && detail ? migrationCommandJourney(current.state, detail) : null + const anyCommandPending = + createMapping.isPending || + freezeMapping.isPending || + createBatch.isPending || + dryRun.isPending || + shadowRun.isPending || + assess.isPending + const importDisabledReason = !canRunMigrationCommand(detail, "run-import") + ? "The server has not granted the run-import command. Organization role is not command authority." + : !current || current.state !== "importing" + ? "Freeze a reviewed mapping set before importing." + : !latestManifest + ? "A source manifest is required before importing." + : !latestMapping || latestMapping.status !== "frozen" + ? "Choose and freeze a mapping set before importing." + : importCount < 0 || importCount > 1000 + ? "Record count must be between 0 and 1,000." + : null + const dryRunDisabledReason = !canRunMigrationCommand(detail, "run-import") + ? "The server has not granted the run-import command. Organization role is not command authority." + : !current || !journey?.canQueueDryRun + ? "Complete a bounded import before starting the dry run." + : !latestManifest || !latestMapping + ? "A source manifest and mapping set are required for comparison." + : null + const shadowDisabledReason = !canRunMigrationCommand(detail, "run-import") + ? "The server has not granted the run-import command. Organization role is not command authority." + : !current || !journey?.canQueueShadow + ? "Complete the dry run before starting shadow comparison." + : !latestManifest || !latestMapping + ? "A source manifest and mapping set are required for comparison." + : null + const readinessDisabledReason = !canRunMigrationCommand(detail, "assess-readiness") + ? "The server has not granted the assess-readiness command. Organization role is explanatory only." + : !current || !journey?.canAssess + ? "Complete shadow comparison before assessing readiness." + : null + const commonError = + project.error ?? + access.error ?? + program.error ?? + manifests.error ?? + mappings.error ?? + batches.error ?? + divergences.error ?? + lifecycle.error + const readinessMissing = readiness.error instanceof ApiError && readiness.error.status === 404 + const state = resolveHostedQueryState({ + error: commonError, + isEmpty: false, + isPending: + project.isPending || + access.isPending || + (scopeReady && + (program.isPending || + manifests.isPending || + mappings.isPending || + batches.isPending || + divergences.isPending || + lifecycle.isPending)), + loadingDescription: "Loading migration scope and operational evidence in parallel.", + onRetry: () => { + void program.refetch() + void manifests.refetch() + void mappings.refetch() + void batches.refetch() + void divergences.refetch() + void lifecycle.refetch() + }, + permissionDescription: "Project membership is required to view this Migration Program.", + scope: { organizationId, projectId }, + }) + + const programBase = `/orgs/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/migrations/${encodeURIComponent(programId)}` + const scopeRows = useMemo(() => current?.scope.applications ?? [], [current?.scope.applications]) + + if (scopeMismatch) + return ( + + + + ) + + return ( + + {current ? : null} + + {command.error ? ( +

+ {command.error} +

+ ) : null} + + {current ? ( +

+ {laterLifecycleNotice(current)} No cutover, rollback, or repair command is implied by + this page. +

+ ) : null} + + {tab === "overview" && current ? ( + +
+
+
State
+
+ {current.state} · version {current.stateVersion} +
+
+
+
Source
+
RevenueCat · {current.source.adapterVersion}
+
+
+
Rollback window
+
{current.rollbackWindowDays} days
+
+
+
    + {scopeRows.map((item) => ( +
  • + {item.applicationId} · {item.platform} +
  • + ))} +
+
+

Server assessments

+

+ Source reads:{" "} + {detail?.sourceCapabilityAssessment?.capabilities.join(", ") || "Not assessed"}. + These provider permissions do not authorize operator commands. +

+

+ Operator commands:{" "} + {detail?.commandCapabilities.size + ? [...detail.commandCapabilities].join(", ") + : "None granted (fail closed)"} + . Organization role {access.role ?? "unknown"} is shown only to explain membership + context. +

+
+
+ ) : null} + + {tab === "evidence" ? ( + + {manifests.data?.length ? ( +
    + {manifests.data.map((item) => ( +
  • + {item.manifestId} + + {item.recordCount} records · {item.currentAccessRecordCount} current access + + + {item.manifestDigest} + +
  • + ))} +
+ ) : ( +

+ No immutable source manifest has been recorded. +

+ )} +
+ ) : null} + + {tab === "mappings" ? ( +
+ {journey?.canCreateMapping && current ? ( + + + command.run( + () => + createMapping.mutateAsync({ + entries, + expectedStateVersion: current.stateVersion, + version: + Math.max(0, ...(mappings.data ?? []).map((item) => item.version)) + 1, + }), + "mapping", + ) + } + /> + + ) : null} + + {mappings.data?.length ? ( +
    + {mappings.data.map((item) => ( +
  • + Version {item.version} + + {item.entries.length} entries + {item.status === "draft" && journey?.canFreezeMapping && current ? ( + + command.run( + () => + freezeMapping.mutateAsync({ + expectedStateVersion: current.stateVersion, + mappingSetId, + }), + "freeze", + ) + } + /> + ) : null} +
  • + ))} +
+ ) : ( +

No mapping set has been created.

+ )} +
+
+ ) : null} + + {tab === "imports" ? ( +
+ {current ? ( + +
+ + +
+
+ + void command.run(async () => { + const created = await createBatch.mutateAsync({ + body: { + cursorBefore, + expectedStateVersion: current.stateVersion, + manifestId: latestManifest?.manifestId ?? "", + mappingSetId: latestMapping?.mappingSetId ?? "", + recordCount: importCount, + }, + idempotencyKey: createMigrationCommandKey(), + }) + onSearchChange({ batchId: created.batchId, tab: "imports" }) + }, "import") + } + pendingLabel="Queueing import…" + title="Review import impact" + /> +
+
+ ) : null} + + {batches.data?.length ? ( +
    + {batches.data.map((item) => ( +
  • + + {item.batchId} + + + + {item.validatedCount}/{item.recordCount} validated · {item.quarantinedCount}{" "} + quarantined + +
  • + ))} +
+ ) : ( +

No import batch has been queued.

+ )} + {batch.data ? ( +

+ Selected batch {batch.data.batchId}: {batch.data.status},{" "} + {batch.data.validatedCount} validated and {batch.data.quarantinedCount}{" "} + quarantined. +

+ ) : null} +
+
+ ) : null} + + {tab === "compare" ? ( +
+ +

+ {dryRunAuthorityNotice(current?.state === "shadowing" ? "shadow" : "dry_run")} +

+
+ + void command.run(async () => { + const job = await dryRun.mutateAsync({ + body: { + expectedStateVersion: current?.stateVersion ?? 0, + manifestDigest: latestManifest?.manifestDigest ?? "", + mappingDigest: latestMapping?.mappingDigest ?? "", + }, + idempotencyKey: createMigrationCommandKey(), + }) + onSearchChange({ runJobId: job.runJobId, tab: "compare" }) + }, "run") + } + pendingLabel="Queueing dry run…" + title="Review dry-run impact" + /> + + void command.run(async () => { + const job = await shadowRun.mutateAsync({ + body: { + expectedStateVersion: current?.stateVersion ?? 0, + manifestDigest: latestManifest?.manifestDigest ?? "", + mappingDigest: latestMapping?.mappingDigest ?? "", + }, + idempotencyKey: createMigrationCommandKey(), + }) + onSearchChange({ runJobId: job.runJobId, tab: "compare" }) + }, "run") + } + pendingLabel="Queueing shadow comparison…" + title="Review shadow-run impact" + variant="outline" + /> +
+ {run.data ? ( +
+ {run.data.runKind.replaceAll("_", " ")} · {run.data.status}.{" "} + {dryRunAuthorityNotice(run.data.runKind)} +
+ ) : null} +
+ + + + {divergences.data?.length ? ( +
    + {divergences.data.map((item) => ( +
  • + +

    {item.reason}

    +

    + Rule {item.classificationRuleVersion} ·{" "} + {new Date(item.observedAt).toLocaleString()} +

    +
  • + ))} +
+ ) : ( +

+ No divergence matches this filter. +

+ )} +
+
+ ) : null} + + {tab === "readiness" ? ( + + {current ? ( + + void command.run( + () => assess.mutateAsync({ expectedStateVersion: current.stateVersion }), + "readiness", + ) + } + pendingLabel="Assessing readiness…" + title="Review readiness impact" + /> + ) : null} + {readiness.data ? ( +
+
+
Ready
+
{readiness.data.ready ? "Yes" : "No"}
+
+
+
Current-access mapping
+
{readiness.data.currentAccessMappingPercent}%
+
+
+
Validated evidence
+
{readiness.data.currentAccessEvidencePercent}%
+
+
+
Critical / blocking
+
+ {readiness.data.unresolved.critical} / {readiness.data.unresolved.blocking} +
+
+
+
Final delta
+
{readiness.data.finalDeltaCompleted ? "Complete" : "Pending"}
+
+
+
Fresh watermarks / aware versions
+
+ {readiness.data.watermarksFresh ? "Fresh" : "Pending"} /{" "} + {readiness.data.supportedVersionsAuthorityAware ? "Ready" : "Pending"} +
+
+
+ ) : readinessMissing ? ( +

+ No readiness assessment exists yet. +

+ ) : readiness.error ? ( +

+ Mosaic could not load the latest readiness assessment. Refresh this view and try + again. +

+ ) : null} +
+ ) : null} + {tab === "lifecycle" && detail ? ( + + ) : null} +
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-programs-page.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-programs-page.tsx new file mode 100644 index 00000000..c1a75e4f --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/components/migration-programs-page.tsx @@ -0,0 +1,181 @@ +import { useState } from "react" +import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" + +import { EmptyState } from "@/components/feedback/empty-state" +import { Button } from "@/components/ui/button" +import { buttonVariants } from "@/components/ui/button-variants" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { CreateMigrationProgramForm } from "@/features/billing-migrations/components/create-migration-program-form" +import { createMigrationProgramMutationOptions } from "@/features/billing-migrations/mutations/migration-mutations" +import { migrationProgramsQueryOptions } from "@/features/billing-migrations/queries/migration-queries" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { applicationsQueryOptions } from "@/features/projects/queries/projects-query" +import { useOrganizationAccess } from "@/hooks/use-organization-access" + +export function MigrationProgramsPage({ + organizationId, + projectId, +}: { + organizationId: string + projectId: string +}) { + const queryClient = useQueryClient() + const navigate = useNavigate() + const access = useOrganizationAccess(organizationId) + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const [programs, environments, applications] = useQueries({ + queries: [ + { ...migrationProgramsQueryOptions(projectId), enabled: scopeReady }, + { ...environmentsQueryOptions(projectId), enabled: scopeReady }, + { ...applicationsQueryOptions(projectId), enabled: scopeReady }, + ], + }) + const create = useMutation(createMigrationProgramMutationOptions(projectId, queryClient)) + const [createOpen, setCreateOpen] = useState(false) + + if (scopeMismatch) { + return ( + + + + ) + } + + const items = programs.data ?? [] + const error = + project.error ?? access.error ?? programs.error ?? environments.error ?? applications.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: + project.isPending || + access.isPending || + (scopeReady && (programs.isPending || environments.isPending || applications.isPending)), + loadingDescription: "Loading Project migration programs and their available scope.", + onRetry: () => { + void programs.refetch() + void environments.refetch() + void applications.refetch() + }, + permissionDescription: "Project membership is required to view Migration Programs.", + scope: { organizationId, projectId }, + }) + const base = `/orgs/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/migrations` + const createDialog = ( + { + setCreateOpen(open) + if (!open) create.reset() + }} + open={createOpen} + > + }>Create program + + + Create Migration Program + + {`Creating a program performs a bounded, read-only source capability assessment. The server authorizes the command; Organization role ${access.role ?? "unknown"} is explanatory only.`} + + +
+ create.mutateAsync(command)} + onCreated={(program) => { + setCreateOpen(false) + void navigate({ + params: (prev) => ({ ...prev, programId: program.programId }), + search: { classification: "all", tab: "overview" }, + to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey/billing/migrations/$programId", + }) + }} + resetMutation={() => create.reset()} + /> +
+
+
+ ) + + return ( + + + {items.length === 0 ? ( + + Review authorized operators + + } + description={ + "No RevenueCat migration has been scoped for this Project. Submit a scoped request from Create program; the server will authorize or deny it." + } + title="No Migration Programs yet" + /> + ) : ( + +
    + {items.map((detail) => { + const program = detail.program + return ( +
  • +
    + + RevenueCat · {program.scope.environmentId} + +

    + {program.scope.applications.length} explicit Application/platform scope(s) · + state version {program.stateVersion} +

    +
    + +
  • + ) + })} +
+
+ )} +
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-migrations/hooks/use-migration-command.ts b/apps/dashboard/src/features/billing-migrations/hooks/use-migration-command.ts new file mode 100644 index 00000000..b8d2fdf0 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/hooks/use-migration-command.ts @@ -0,0 +1,31 @@ +import { useCallback, useRef, useState } from "react" + +import { migrationErrorCopy } from "@/features/billing-migrations/types/migration-operations" +import { ApiError } from "@/lib/api/errors" + +type CommandKind = "mapping" | "freeze" | "import" | "run" | "readiness" + +export function useMigrationCommand(onStale: () => Promise) { + const inFlight = useRef(false) + const [error, setError] = useState(null) + const run = useCallback( + async (action: () => Promise, command: CommandKind) => { + if (inFlight.current) return undefined + inFlight.current = true + setError(null) + try { + return await action() + } catch (cause) { + setError(migrationErrorCopy(cause, command)) + if (cause instanceof ApiError && (cause.status === 403 || cause.status === 409)) { + await onStale() + } + return undefined + } finally { + inFlight.current = false + } + }, + [onStale], + ) + return { error, run } +} diff --git a/apps/dashboard/src/features/billing-migrations/mutations/migration-mutations.ts b/apps/dashboard/src/features/billing-migrations/mutations/migration-mutations.ts new file mode 100644 index 00000000..cdf6e939 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/mutations/migration-mutations.ts @@ -0,0 +1,298 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query" + +import { + assessBillingMigrationReadiness, + approveBillingMigrationLegalHold, + approveBillingMigrationProposal, + completeBillingMigration, + createBillingMigrationImportBatch, + createBillingMigrationMappingSet, + createBillingMigrationProgram, + createBillingMigrationCheckpoint, + executeBillingMigrationCutover, + executeBillingMigrationRepair, + executeBillingMigrationRollback, + freezeBillingMigrationMappingSet, + observeBillingMigrationStabilization, + previewBillingMigrationRepair, + proposeBillingMigrationCutover, + proposeBillingMigrationLegalHold, + proposeBillingMigrationRollback, + queueBillingMigrationDryRun, + queueBillingMigrationShadowRun, + redeliverBillingMigrationWebhook, + removeBillingMigrationCredential, + type BillingMigrationCheckpointRequest, + type BillingMigrationCompletionRequest, + type BillingMigrationCredentialRemovalRequest, + type BillingMigrationCutoverExecutionRequest, + type BillingMigrationCutoverProposalRequest, + type BillingMigrationLegalHoldApprovalRequest, + type BillingMigrationLegalHoldProposalRequest, + type BillingMigrationObserveStabilizationRequest, + type BillingMigrationRedeliveryRequest, + type BillingMigrationRepairExecutionRequest, + type BillingMigrationRepairPreviewRequest, + type BillingMigrationRollbackExecutionRequest, + type BillingMigrationRollbackProposalRequest, + type BillingMigrationStateVersionRequest, + type CreateBillingMigrationImportBatchRequest, + type CreateBillingMigrationMappingSetRequest, + type CreateBillingMigrationProgramRequestWritable, + type QueueBillingMigrationRunRequest, +} from "@/generated/api" +import { migrationKeys } from "@/features/billing-migrations/queries/migration-queries" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +export function createMigrationCommandKey() { + return typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `mosaic-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +type LifecycleCommand = + | { kind: "propose_cutover"; body: BillingMigrationCutoverProposalRequest } + | { kind: "approve_proposal"; proposalId: string; body: BillingMigrationStateVersionRequest } + | { kind: "create_checkpoint"; body: BillingMigrationCheckpointRequest } + | { kind: "execute_cutover"; body: BillingMigrationCutoverExecutionRequest } + | { kind: "propose_rollback"; body: BillingMigrationRollbackProposalRequest } + | { kind: "execute_rollback"; body: BillingMigrationRollbackExecutionRequest } + | { kind: "observe_stabilization"; body: BillingMigrationObserveStabilizationRequest } + | { kind: "preview_repair"; body: BillingMigrationRepairPreviewRequest } + | { kind: "execute_repair"; body: BillingMigrationRepairExecutionRequest } + | { kind: "redeliver_webhook"; body: BillingMigrationRedeliveryRequest } + | { kind: "remove_credential"; body: BillingMigrationCredentialRemovalRequest } + | { kind: "propose_legal_hold"; body: BillingMigrationLegalHoldProposalRequest } + | { + kind: "approve_legal_hold" + proposalId: string + body: BillingMigrationLegalHoldApprovalRequest + } + | { kind: "complete"; body: BillingMigrationCompletionRequest } + +export function migrationLifecycleMutationOptions( + projectId: string, + programId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async ({ + command, + idempotencyKey, + }: { + command: LifecycleCommand + idempotencyKey: string + }) => { + const common = { + client: generatedDashboardClient, + headers: { "Idempotency-Key": idempotencyKey }, + path: { programId, projectId }, + throwOnError: true, + } as const + switch (command.kind) { + case "propose_cutover": + return (await proposeBillingMigrationCutover({ ...common, body: command.body })).data.data + .payload + case "approve_proposal": + return ( + await approveBillingMigrationProposal({ + ...common, + body: command.body, + path: { ...common.path, proposalId: command.proposalId }, + }) + ).data.data.payload + case "create_checkpoint": + return (await createBillingMigrationCheckpoint({ ...common, body: command.body })).data + .data.payload + case "execute_cutover": + return (await executeBillingMigrationCutover({ ...common, body: command.body })).data.data + .payload + case "propose_rollback": + return (await proposeBillingMigrationRollback({ ...common, body: command.body })).data + .data.payload + case "execute_rollback": + return (await executeBillingMigrationRollback({ ...common, body: command.body })).data + .data.payload + case "observe_stabilization": + return (await observeBillingMigrationStabilization({ ...common, body: command.body })) + .data.data.payload + case "preview_repair": + return (await previewBillingMigrationRepair({ ...common, body: command.body })).data.data + .payload + case "execute_repair": + return (await executeBillingMigrationRepair({ ...common, body: command.body })).data.data + .payload + case "redeliver_webhook": + return (await redeliverBillingMigrationWebhook({ ...common, body: command.body })).data + .data.payload + case "remove_credential": + return (await removeBillingMigrationCredential({ ...common, body: command.body })).data + .data.payload + case "propose_legal_hold": + return (await proposeBillingMigrationLegalHold({ ...common, body: command.body })).data + .data.payload + case "approve_legal_hold": + return ( + await approveBillingMigrationLegalHold({ + ...common, + body: command.body, + path: { ...common.path, proposalId: command.proposalId }, + }) + ).data.data.payload + case "complete": + return (await completeBillingMigration({ ...common, body: command.body })).data.data + .payload + } + }, + onSettled: async () => { + await refreshProgram(queryClient, projectId, programId) + await queryClient.invalidateQueries({ + queryKey: migrationKeys.lifecycle(projectId, programId), + }) + }, + }) +} + +async function refreshProgram(queryClient: QueryClient, projectId: string, programId?: string) { + await queryClient.invalidateQueries({ queryKey: migrationKeys.project(projectId) }) + if (programId) { + await queryClient.invalidateQueries({ queryKey: migrationKeys.program(projectId, programId) }) + } +} + +export function createMigrationProgramMutationOptions(projectId: string, queryClient: QueryClient) { + return mutationOptions({ + gcTime: 0, + mutationFn: async ({ + body, + idempotencyKey, + }: { + body: CreateBillingMigrationProgramRequestWritable + idempotencyKey: string + }) => { + const result = await createBillingMigrationProgram({ + body, + client: generatedDashboardClient, + headers: { "Idempotency-Key": idempotencyKey }, + path: { projectId }, + throwOnError: true, + }) + return result.data.data.payload.program + }, + onSuccess: () => refreshProgram(queryClient, projectId), + }) +} + +export function createMigrationMappingMutationOptions( + projectId: string, + programId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (body: CreateBillingMigrationMappingSetRequest) => { + const result = await createBillingMigrationMappingSet({ + body, + client: generatedDashboardClient, + path: { programId, projectId }, + throwOnError: true, + }) + return result.data.data.payload + }, + onSettled: () => refreshProgram(queryClient, projectId, programId), + }) +} + +export function freezeMigrationMappingMutationOptions( + projectId: string, + programId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async ({ + expectedStateVersion, + mappingSetId, + }: BillingMigrationStateVersionRequest & { mappingSetId: string }) => { + await freezeBillingMigrationMappingSet({ + body: { expectedStateVersion }, + client: generatedDashboardClient, + path: { mappingSetId, programId, projectId }, + throwOnError: true, + }) + }, + onSettled: () => refreshProgram(queryClient, projectId, programId), + }) +} + +export function createMigrationBatchMutationOptions( + projectId: string, + programId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async ({ + body, + idempotencyKey, + }: { + body: CreateBillingMigrationImportBatchRequest + idempotencyKey: string + }) => { + const result = await createBillingMigrationImportBatch({ + body, + client: generatedDashboardClient, + headers: { "Idempotency-Key": idempotencyKey }, + path: { programId, projectId }, + throwOnError: true, + }) + return result.data.data.payload + }, + onSettled: () => refreshProgram(queryClient, projectId, programId), + }) +} + +export function queueMigrationRunMutationOptions( + projectId: string, + programId: string, + kind: "dry_run" | "shadow", + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async ({ + body, + idempotencyKey, + }: { + body: QueueBillingMigrationRunRequest + idempotencyKey: string + }) => { + const operation = + kind === "dry_run" ? queueBillingMigrationDryRun : queueBillingMigrationShadowRun + const result = await operation({ + body, + client: generatedDashboardClient, + headers: { "Idempotency-Key": idempotencyKey }, + path: { programId, projectId }, + throwOnError: true, + }) + return result.data.data + }, + onSettled: () => refreshProgram(queryClient, projectId, programId), + }) +} + +export function assessMigrationReadinessMutationOptions( + projectId: string, + programId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (body: BillingMigrationStateVersionRequest) => { + const result = await assessBillingMigrationReadiness({ + body, + client: generatedDashboardClient, + path: { programId, projectId }, + throwOnError: true, + }) + return result.data.data.payload + }, + onSettled: () => refreshProgram(queryClient, projectId, programId), + }) +} diff --git a/apps/dashboard/src/features/billing-migrations/queries/migration-queries.ts b/apps/dashboard/src/features/billing-migrations/queries/migration-queries.ts new file mode 100644 index 00000000..cc8180bf --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/queries/migration-queries.ts @@ -0,0 +1,275 @@ +import { queryOptions } from "@tanstack/react-query" + +import { + getBillingMigrationImportBatch, + getBillingMigrationProgram, + getBillingMigrationRunJob, + getLatestBillingMigrationReadiness, + inspectBillingMigrationCompletion, + listBillingMigrationApprovals, + listBillingMigrationAuthorityExecutions, + listBillingMigrationCases, + listBillingMigrationCheckpoints, + listBillingMigrationCompletionHistory, + listBillingMigrationCredentialRemovals, + listBillingMigrationDivergences, + listBillingMigrationImportBatches, + listBillingMigrationMappingSets, + listBillingMigrationLegalHoldProposals, + listBillingMigrationLegalHolds, + listBillingMigrationPrograms, + listBillingMigrationProposals, + listBillingMigrationRepairExecutions, + listBillingMigrationRepairPreviews, + listBillingMigrationRollbackReadinessAssessments, + listBillingMigrationSourceManifests, + listBillingMigrationStabilizationObservations, + listBillingMigrationWebhookRedeliveries, +} from "@/generated/api" +import { + migrationRunPollingInterval, + normalizeMigrationProgramDetail, +} from "@/features/billing-migrations/types/migration-operations" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +export const migrationKeys = { + project: (projectId: string) => ["billing-migrations", projectId] as const, + programs: (projectId: string) => ["billing-migrations", projectId, "programs"] as const, + program: (projectId: string, programId: string) => + ["billing-migrations", projectId, "program", programId] as const, + manifests: (projectId: string, programId: string) => + ["billing-migrations", projectId, programId, "manifests"] as const, + mappings: (projectId: string, programId: string) => + ["billing-migrations", projectId, programId, "mappings"] as const, + batches: (projectId: string, programId: string) => + ["billing-migrations", projectId, programId, "batches"] as const, + batch: (projectId: string, programId: string, batchId: string) => + ["billing-migrations", projectId, programId, "batch", batchId] as const, + run: (projectId: string, programId: string, runJobId: string) => + ["billing-migrations", projectId, programId, "run", runJobId] as const, + divergences: (projectId: string, programId: string, classification: string) => + ["billing-migrations", projectId, programId, "divergences", classification] as const, + readiness: (projectId: string, programId: string) => + ["billing-migrations", projectId, programId, "readiness"] as const, + lifecycle: (projectId: string, programId: string) => + ["billing-migrations", projectId, programId, "lifecycle"] as const, +} + +export function migrationLifecycleQueryOptions(projectId: string, programId: string) { + return queryOptions({ + queryKey: migrationKeys.lifecycle(projectId, programId), + queryFn: async ({ signal }) => { + const withSignal = { + client: generatedDashboardClient, + path: { programId, projectId }, + signal, + throwOnError: true, + } as const + const [ + proposals, + approvals, + checkpoints, + executions, + cases, + previews, + repairs, + redeliveries, + removals, + holdProposals, + holds, + reports, + observations, + rollbackAssessments, + completion, + ] = await Promise.all([ + listBillingMigrationProposals(withSignal), + listBillingMigrationApprovals(withSignal), + listBillingMigrationCheckpoints(withSignal), + listBillingMigrationAuthorityExecutions(withSignal), + listBillingMigrationCases(withSignal), + listBillingMigrationRepairPreviews(withSignal), + listBillingMigrationRepairExecutions(withSignal), + listBillingMigrationWebhookRedeliveries(withSignal), + listBillingMigrationCredentialRemovals(withSignal), + listBillingMigrationLegalHoldProposals(withSignal), + listBillingMigrationLegalHolds(withSignal), + listBillingMigrationCompletionHistory(withSignal), + listBillingMigrationStabilizationObservations(withSignal), + listBillingMigrationRollbackReadinessAssessments(withSignal), + inspectBillingMigrationCompletion(withSignal).catch(() => undefined), + ]) + return { + approvals: approvals.data.data.items, + cases: cases.data.data.items, + checkpoints: checkpoints.data.data.items, + completion: completion?.data.data.payload, + executions: executions.data.data.items, + holdProposals: holdProposals.data.data.items, + holds: holds.data.data.items, + observations: observations.data.data.items, + proposals: proposals.data.data.items, + removals: removals.data.data.items, + repairExecutions: repairs.data.data.items, + repairPreviews: previews.data.data.items, + reports: reports.data.data.items, + rollbackAssessments: rollbackAssessments.data.data.items, + webhookRedeliveries: redeliveries.data.data.items, + } + }, + }) +} + +export function migrationProgramsQueryOptions(projectId: string) { + return queryOptions({ + queryKey: migrationKeys.programs(projectId), + queryFn: async ({ signal }) => { + const result = await listBillingMigrationPrograms({ + client: generatedDashboardClient, + path: { projectId }, + query: { limit: 100 }, + signal, + throwOnError: true, + }) + return result.data.data.items.map((record) => normalizeMigrationProgramDetail(record.payload)) + }, + }) +} + +export function migrationProgramQueryOptions(projectId: string, programId: string) { + return queryOptions({ + queryKey: migrationKeys.program(projectId, programId), + queryFn: async ({ signal }) => { + const result = await getBillingMigrationProgram({ + client: generatedDashboardClient, + path: { programId, projectId }, + signal, + throwOnError: true, + }) + return normalizeMigrationProgramDetail(result.data.data.payload) + }, + }) +} + +export function migrationManifestsQueryOptions(projectId: string, programId: string) { + return queryOptions({ + queryKey: migrationKeys.manifests(projectId, programId), + queryFn: async ({ signal }) => { + const result = await listBillingMigrationSourceManifests({ + client: generatedDashboardClient, + path: { programId, projectId }, + query: { limit: 100 }, + signal, + throwOnError: true, + }) + return result.data.data.items.map((record) => record.payload) + }, + }) +} + +export function migrationMappingsQueryOptions(projectId: string, programId: string) { + return queryOptions({ + queryKey: migrationKeys.mappings(projectId, programId), + queryFn: async ({ signal }) => { + const result = await listBillingMigrationMappingSets({ + client: generatedDashboardClient, + path: { programId, projectId }, + query: { limit: 100 }, + signal, + throwOnError: true, + }) + return result.data.data.items.map((record) => record.payload) + }, + }) +} + +export function migrationBatchesQueryOptions(projectId: string, programId: string) { + return queryOptions({ + queryKey: migrationKeys.batches(projectId, programId), + queryFn: async ({ signal }) => { + const result = await listBillingMigrationImportBatches({ + client: generatedDashboardClient, + path: { programId, projectId }, + query: { limit: 100 }, + signal, + throwOnError: true, + }) + return result.data.data.items.map((record) => record.payload) + }, + }) +} + +export function migrationBatchQueryOptions(projectId: string, programId: string, batchId: string) { + return queryOptions({ + enabled: batchId.length > 0, + queryKey: migrationKeys.batch(projectId, programId, batchId), + queryFn: async ({ signal }) => { + const result = await getBillingMigrationImportBatch({ + client: generatedDashboardClient, + path: { batchId, programId, projectId }, + signal, + throwOnError: true, + }) + return result.data.data.payload + }, + }) +} + +export function migrationRunQueryOptions(projectId: string, programId: string, runJobId: string) { + return queryOptions({ + enabled: runJobId.length > 0, + queryKey: migrationKeys.run(projectId, programId, runJobId), + queryFn: async ({ signal }) => { + const result = await getBillingMigrationRunJob({ + client: generatedDashboardClient, + path: { programId, projectId, runJobId }, + signal, + throwOnError: true, + }) + return result.data.data + }, + // A Stage 2B job may remain pending until a later worker stage is present. + // Poll for at most two minutes, and always stop immediately on terminal state. + refetchInterval: (query) => + migrationRunPollingInterval(query.state.data, query.state.dataUpdateCount), + }) +} + +export function migrationDivergencesQueryOptions( + projectId: string, + programId: string, + classification = "all", +) { + return queryOptions({ + queryKey: migrationKeys.divergences(projectId, programId, classification), + queryFn: async ({ signal }) => { + const result = await listBillingMigrationDivergences({ + client: generatedDashboardClient, + path: { programId, projectId }, + query: { limit: 100 }, + signal, + throwOnError: true, + }) + const items = result.data.data.items.map((record) => record.payload) + return classification === "all" + ? items + : items.filter((item) => item.classification === classification) + }, + }) +} + +export function migrationReadinessQueryOptions(projectId: string, programId: string) { + return queryOptions({ + queryKey: migrationKeys.readiness(projectId, programId), + queryFn: async ({ signal }) => { + const result = await getLatestBillingMigrationReadiness({ + client: generatedDashboardClient, + path: { programId, projectId }, + signal, + throwOnError: true, + }) + return result.data.data.payload + }, + retry: (count, error) => + !(error instanceof Error && "status" in error && error.status === 404) && count < 2, + }) +} diff --git a/apps/dashboard/src/features/billing-migrations/types/migration-operations.test.ts b/apps/dashboard/src/features/billing-migrations/types/migration-operations.test.ts new file mode 100644 index 00000000..6d82cdf1 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/types/migration-operations.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest" + +import { + dryRunAuthorityNotice, + isStaleMigrationConflict, + migrationCommandJourney, + migrationCompletionBlockers, + migrationRequiresDistinctApprover, + migrationRunPollingInterval, + normalizeMigrationProgramDetail, +} from "@/features/billing-migrations/types/migration-operations" +import type { BillingMigrationProgramDetail } from "@/generated/api" +import { ApiError } from "@/lib/api/errors" + +describe("billing migration operational safety", () => { + const detail = (operatorCapabilities?: unknown) => + normalizeMigrationProgramDetail({ + operatorCapabilities, + program: { + authorityEpochBefore: 0, + programId: "migration_1", + rollbackWindowDays: 7, + scope: { applications: [], environmentId: "env_1", projectId: "project_1" }, + source: { adapter: "revenuecat", adapterVersion: "v2", credentialReference: "redacted" }, + stabilizationDays: 7, + state: "mapping", + stateVersion: 4, + }, + } as BillingMigrationProgramDetail) + + it("fails closed and uses only server-returned command capabilities", () => { + expect(detail().commandCapabilities.size).toBe(0) + expect(detail(["manage-mappings", "unknown", "run-import"]).commandCapabilities).toEqual( + new Set(["manage-mappings", "run-import"]), + ) + }) + + it("recognizes stale state conflicts so the UI can refetch before retry", () => { + const stale = new ApiError("conflict", { + code: "migration_state_conflict", + correlationId: "request-1", + retryable: false, + status: 409, + }) + expect(isStaleMigrationConflict(stale)).toBe(true) + expect(isStaleMigrationConflict(new Error("conflict"))).toBe(false) + }) + + it("states that dry and shadow runs confer no billing authority", () => { + expect(dryRunAuthorityNotice("dry_run")).toContain("does not change billing authority") + expect(dryRunAuthorityNotice("shadow")).toContain("does not change billing authority") + }) + + it("polls pending jobs and stops after every terminal outcome", () => { + expect(migrationRunPollingInterval({ status: "pending" })).toBe(4000) + expect(migrationRunPollingInterval({ status: "completed" })).toBe(false) + expect(migrationRunPollingInterval({ status: "failed" })).toBe(false) + expect(migrationRunPollingInterval({ status: "pending" }, 30)).toBe(false) + }) + + it("protects the critical journey by state and server capability", () => { + expect(migrationCommandJourney("mapping", detail(["manage-mappings"]))).toMatchObject({ + canCreateMapping: true, + canImport: false, + canQueueDryRun: false, + }) + expect(migrationCommandJourney("importing", detail(["run-import"]))).toMatchObject({ + canCreateMapping: false, + canImport: true, + canQueueDryRun: true, + }) + expect(migrationCommandJourney("shadowing", detail([])).canAssess).toBe(false) + expect(migrationCommandJourney("shadowing", detail(["assess-readiness"])).canAssess).toBe(true) + }) + + it("keeps production authority and retention commands two-person", () => { + expect(migrationRequiresDistinctApprover("cutover")).toBe(true) + expect(migrationRequiresDistinctApprover("rollback")).toBe(true) + expect(migrationRequiresDistinctApprover("legal_hold")).toBe(true) + }) + + it("fails completion closed and exposes server blockers", () => { + expect(migrationCompletionBlockers(undefined)).toEqual(["Completion evidence is unavailable."]) + const prerequisites = { + authorityDigest: `sha256:${"a".repeat(64)}`, + authorityStable: false, + credentialRemoved: false, + eligible: false, + policyDigest: `sha256:${"b".repeat(64)}`, + programId: "program_one", + rollbackWindowEndsAt: "2020-01-01T00:00:00Z", + stabilizationEndsAt: "2020-01-01T00:00:00Z", + stabilityEvidenceDigest: `sha256:${"c".repeat(64)}`, + state: "stabilizing", + stateVersion: 7, + unresolvedCriticalBlocking: 2, + webhookReady: false, + } + expect(migrationCompletionBlockers(prerequisites)).toEqual([ + "migration credential is still active", + "2 critical or blocking cases remain", + "authority is not stable", + "webhook delivery is not ready", + ]) + expect( + migrationCompletionBlockers({ + ...prerequisites, + authorityStable: true, + credentialRemoved: true, + eligible: true, + unresolvedCriticalBlocking: 0, + webhookReady: true, + }), + ).toEqual([]) + }) +}) diff --git a/apps/dashboard/src/features/billing-migrations/types/migration-operations.ts b/apps/dashboard/src/features/billing-migrations/types/migration-operations.ts new file mode 100644 index 00000000..3b7a2ef8 --- /dev/null +++ b/apps/dashboard/src/features/billing-migrations/types/migration-operations.ts @@ -0,0 +1,176 @@ +import type { + BillingMigrationImportBatch, + BillingMigrationCompletionPrerequisites, + BillingMigrationProgram, + BillingMigrationProgramDetail, + BillingMigrationReadiness, + BillingMigrationRunJob, +} from "@/generated/api" +import { ApiError } from "@/lib/api/errors" + +export const migrationCommandCapabilities = [ + "view", + "assess-readiness", + "manage-mappings", + "manage-source", + "run-import", + "resolve-cases", + "propose-cutover", + "approve-cutover", + "execute-cutover", + "execute-rollback", + "execute-repair", + "delete-source", + "remove-credential", + "manage-legal-hold", + "complete-migration", +] as const + +export type MigrationCommandCapability = (typeof migrationCommandCapabilities)[number] + +export interface MigrationProgramView extends BillingMigrationProgramDetail { + /** Closed, server-derived command capabilities. Missing means fail closed. */ + commandCapabilities: ReadonlySet + sourceCapabilityAssessment?: { + adapter: string + assessedAt: string + capabilities: string[] + programId: string + providerApiVersion: string + stateVersion: number + } +} + +export function normalizeMigrationProgramDetail(payload: BillingMigrationProgramDetail) { + const candidate = payload as BillingMigrationProgramDetail & { + capabilityAssessment?: MigrationProgramView["sourceCapabilityAssessment"] + commandCapabilities?: unknown + operatorCapabilities?: unknown + sourceCapabilityAssessment?: MigrationProgramView["sourceCapabilityAssessment"] + } + const raw = candidate.commandCapabilities ?? candidate.operatorCapabilities + const allowed = new Set(migrationCommandCapabilities) + const commandCapabilities = new Set() + if (Array.isArray(raw)) { + for (const value of raw) { + if (typeof value === "string" && allowed.has(value)) { + commandCapabilities.add(value as MigrationCommandCapability) + } + } + } + return { + ...payload, + commandCapabilities, + sourceCapabilityAssessment: + candidate.sourceCapabilityAssessment ?? candidate.capabilityAssessment, + } satisfies MigrationProgramView +} + +export function canRunMigrationCommand( + detail: MigrationProgramView | undefined, + capability: MigrationCommandCapability, +) { + return detail?.commandCapabilities.has(capability) ?? false +} + +export function migrationRequiresDistinctApprover(command: "cutover" | "rollback" | "legal_hold") { + return command === "cutover" || command === "rollback" || command === "legal_hold" +} + +export function migrationCompletionBlockers( + prerequisites: BillingMigrationCompletionPrerequisites | undefined, +): string[] { + if (!prerequisites) return ["Completion evidence is unavailable."] + if (prerequisites.eligible) return [] + const blockers: string[] = [] + if (!prerequisites.credentialRemoved) blockers.push("migration credential is still active") + if (prerequisites.unresolvedCriticalBlocking > 0) + blockers.push(`${prerequisites.unresolvedCriticalBlocking} critical or blocking cases remain`) + if (!prerequisites.authorityStable) blockers.push("authority is not stable") + if (!prerequisites.webhookReady) blockers.push("webhook delivery is not ready") + if (new Date(prerequisites.stabilizationEndsAt) > new Date()) + blockers.push("stabilization window has not ended") + if (new Date(prerequisites.rollbackWindowEndsAt) > new Date()) + blockers.push("rollback window has not ended") + return blockers.length ? blockers : ["Server-derived completion prerequisites are not satisfied."] +} + +const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "cancelled"]) + +export function migrationRunIsTerminal(run: Pick | undefined) { + return run ? TERMINAL_RUN_STATUSES.has(run.status) : false +} + +const MAX_RUN_POLL_UPDATES = 30 + +export function migrationRunPollingInterval( + run: Pick | undefined, + updateCount = 0, +) { + return run && !migrationRunIsTerminal(run) && updateCount < MAX_RUN_POLL_UPDATES ? 4000 : false +} + +export function migrationCommandJourney( + state: BillingMigrationProgram["state"], + detail: MigrationProgramView, +) { + return { + canAssess: state === "shadowing" && canRunMigrationCommand(detail, "assess-readiness"), + canCreateMapping: state === "mapping" && canRunMigrationCommand(detail, "manage-mappings"), + canFreezeMapping: state === "mapping" && canRunMigrationCommand(detail, "manage-mappings"), + canImport: state === "importing" && canRunMigrationCommand(detail, "run-import"), + canQueueDryRun: + ["importing", "dry_run"].includes(state) && canRunMigrationCommand(detail, "run-import"), + canQueueShadow: + ["dry_run", "shadowing"].includes(state) && canRunMigrationCommand(detail, "run-import"), + } +} + +export function isStaleMigrationConflict(error: unknown) { + return error instanceof ApiError && error.status === 409 +} + +export function dryRunAuthorityNotice(runKind: BillingMigrationRunJob["runKind"]) { + return runKind === "dry_run" + ? "A dry run writes isolated migration evidence only. It does not change billing authority or customer access." + : "A shadow run compares isolated views only. It does not change billing authority or customer access." +} + +export function laterLifecycleNotice(program: Pick) { + return ["ready", "cutover_pending", "stabilizing", "completed", "rolled_back"].includes( + program.state, + ) + ? "This workspace can inspect readiness, but RevenueCat remains the billing authority until Mosaic ships, verifies, and explicitly executes the cutover workflow." + : "RevenueCat remains the billing authority. Cutover must be shipped, verified, and explicitly executed before Mosaic can become authoritative." +} + +export type ReadinessView = BillingMigrationReadiness +export type MigrationImportBatch = BillingMigrationImportBatch +export type MigrationRunJob = BillingMigrationRunJob + +export function migrationErrorCopy( + error: unknown, + command: "create" | "mapping" | "freeze" | "import" | "run" | "readiness" | "lifecycle", +) { + if (isStaleMigrationConflict(error)) { + return "This Migration Program changed after you loaded it. Mosaic refreshed the latest state; review the command before trying again." + } + if (error instanceof ApiError && error.status === 403) { + return "The server denied this migration command. Mosaic refreshed your command capabilities; review the latest state or ask an authorized operator for help." + } + if (error instanceof ApiError && error.status === 422) { + return "Mosaic could not use these values. Review the highlighted fields and try again." + } + return { + create: + "Mosaic could not check this source or create the Migration Program. Verify the source details and try again.", + freeze: + "Mosaic could not freeze this mapping set. Refresh the Program and review the exact version again.", + import: "Mosaic could not queue this import batch. Review its manifest, mapping set, and size.", + mapping: "Mosaic could not create this mapping set. Review every source and target.", + readiness: "Mosaic could not create a readiness assessment from the current evidence.", + run: "Mosaic could not queue this comparison. Review the selected evidence and try again.", + lifecycle: + "Mosaic could not run this lifecycle command. Review its state, digests, authority consequence, and approval before trying again.", + }[command] +} diff --git a/apps/dashboard/src/features/billing-operations/components/billing-health-page.tsx b/apps/dashboard/src/features/billing-operations/components/billing-health-page.tsx index c70d88f7..6ae6ffb1 100644 --- a/apps/dashboard/src/features/billing-operations/components/billing-health-page.tsx +++ b/apps/dashboard/src/features/billing-operations/components/billing-health-page.tsx @@ -17,8 +17,8 @@ import { } from "@/features/billing-ledger/types/billing-vocabulary" import { billingHealthQueryOptions } from "@/features/billing-operations/queries/billing-health-queries" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" import { storeCredentialsQueryOptions } from "@/features/store-connections/queries/store-connection-queries" import { @@ -96,7 +96,7 @@ export function BillingHealthPage({ ) } - const base = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` + const base = `/orgs/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` const connectionsHref = storeConnectionsHref({ organizationId, projectId }) ?? "#" const backlogSeconds = data?.oldestQueuedAgeSeconds const backlogUnhealthy = typeof backlogSeconds === "number" && backlogSeconds > 900 diff --git a/apps/dashboard/src/features/billing-operations/components/create-reconciliation-run-sheet.tsx b/apps/dashboard/src/features/billing-operations/components/create-reconciliation-run-sheet.tsx index 1af9bacf..c0f20c19 100644 --- a/apps/dashboard/src/features/billing-operations/components/create-reconciliation-run-sheet.tsx +++ b/apps/dashboard/src/features/billing-operations/components/create-reconciliation-run-sheet.tsx @@ -4,6 +4,13 @@ import { useState } from "react" import { Button } from "@/components/ui/button" import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Sheet, SheetContent, @@ -27,9 +34,6 @@ import { } from "@/features/billing-operations/types/reconciliation-range" import type { CreateReconciliationRunRequest, StoreServerCredential } from "@/generated/api" -const fieldClass = - "border-input bg-background focus-visible:border-ring focus-visible:ring-ring/40 h-9 w-full rounded border px-3 text-sm outline-none focus-visible:ring-3" - /** * The strategies the API accepts today. * @@ -106,6 +110,17 @@ export function CreateReconciliationRunSheet({ const credentialId = useStore(form.store, (state) => state.values.credentialId) const selected = usable.find((item) => item.id === credentialId) + const credentialOptions = [ + { label: "Select a credential", value: "" }, + ...usable.map((credential) => ({ + label: `${credential.name} · ${providerLabel(credential.provider)} · ${storeEnvironmentLabel(credential.storeEnvironment)}`, + value: credential.id, + })), + ] + const strategyOptions = strategiesFor(selected?.provider).map((strategy) => ({ + label: reconciliationStrategyLabel(strategy), + value: strategy, + })) return ( Store Server Credential - { + field.handleChange(value) + const next = usable.find((item) => item.id === value) form.setFieldValue("strategy", strategiesFor(next?.provider)[0] as Strategy) }} value={field.state.value} > - - {usable.map((credential) => ( - - ))} - + + + + + {credentialOptions.map((option) => ( + + {option.label} + + ))} + + The credential fixes both the store and the Store Environment. The Mosaic Environment is {environmentName} and comes from the address. @@ -191,18 +208,22 @@ export function CreateReconciliationRunSheet({ {(field) => ( Strategy - field.handleChange(value as Strategy)} value={field.state.value} > - {strategiesFor(selected?.provider).map((strategy) => ( - - ))} - + + + + + {strategyOptions.map((option) => ( + + {option.label} + + ))} + + )} diff --git a/apps/dashboard/src/features/billing-operations/components/quarantine-detail-page.tsx b/apps/dashboard/src/features/billing-operations/components/quarantine-detail-page.tsx index eb4a02ed..da877024 100644 --- a/apps/dashboard/src/features/billing-operations/components/quarantine-detail-page.tsx +++ b/apps/dashboard/src/features/billing-operations/components/quarantine-detail-page.tsx @@ -27,8 +27,8 @@ import { } from "@/features/billing-operations/mutations/quarantine-mutations" import { quarantineRecordQueryOptions } from "@/features/billing-operations/queries/quarantine-queries" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" import { applicationsQueryOptions } from "@/features/projects/queries/projects-query" import { useOrganizationAccess } from "@/hooks/use-organization-access" @@ -116,7 +116,7 @@ export function QuarantineDetailPage({ ) } - const projectBase = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}` + const projectBase = `/orgs/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}` const billingBase = `${projectBase}/billing/${encodeURIComponent(environmentId)}` return ( @@ -226,7 +226,7 @@ export function QuarantineDetailPage({ {...(closeSuperseded.error ? { closeError: closeSuperseded.error.message } : {})} isClosing={closeSuperseded.isPending} isRetrying={retry.isPending} - membersHref={`/organizations/${encodeURIComponent(organizationId)}/members`} + membersHref={`/orgs/${encodeURIComponent(organizationId)}/members`} onCloseSuperseded={(supersededByRecordId) => closeSuperseded.mutate({ supersededByRecordId }) } diff --git a/apps/dashboard/src/features/billing-operations/components/quarantine-page.tsx b/apps/dashboard/src/features/billing-operations/components/quarantine-page.tsx index e6e639a0..e87a2e19 100644 --- a/apps/dashboard/src/features/billing-operations/components/quarantine-page.tsx +++ b/apps/dashboard/src/features/billing-operations/components/quarantine-page.tsx @@ -1,6 +1,13 @@ import { useQuery } from "@tanstack/react-query" import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { buttonVariants } from "@/components/ui/button-variants" import { EmptyState } from "@/components/feedback/empty-state" import { @@ -31,8 +38,8 @@ import { type QuarantineListFilters, } from "@/features/billing-operations/queries/quarantine-queries" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" const fieldClass = @@ -46,6 +53,20 @@ interface QuarantinePageProps { projectId: string } +const QUARANTINE_STATUS_OPTIONS = [ + { label: "Any status", value: "" }, + { label: "Open", value: "open" }, + { label: "Retrying", value: "retrying" }, + { label: "Closed after a successful attempt", value: "closed_after_success" }, + { label: "Closed as superseded", value: "closed_superseded" }, +] + +const quarantineProviderOptions = [ + { label: "Any store", value: "" }, + { label: providerLabel("app_store"), value: "app_store" }, + { label: providerLabel("google_play"), value: "google_play" }, +] + export function QuarantinePage({ environmentId, filters, @@ -103,7 +124,7 @@ export function QuarantinePage({ ) } - const base = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` + const base = `/orgs/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` return (
- -
+
+ + - + + + + + {quarantineProviderOptions.map((option) => ( + + {option.label} + + ))} + + +
{/* A reason-code filter can arrive from a health or ledger recovery link. Without a visible control it would filter invisibly. */} {filters.reasonCode ? ( diff --git a/apps/dashboard/src/features/billing-operations/components/quarantine-recovery-actions.tsx b/apps/dashboard/src/features/billing-operations/components/quarantine-recovery-actions.tsx index c4eb4d4d..f2815c8d 100644 --- a/apps/dashboard/src/features/billing-operations/components/quarantine-recovery-actions.tsx +++ b/apps/dashboard/src/features/billing-operations/components/quarantine-recovery-actions.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { WorkflowPanel } from "@/features/orgs/components/workspace-page" import { quarantineNoActionExplanation, quarantineRecoveryActions, diff --git a/apps/dashboard/src/features/billing-operations/components/reconciliation-page.tsx b/apps/dashboard/src/features/billing-operations/components/reconciliation-page.tsx index d35eab78..3cde5a00 100644 --- a/apps/dashboard/src/features/billing-operations/components/reconciliation-page.tsx +++ b/apps/dashboard/src/features/billing-operations/components/reconciliation-page.tsx @@ -31,8 +31,8 @@ import { reconciliationRunsQueryOptions, } from "@/features/billing-operations/queries/reconciliation-queries" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" import { storeCredentialsQueryOptions } from "@/features/store-connections/queries/store-connection-queries" import { useOrganizationAccess } from "@/hooks/use-organization-access" @@ -112,7 +112,7 @@ export function ReconciliationPage({ ) } - const base = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` + const base = `/orgs/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` return ( replay.mutateAsync(request)} /> diff --git a/apps/dashboard/src/features/billing-projection/components/projection-replay-panel.tsx b/apps/dashboard/src/features/billing-projection/components/projection-replay-panel.tsx index e9cecf22..685081ab 100644 --- a/apps/dashboard/src/features/billing-projection/components/projection-replay-panel.tsx +++ b/apps/dashboard/src/features/billing-projection/components/projection-replay-panel.tsx @@ -14,7 +14,7 @@ import { replaySummary, validateReplayScope, } from "@/features/billing-projection/types/projection-vocabulary" -import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { WorkflowPanel } from "@/features/orgs/components/workspace-page" import type { CreateProjectionReplayRequest, ProjectionReplayResult } from "@/generated/api" interface ProjectionReplayPanelProps { diff --git a/apps/dashboard/src/features/catalog/components/connected-product-panels.test.tsx b/apps/dashboard/src/features/catalog/components/connected-product-panels.test.tsx index 07e5c2f8..c0995f6b 100644 --- a/apps/dashboard/src/features/catalog/components/connected-product-panels.test.tsx +++ b/apps/dashboard/src/features/catalog/components/connected-product-panels.test.tsx @@ -23,7 +23,7 @@ describe("connected Product panels", () => { { expect(screen.queryByRole("button", { name: "Replace mapping" })).not.toBeInTheDocument() expect( screen.getByRole("link", { name: "Ask an Owner or Admin to change this mapping" }), - ).toHaveAttribute("href", "/organizations/org_01/members") + ).toHaveAttribute("href", "/orgs/org_01/members") }) it("uses provider- and Product-type-aware native replacement fields", () => { diff --git a/apps/dashboard/src/features/catalog/components/entitlement-detail-page.tsx b/apps/dashboard/src/features/catalog/components/entitlement-detail-page.tsx index 3ca6cd31..48cccae8 100644 --- a/apps/dashboard/src/features/catalog/components/entitlement-detail-page.tsx +++ b/apps/dashboard/src/features/catalog/components/entitlement-detail-page.tsx @@ -5,9 +5,9 @@ import { buttonVariants } from "@/components/ui/button-variants" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" import { entitlementQueryOptions } from "@/features/catalog/queries/catalog-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" -import { detectNestedScopeMismatch } from "@/features/organizations/types/nested-scope" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { detectNestedScopeMismatch } from "@/features/orgs/types/nested-scope" import { projectQueryOptions } from "@/features/projects/queries/projects-query" interface EntitlementDetailPageProps { @@ -41,8 +41,8 @@ export function EntitlementDetailPage({ permissionAction: ( prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" > Return to Project diff --git a/apps/dashboard/src/features/catalog/components/entitlements-page.tsx b/apps/dashboard/src/features/catalog/components/entitlements-page.tsx index 62177067..0c0ad62e 100644 --- a/apps/dashboard/src/features/catalog/components/entitlements-page.tsx +++ b/apps/dashboard/src/features/catalog/components/entitlements-page.tsx @@ -1,16 +1,27 @@ +import { useState } from "react" import { useForm } from "@tanstack/react-form" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" import { createEntitlementMutationOptions } from "@/features/catalog/mutations/catalog-mutations" import { entitlementsQueryOptions } from "@/features/catalog/queries/catalog-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" interface EntitlementsPageProps { @@ -24,6 +35,7 @@ export function EntitlementsPage({ organizationId, projectId }: EntitlementsPage const entitlements = useQuery({ ...entitlementsQueryOptions(projectId), enabled: scopeReady }) const mutation = useMutation(createEntitlementMutationOptions(projectId, queryClient)) const items = entitlements.data?.items ?? [] + const [createOpen, setCreateOpen] = useState(false) const form = useForm({ defaultValues: { description: "", key: "", name: "" }, onSubmit: async ({ value }) => { @@ -33,6 +45,7 @@ export function EntitlementsPage({ organizationId, projectId }: EntitlementsPage name: value.name.trim(), }) form.reset() + setCreateOpen(false) }, }) const state = resolveHostedQueryState({ @@ -64,45 +77,34 @@ export function EntitlementsPage({ organizationId, projectId }: EntitlementsPage ) } - return ( - { + setCreateOpen(open) + if (!open) { + form.reset() + mutation.reset() + } + }} + open={createOpen} > - - -
    - {items.map((entitlement) => ( -
  • - - {entitlement.name} - - {entitlement.key} - - - - View definition - -
  • - ))} -
-
-
- {canManageEntitlements ? ( - -
{ - event.preventDefault() - event.stopPropagation() - void form.handleSubmit() - }} - > + }>Create Entitlement + + { + event.preventDefault() + event.stopPropagation() + void form.handleSubmit() + }} + > + + Create Entitlement + + An Entitlement is a named capability that Products unlock. This defines it only; it + does not grant customer access. + + +
{(field) => ( @@ -142,17 +144,53 @@ export function EntitlementsPage({ organizationId, projectId }: EntitlementsPage )} -
+ + }>Cancel + - - {mutation.error ? ( -

- {mutation.error.message} -

- ) : null} +
+ +
+ + ) + + return ( + + + +
    + {items.map((entitlement) => ( +
  • + + {entitlement.name} + + {entitlement.key} + + + ({ ...prev, entitlementId: entitlement.id })} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/catalog/entitlements/$entitlementId" + > + View definition + +
  • + ))} +
- ) : null} +
) } diff --git a/apps/dashboard/src/features/catalog/components/plan-detail-page.tsx b/apps/dashboard/src/features/catalog/components/plan-detail-page.tsx index f9a5afe6..85cab36c 100644 --- a/apps/dashboard/src/features/catalog/components/plan-detail-page.tsx +++ b/apps/dashboard/src/features/catalog/components/plan-detail-page.tsx @@ -14,9 +14,9 @@ import { planQueryOptions, productsQueryOptions, } from "@/features/catalog/queries/catalog-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" -import { detectNestedScopeMismatch } from "@/features/organizations/types/nested-scope" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { detectNestedScopeMismatch } from "@/features/orgs/types/nested-scope" import { projectQueryOptions } from "@/features/projects/queries/projects-query" interface PlanDetailPageProps { @@ -58,8 +58,8 @@ export function PlanDetailPage({ organizationId, planId, projectId }: PlanDetail permissionAction: ( prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" > Return to Project @@ -104,8 +104,8 @@ export function PlanDetailPage({ organizationId, planId, projectId }: PlanDetail ({ ...prev, productId: product.id })} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/catalog/products/$productId" > View Product diff --git a/apps/dashboard/src/features/catalog/components/plans-page.tsx b/apps/dashboard/src/features/catalog/components/plans-page.tsx index 28c0ddc8..2a1f96b8 100644 --- a/apps/dashboard/src/features/catalog/components/plans-page.tsx +++ b/apps/dashboard/src/features/catalog/components/plans-page.tsx @@ -1,16 +1,27 @@ +import { useState } from "react" import { useForm } from "@tanstack/react-form" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" import { Field, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" import { createPlanMutationOptions } from "@/features/catalog/mutations/catalog-mutations" import { plansQueryOptions } from "@/features/catalog/queries/catalog-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" interface PlansPageProps { @@ -24,6 +35,7 @@ export function PlansPage({ organizationId, projectId }: PlansPageProps) { const plans = useQuery({ ...plansQueryOptions(projectId), enabled: scopeReady }) const mutation = useMutation(createPlanMutationOptions(projectId, queryClient)) const items = plans.data?.items ?? [] + const [createOpen, setCreateOpen] = useState(false) const form = useForm({ defaultValues: { description: "", key: "", name: "" }, onSubmit: async ({ value }) => { @@ -33,6 +45,7 @@ export function PlansPage({ organizationId, projectId }: PlansPageProps) { name: value.name.trim(), }) form.reset() + setCreateOpen(false) }, }) const state = resolveHostedQueryState({ @@ -64,47 +77,34 @@ export function PlansPage({ organizationId, projectId }: PlansPageProps) { ) } - return ( - { + setCreateOpen(open) + if (!open) { + form.reset() + mutation.reset() + } + }} + open={createOpen} > - - -
    - {items.map((plan) => ( -
  • -

    {plan.name}

    -

    {plan.key}

    -

    - {plan.description ?? "No description"} -

    - - Manage Products - -
  • - ))} -
-
-
- {canManagePlans ? ( - }>Create Plan + +
{ + event.preventDefault() + event.stopPropagation() + void form.handleSubmit() + }} > - { - event.preventDefault() - event.stopPropagation() - void form.handleSubmit() - }} - > + + Create Plan + + A Plan is what you sell; Products are its monthly, yearly, or lifetime purchase + options. + + +
{(field) => ( @@ -144,17 +144,52 @@ export function PlansPage({ organizationId, projectId }: PlansPageProps) { )} -
+ + }>Cancel + - - {mutation.error ? ( -

- {mutation.error.message} -

- ) : null} +
+ +
+ + ) + + return ( + + + +
    + {items.map((plan) => ( +
  • +

    {plan.name}

    +

    {plan.key}

    +

    + {plan.description ?? "No description"} +

    + ({ ...prev, planId: plan.id })} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/catalog/plans/$planId" + > + Manage Products + +
  • + ))} +
- ) : null} +
) } diff --git a/apps/dashboard/src/features/catalog/components/product-detail-page.tsx b/apps/dashboard/src/features/catalog/components/product-detail-page.tsx index 251f91e7..73e57e9e 100644 --- a/apps/dashboard/src/features/catalog/components/product-detail-page.tsx +++ b/apps/dashboard/src/features/catalog/components/product-detail-page.tsx @@ -6,6 +6,13 @@ import { useState } from "react" import { Button } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button-variants" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" import { ProductReadinessPanel } from "@/features/catalog/components/product-readiness-panel" @@ -41,9 +48,9 @@ import { readinessStateLabel, } from "@/features/catalog/types/connected-product-view" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" -import { detectNestedScopeMismatch } from "@/features/organizations/types/nested-scope" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { detectNestedScopeMismatch } from "@/features/orgs/types/nested-scope" import { providerConnectionsQueryOptions } from "@/features/provider-connections/queries/provider-connection-queries" import { applicationsQueryOptions, @@ -187,8 +194,8 @@ export function ProductDetailPage({ permissionAction: ( prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" > Return to Project @@ -203,6 +210,24 @@ export function ProductDetailPage({ productId, product.data?.type, ) + const replacementSelectOptions = replacementOptions.map((item) => ({ + label: item.internalName, + value: item.id, + })) + const readinessEnvironmentOptions = [ + { label: "Select Environment", value: "" }, + ...(environments.data?.items ?? []).map((environment) => ({ + label: `${environment.name} · ${environment.mode}`, + value: environment.id, + })), + ] + const readinessApplicationOptions = [ + { label: "Select Application", value: "" }, + ...(applications.data?.items ?? []).map((application) => ({ + label: `${application.name} · ${application.platform.toUpperCase()}`, + value: application.id, + })), + ] const effectiveReplacementId = selectedReplacementId ?? product.data?.replacementProductId const replacementNeedsSave = Boolean( selectedReplacementId && selectedReplacementId !== product.data?.replacementProductId, @@ -230,7 +255,7 @@ export function ProductDetailPage({ : undefined, } }) ?? [] - const manageProvidersHref = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/catalog/providers` + const manageProvidersHref = `/orgs/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/catalog/providers` async function confirmArchive() { try { @@ -320,46 +345,54 @@ export function ProductDetailPage({ title="Readiness scope" >
- -
+
+ + - + + + + + {readinessApplicationOptions.map((option) => ( + + {option.label} + + ))} + + +
{!hasExplicitReadinessScope ? (

@@ -385,7 +418,7 @@ export function ProductDetailPage({ {connectedReadiness ? ( Ask an Owner or Admin to change Access grants @@ -487,7 +520,7 @@ export function ProductDetailPage({ queryClient.fetchQuery(providerMappingUsageQueryOptions(mappingId)) } manageProvidersHref={manageProvidersHref} - membersHref={`/organizations/${encodeURIComponent(organizationId)}/members`} + membersHref={`/orgs/${encodeURIComponent(organizationId)}/members`} mappings={mappingViews} onArchive={async (mappingId) => { await archiveMapping.mutateAsync(mappingId) @@ -535,7 +568,7 @@ export function ProductDetailPage({ {!access.canManage ? ( Ask an Owner or Admin to change Product lifecycle @@ -564,23 +597,25 @@ export function ProductDetailPage({ : "This Product has no known usage and can be archived safely."}

{usageCount > 0 ? ( - + + + + + {replacementSelectOptions.map((option) => ( + + {option.label} + + ))} + + + ) : null} {usageCount > 0 && product.data?.replacementProductId ? (

@@ -592,8 +627,8 @@ export function ProductDetailPage({

prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/catalog/products" > Create Replacement Product diff --git a/apps/dashboard/src/features/catalog/components/products-page.tsx b/apps/dashboard/src/features/catalog/components/products-page.tsx index f10a9375..5651e13c 100644 --- a/apps/dashboard/src/features/catalog/components/products-page.tsx +++ b/apps/dashboard/src/features/catalog/components/products-page.tsx @@ -1,20 +1,57 @@ +import { useState } from "react" import { useForm } from "@tanstack/react-form" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" import { Button } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button-variants" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" import { createProductMutationOptions } from "@/features/catalog/mutations/catalog-mutations" import { productsQueryOptions, type ProductFilters } from "@/features/catalog/queries/catalog-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" import { describeReturnDestination } from "@/lib/routing/workspace-hrefs" +const PRODUCT_TYPE_OPTIONS = [ + { label: "Subscription", value: "subscription" }, + { label: "One-time non-consumable", value: "one_time_non_consumable" }, +] + +const STATUS_FILTER_OPTIONS = [ + { label: "All statuses", value: "" }, + { label: "Draft", value: "draft" }, + { label: "Connected", value: "connected" }, + { label: "Attention required", value: "attention_required" }, + { label: "Archived", value: "archived" }, +] + +const TYPE_FILTER_OPTIONS = [ + { label: "All types", value: "" }, + { label: "Subscription", value: "subscription" }, + { label: "One-time", value: "one_time_non_consumable" }, +] + interface ProductsPageProps { filters: ProductFilters onFiltersChange: (filters: ProductFilters) => void @@ -40,6 +77,7 @@ export function ProductsPage({ const products = useQuery({ ...productsQueryOptions(projectId, filters), enabled: scopeReady }) const mutation = useMutation(createProductMutationOptions(projectId, queryClient)) const items = products.data?.items ?? [] + const [createOpen, setCreateOpen] = useState(false) const form = useForm({ defaultValues: { description: "", @@ -55,6 +93,7 @@ export function ProductsPage({ type: value.type, }) form.reset() + setCreateOpen(false) }, }) const state = resolveHostedQueryState({ @@ -90,8 +129,118 @@ export function ProductsPage({ ) } + const createDialog = ( + { + setCreateOpen(open) + if (!open) { + form.reset() + mutation.reset() + } + }} + open={createOpen} + > + }>Create Product + +
{ + event.preventDefault() + event.stopPropagation() + void form.handleSubmit() + }} + > + + Create Product + + Create a provider-neutral Product manually or import synchronized provider metadata + from Purchase setup. + + +
+ + {(field) => ( + + Internal name + field.handleChange(event.target.value)} + placeholder="Monthly" + value={field.state.value} + /> + + )} + + + {(field) => ( + + Key + field.handleChange(event.target.value.toLowerCase())} + placeholder="monthly" + value={field.state.value} + /> + + )} + + + {(field) => ( + + Type + + + )} + + + {(field) => ( + + Description + field.handleChange(event.target.value)} + value={field.state.value} + /> + Optional Mosaic-owned metadata. + + )} + + {mutation.error ? ( +

+ {mutation.error.message} +

+ ) : null} +
+ + }>Cancel + + +
+
+
+ ) + return ( prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/catalog/providers" > Review Purchase setup @@ -132,42 +281,54 @@ export function ProductsPage({ value={filters.search ?? ""} /> - -
+
+ + - + + + + + {TYPE_FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
@@ -185,9 +346,9 @@ export function ProductsPage({ ({ ...prev, productId: product.id })} search={returnTo ? { returnTo } : {}} - to="/organizations/$organizationId/projects/$projectId/catalog/products/$productId" + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/catalog/products/$productId" > {returnTo ? "Open mappings" : "View usage"} @@ -196,89 +357,6 @@ export function ProductsPage({ - {canManageProducts ? ( - -
{ - event.preventDefault() - event.stopPropagation() - void form.handleSubmit() - }} - > - - {(field) => ( - - Internal name - field.handleChange(event.target.value)} - placeholder="Monthly" - value={field.state.value} - /> - - )} - - - {(field) => ( - - Key - field.handleChange(event.target.value.toLowerCase())} - placeholder="monthly" - value={field.state.value} - /> - - )} - - - {(field) => ( - - Type - - - )} - - - {(field) => ( - - Description - field.handleChange(event.target.value)} - value={field.state.value} - /> - Optional Mosaic-owned metadata. - - )} - - -
- {mutation.error ? ( -

- {mutation.error.message} -

- ) : null} -
- ) : null} ) } diff --git a/apps/dashboard/src/features/entitlement-grants/components/grant-versions-page.tsx b/apps/dashboard/src/features/entitlement-grants/components/grant-versions-page.tsx index dd29ed2a..313358b3 100644 --- a/apps/dashboard/src/features/entitlement-grants/components/grant-versions-page.tsx +++ b/apps/dashboard/src/features/entitlement-grants/components/grant-versions-page.tsx @@ -2,6 +2,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { EmptyState } from "@/components/feedback/empty-state" import { buttonVariants } from "@/components/ui/button-variants" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" import { DefinitionRow, StatusPill } from "@/features/billing-ledger/components/billing-chrome" @@ -20,8 +27,8 @@ import { grantPolicyFields, grantPolicyLabel, } from "@/features/entitlement-grants/types/grant-version-view" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" -import { WorkflowPanel, WorkspacePage } from "@/features/organizations/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" +import { WorkflowPanel, WorkspacePage } from "@/features/orgs/components/workspace-page" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" import { useOrganizationAccess } from "@/hooks/use-organization-access" import { catalogProductsHref } from "@/lib/routing/workspace-hrefs" @@ -98,6 +105,17 @@ export function GrantVersionsPage({ } const productsHref = catalogProductsHref({ organizationId, projectId }) ?? "#" + const productOptions = productItems.map((product) => ({ + label: product.internalName, + value: product.id, + })) + const entitlementOptions = [ + { label: "All Entitlements", value: "" }, + ...(entitlements.data?.items ?? []).map((entitlement) => ({ + label: entitlement.name, + value: entitlement.id, + })), + ] const grouped = groupByEntitlement(versions.data ?? []) return ( @@ -128,42 +146,59 @@ export function GrantVersionsPage({ <>
- -
+
+ + - + + + + + {entitlementOptions.map((option) => ( + + {option.label} + + ))} + + +
Ask an Owner or Admin to change what this Product grants diff --git a/apps/dashboard/src/features/entitlement-grants/components/publish-grant-version-wizard.tsx b/apps/dashboard/src/features/entitlement-grants/components/publish-grant-version-wizard.tsx index d7c09719..347a0229 100644 --- a/apps/dashboard/src/features/entitlement-grants/components/publish-grant-version-wizard.tsx +++ b/apps/dashboard/src/features/entitlement-grants/components/publish-grant-version-wizard.tsx @@ -4,14 +4,21 @@ import { Button } from "@/components/ui/button" import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { - Sheet, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet" + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" import { evaluatePublishGate, @@ -26,9 +33,6 @@ import { } from "@/features/entitlement-grants/types/grant-version-view" import type { Entitlement, GrantVersionImpact, Product } from "@/generated/api" -const fieldClass = - "border-input bg-background focus-visible:border-ring focus-visible:ring-ring/40 h-9 w-full rounded border px-3 text-sm outline-none focus-visible:ring-3" - type PurchaseType = "auto_renewable_subscription" | "non_consumable" interface PublishGrantVersionWizardProps { @@ -102,6 +106,15 @@ export function PublishGrantVersionWizard({ }) } + const productOptions = products.map((product) => ({ + label: product.internalName, + value: product.id, + })) + const entitlementOptions = entitlements.map((entitlement) => ({ + label: entitlement.name, + value: entitlement.id, + })) + const gate = evaluatePublishGate({ canManage, impact, @@ -148,24 +161,24 @@ export function PublishGrantVersionWizard({ } return ( - { setOpen(nextOpen) if (!nextOpen) reset() }} open={open} > - }> + }> {triggerLabel} - - - - Create a new grant version - + + + + Create a new grant version + Published versions are never edited. This creates the next version and closes the current one at exactly its start, so the two intervals abut with no gap and no overlap. - - + +
    @@ -178,34 +191,42 @@ export function PublishGrantVersionWizard({ <> Product - update({ productId: value })} value={proposal.productId} > - {products.map((product) => ( - - ))} - + + + + + {productOptions.map((option) => ( + + {option.label} + + ))} + + Entitlement - update({ entitlementId: value })} value={proposal.entitlementId} > - {entitlements.map((entitlement) => ( - - ))} - + + + + + {entitlementOptions.map((option) => ( + + {option.label} + + ))} + + One version history belongs to one (Product, Entitlement) pair. @@ -368,7 +389,7 @@ export function PublishGrantVersionWizard({ ) : null}
- +
{step === "shape" ? ( +
+ + ) + } + + if (!active) return null + + return ( + + + + +
+ +
+ {active.name} +
+
+ + + } + /> + + + + Environments + + + {items.map((environment) => { + const target = pathFor(environment.id) + + return ( + remember(environment.id) : () => select(environment.id)} + render={target ? : undefined} + > + + {environment.id === active.id ? ( + + ) : null} + + {environment.name} + + {environment.mode} + + + ) + })} + +
+
+
+ ) +} diff --git a/apps/dashboard/src/features/environments/components/environments-page.tsx b/apps/dashboard/src/features/environments/components/environments-page.tsx index 981551b5..0920e079 100644 --- a/apps/dashboard/src/features/environments/components/environments-page.tsx +++ b/apps/dashboard/src/features/environments/components/environments-page.tsx @@ -3,8 +3,8 @@ import { useQuery } from "@tanstack/react-query" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" -import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" interface EnvironmentsPageProps { diff --git a/apps/dashboard/src/features/environments/components/monetization-workspace.tsx b/apps/dashboard/src/features/environments/components/monetization-workspace.tsx index 709b66ea..98843c53 100644 --- a/apps/dashboard/src/features/environments/components/monetization-workspace.tsx +++ b/apps/dashboard/src/features/environments/components/monetization-workspace.tsx @@ -1,14 +1,13 @@ import { useQuery } from "@tanstack/react-query" -import { Link, useNavigate } from "@tanstack/react-router" +import { Link } from "@tanstack/react-router" import type { ReactNode } from "react" import { buttonVariants } from "@/components/ui/button-variants" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { WorkspacePage } from "@/features/organizations/components/workspace-page" +import { WorkspacePage } from "@/features/orgs/components/workspace-page" import { projectQueryOptions } from "@/features/projects/queries/projects-query" -import { cn } from "@/lib/utils" export type MonetizationSurface = "assets" | "experiments" | "paywalls" | "placements" | "releases" @@ -23,25 +22,15 @@ interface MonetizationWorkspaceProps { title: string } -const tabs = [ - { label: "Paywalls", surface: "paywalls" }, - { label: "Placements", surface: "placements" }, - { label: "Experiments", surface: "experiments" }, - { label: "Assets", surface: "assets" }, - { label: "Publish history", surface: "releases" }, -] as const export function MonetizationWorkspace({ actions, children, description, environmentId, - organizationId, projectId, - surface, title, }: MonetizationWorkspaceProps) { - const navigate = useNavigate() const project = useQuery(projectQueryOptions(projectId)) const environments = useQuery(environmentsQueryOptions(projectId)) const items = environments.data?.items ?? [] @@ -60,8 +49,8 @@ export function MonetizationWorkspace({ permissionAction: ( prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" > Return to project @@ -70,37 +59,6 @@ export function MonetizationWorkspace({ "Project membership with Environment access is required to manage monetization.", }) - function changeEnvironment(nextEnvironmentId: string) { - const params = { environmentId: nextEnvironmentId, organizationId, projectId } - switch (surface) { - case "assets": - return navigate({ - params, - to: "/organizations/$organizationId/projects/$projectId/monetization/$environmentId/assets", - }) - case "placements": - return navigate({ - params, - to: "/organizations/$organizationId/projects/$projectId/monetization/$environmentId/placements", - }) - case "experiments": - return navigate({ - params, - to: "/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments", - }) - case "releases": - return navigate({ - params, - to: "/organizations/$organizationId/projects/$projectId/monetization/$environmentId/releases", - }) - case "paywalls": - return navigate({ - params, - to: "/organizations/$organizationId/projects/$projectId/monetization/$environmentId/paywalls", - }) - } - } - return ( -
-
- -

- Drafts, bindings, publishing, and history stay isolated to this Environment. -

-
- -
{children}
diff --git a/apps/dashboard/src/features/environments/hooks/use-active-environment.ts b/apps/dashboard/src/features/environments/hooks/use-active-environment.ts new file mode 100644 index 00000000..c98d2c45 --- /dev/null +++ b/apps/dashboard/src/features/environments/hooks/use-active-environment.ts @@ -0,0 +1,119 @@ +import * as React from "react" + +import { useNavigate, useRouterState } from "@tanstack/react-router" +import { useQuery } from "@tanstack/react-query" + +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { + rememberEnvironmentId, + rememberedEnvironmentId, + subscribeToActiveEnvironment, +} from "@/features/environments/types/active-environment-store" +import { + environmentAlias, + environmentForAlias, +} from "@/features/environments/types/environment-alias" +import { switchEnvironmentPath } from "@/features/environments/types/environment-path" +import { readWorkspaceScope } from "@/features/orgs/types/workspace-navigation" + +/** + * The single place to ask "which Environment am I working in?". Pages and queries + * read it here instead of threading an environmentId prop down from a route. + * + * Precedence is URL, then the remembered choice, then the Project's first + * Environment. The URL winning is what keeps a shared link honest; the remembered + * choice is what makes Catalog, Apps, and other Environment-less surfaces able to + * answer the question at all. + * + * `inPath` tells a caller whether the current route actually carries the + * Environment. Anything that must not act on an inherited Environment — provider + * and purchase setup, which refuse to default — should require it. + */ +export function useActiveEnvironment() { + const navigate = useNavigate() + const pathname = useRouterState({ select: (state) => state.location.pathname }) + const scope = readWorkspaceScope(pathname) + const projectId = scope.projectId ?? "" + + const query = useQuery({ + ...environmentsQueryOptions(projectId), + enabled: projectId.length > 0, + }) + // Stable identity: pathFor closes over this, and a fresh array every render would + // rebuild the callback and every memo downstream of it. + const items = React.useMemo(() => query.data?.items ?? [], [query.data?.items]) + + const rememberedId = React.useSyncExternalStore( + subscribeToActiveEnvironment, + () => rememberedEnvironmentId(projectId), + () => undefined, + ) + + // This is the mapping point: the alias in the address resolves to the Environment + // here, so no page has to know that the API wants an id. Candidates are validated + // against the Project's own Environments, so neither a stale remembered id nor a + // malformed segment can be mistaken for a scope. + const fromPath = environmentForAlias(items, scope.environmentSegment) + const fromMemory = items.find((environment) => environment.id === rememberedId) + const active = fromPath ?? fromMemory ?? items[0] + + // Visiting an Environment-scoped page is itself a choice; remembering it is what + // carries the Environment onto the surfaces that have none. + React.useEffect(() => { + if (fromPath) rememberEnvironmentId(projectId, fromPath.id) + }, [projectId, fromPath]) + + /** Where this route would live under another Environment, or null if it does not name one. */ + const pathFor = React.useCallback( + (environmentId: string) => { + if (!fromPath) return null + const next = items.find((environment) => environment.id === environmentId) + if (!next) return null + + return switchEnvironmentPath(pathname, environmentAlias(fromPath), environmentAlias(next)) + }, + [fromPath, items, pathname], + ) + + /** Records the choice without navigating, for callers that render their own link. */ + const remember = React.useCallback( + (environmentId: string) => rememberEnvironmentId(projectId, environmentId), + [projectId], + ) + + const select = React.useCallback( + (environmentId: string) => { + remember(environmentId) + + // On a route that carries the Environment, the URL is the authority and has + // to move too, keeping the surface and its search intact. + const target = pathFor(environmentId) + if (target) void navigate({ search: true, to: target }) + }, + [navigate, pathFor, remember], + ) + + return { + active, + activeId: active?.id, + /** + * The Environment as an address names it. Components hold ids and routes carry + * aliases, so this is the translation in the direction links need. + */ + alias: active ? environmentAlias(active) : "", + inPath: Boolean(fromPath), + items, + /** + * Only what the address itself names. A route must resolve from this, never from + * `active`: falling back to a remembered Environment would render one page while + * the address described another. + */ + pathEnvironment: fromPath, + organizationId: scope.organizationId, + pathFor, + projectId, + query, + remember, + select, + } +} diff --git a/apps/dashboard/src/features/environments/hooks/use-route-environment.tsx b/apps/dashboard/src/features/environments/hooks/use-route-environment.tsx new file mode 100644 index 00000000..bb841a4f --- /dev/null +++ b/apps/dashboard/src/features/environments/hooks/use-route-environment.tsx @@ -0,0 +1,42 @@ +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" +import { useActiveEnvironment } from "@/features/environments/hooks/use-active-environment" + +/** + * Turns the Environment alias in the address into the id a page needs. + * + * Routes carry `prod`, `staging`, or `dev`; the API takes ids. Resolving the two + * requires the Project's Environment list, so it is asynchronous, and a route + * cannot hand an id to its page until that read lands. This is the one place that + * waits, so no page has to: + * + * const { environmentId, fallback } = useRouteEnvironment() + * if (!environmentId) return fallback + * + * `fallback` reports loading, an unreachable API, and an alias the Project has no + * Environment for, which is what a hand-edited or stale address looks like. + */ +export function useRouteEnvironment() { + const { pathEnvironment, query } = useActiveEnvironment() + + if (pathEnvironment) return { environmentId: pathEnvironment.id, fallback: undefined } + + const state = resolveHostedQueryState({ + emptyDescription: + "This address names an environment the project does not have. Choose one from the sidebar.", + emptyTitle: "Environment not found", + error: query.error, + // Resolved against the address only. Standing in a remembered Environment here + // would show one Environment while the address named another. + isEmpty: query.isSuccess, + isPending: query.isPending, + loadingDescription: "Loading the project's environments.", + onRetry: () => void query.refetch(), + permissionDescription: "Project membership is required to read this environment.", + }) + + return { + environmentId: undefined, + fallback: {null}, + } +} diff --git a/apps/dashboard/src/features/environments/types/active-environment-store.test.ts b/apps/dashboard/src/features/environments/types/active-environment-store.test.ts new file mode 100644 index 00000000..f1124dd9 --- /dev/null +++ b/apps/dashboard/src/features/environments/types/active-environment-store.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { + rememberEnvironmentId, + rememberedEnvironmentId, + resetActiveEnvironmentStore, + subscribeToActiveEnvironment, +} from "@/features/environments/types/active-environment-store" + +afterEach(() => { + resetActiveEnvironmentStore() + window.localStorage.clear() + vi.restoreAllMocks() +}) + +describe("active environment store", () => { + it("keeps one value per Project", () => { + rememberEnvironmentId("prj_01", "env_prod") + rememberEnvironmentId("prj_02", "env_dev") + + expect(rememberedEnvironmentId("prj_01")).toBe("env_prod") + expect(rememberedEnvironmentId("prj_02")).toBe("env_dev") + expect(rememberedEnvironmentId("prj_unknown")).toBeUndefined() + }) + + it("returns a stable primitive so a subscribing reader does not loop", () => { + rememberEnvironmentId("prj_01", "env_prod") + + // useSyncExternalStore compares snapshots by identity; an object would make + // every read look like a change. + expect(rememberedEnvironmentId("prj_01")).toBe(rememberedEnvironmentId("prj_01")) + }) + + it("notifies readers on a change, and not on a repeat of the same value", () => { + const listener = vi.fn() + subscribeToActiveEnvironment(listener) + listener.mockClear() + + rememberEnvironmentId("prj_01", "env_prod") + expect(listener).toHaveBeenCalledTimes(1) + + rememberEnvironmentId("prj_01", "env_prod") + expect(listener).toHaveBeenCalledTimes(1) + + rememberEnvironmentId("prj_01", "env_dev") + expect(listener).toHaveBeenCalledTimes(2) + }) + + it("stops notifying after unsubscribe", () => { + const listener = vi.fn() + const unsubscribe = subscribeToActiveEnvironment(listener) + unsubscribe() + listener.mockClear() + + rememberEnvironmentId("prj_01", "env_prod") + + expect(listener).not.toHaveBeenCalled() + }) + + it("recovers the choice from storage on the first subscription", () => { + window.localStorage.setItem("mosaic.activeEnvironment.prj_01", "env_prod") + + // Hydration is deferred to subscribe, which runs in an effect: reading storage + // during render would report a value the server-rendered HTML did not have. + expect(rememberedEnvironmentId("prj_01")).toBeUndefined() + subscribeToActiveEnvironment(() => {}) + expect(rememberedEnvironmentId("prj_01")).toBe("env_prod") + }) + + it("ignores unrelated storage keys", () => { + window.localStorage.setItem("unrelated.prj_01", "env_prod") + subscribeToActiveEnvironment(() => {}) + + expect(rememberedEnvironmentId("prj_01")).toBeUndefined() + }) + + it("keeps working in memory when storage refuses the write", () => { + vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { + throw new Error("quota exceeded") + }) + + expect(() => rememberEnvironmentId("prj_01", "env_prod")).not.toThrow() + expect(rememberedEnvironmentId("prj_01")).toBe("env_prod") + }) + + it("refuses a blank Project or Environment rather than storing an empty key", () => { + rememberEnvironmentId("", "env_prod") + rememberEnvironmentId("prj_01", "") + + expect(rememberedEnvironmentId("")).toBeUndefined() + expect(rememberedEnvironmentId("prj_01")).toBeUndefined() + }) +}) diff --git a/apps/dashboard/src/features/environments/types/active-environment-store.ts b/apps/dashboard/src/features/environments/types/active-environment-store.ts new file mode 100644 index 00000000..9cdd2cb4 --- /dev/null +++ b/apps/dashboard/src/features/environments/types/active-environment-store.ts @@ -0,0 +1,80 @@ +/** + * The remembered Environment per Project, held in memory so every reader sees one + * value, and mirrored to localStorage so a reload does not lose the choice. + * + * This is deliberately not the source of truth. A route that carries an + * Environment outranks it (see useActiveEnvironment), which is what keeps a shared + * link unambiguous: the recipient sees the Environment in the URL, not whichever + * one their own browser happens to remember. + */ + +const STORAGE_PREFIX = "mosaic.activeEnvironment." + +const remembered = new Map() +const listeners = new Set<() => void>() + +let hydrated = false + +function storageKey(projectId: string) { + return `${STORAGE_PREFIX}${projectId}` +} + +function notify() { + for (const listener of listeners) listener() +} + +/** + * Reads persisted choices once, on the first subscription. Subscription happens in + * an effect, so this never runs during render or hydration: a getSnapshot that + * touched storage would report a value the server-rendered HTML did not have. + */ +function hydrateOnce() { + if (hydrated) return + hydrated = true + if (typeof window === "undefined") return + + try { + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index) + if (!key?.startsWith(STORAGE_PREFIX)) continue + const value = window.localStorage.getItem(key) + if (value) remembered.set(key.slice(STORAGE_PREFIX.length), value) + } + } catch { + // Storage can be unavailable or full. The in-memory value still works for + // the session; only persistence across reloads is lost. + } +} + +export function subscribeToActiveEnvironment(listener: () => void) { + hydrateOnce() + listeners.add(listener) + if (remembered.size > 0) listener() + return () => listeners.delete(listener) +} + +/** A primitive snapshot, so useSyncExternalStore never sees a new identity. */ +export function rememberedEnvironmentId(projectId: string) { + return projectId ? remembered.get(projectId) : undefined +} + +export function rememberEnvironmentId(projectId: string, environmentId: string) { + if (!projectId || !environmentId) return + if (remembered.get(projectId) === environmentId) return + + remembered.set(projectId, environmentId) + try { + window.localStorage.setItem(storageKey(projectId), environmentId) + } catch { + // See hydrateOnce: persistence is best effort. + } + notify() +} + +/** Test seam. Production code has no reason to discard the choice. */ +export function resetActiveEnvironmentStore() { + remembered.clear() + hydrated = false + notify() + listeners.clear() +} diff --git a/apps/dashboard/src/features/environments/types/environment-alias.test.ts b/apps/dashboard/src/features/environments/types/environment-alias.test.ts new file mode 100644 index 00000000..c428fa29 --- /dev/null +++ b/apps/dashboard/src/features/environments/types/environment-alias.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest" + +import type { Environment } from "@/generated/api" +import { + environmentAlias, + environmentForAlias, +} from "@/features/environments/types/environment-alias" + +const timestamps = { createdAt: "2026-07-30T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z" } + +function environment(id: string, key: string, mode: Environment["mode"]): Environment { + return { ...timestamps, id, key, mode, name: key, projectId: "prj_01" } +} + +const development = environment("env_01", "development", "development") +const staging = environment("env_02", "staging", "staging") +const production = environment("env_03", "production", "production") +const all = [development, staging, production] + +describe("environment aliases", () => { + it("shortens the seeded keys for the address", () => { + expect(environmentAlias(development)).toBe("dev") + expect(environmentAlias(staging)).toBe("staging") + expect(environmentAlias(production)).toBe("prod") + }) + + it("falls back to the key it cannot shorten", () => { + expect(environmentAlias(environment("env_04", "canary", "staging"))).toBe("canary") + }) + + it("resolves an alias back to its Environment", () => { + expect(environmentForAlias(all, "prod")).toBe(production) + expect(environmentForAlias(all, "dev")).toBe(development) + expect(environmentForAlias(all, "staging")).toBe(staging) + }) + + it("also accepts the underlying key", () => { + expect(environmentForAlias(all, "development")).toBe(development) + expect(environmentForAlias(all, "production")).toBe(production) + }) + + it("still accepts an id, so links minted before the change keep working", () => { + expect(environmentForAlias(all, "env_03")).toBe(production) + }) + + it("resolves nothing for an absent or unknown segment", () => { + expect(environmentForAlias(all, undefined)).toBeUndefined() + expect(environmentForAlias(all, "")).toBeUndefined() + // A literal from a sibling route must not resolve to an Environment. + expect(environmentForAlias(all, "connections")).toBeUndefined() + }) + + it("round-trips every seeded Environment", () => { + for (const value of all) { + expect(environmentForAlias(all, environmentAlias(value))).toBe(value) + } + }) +}) diff --git a/apps/dashboard/src/features/environments/types/environment-alias.ts b/apps/dashboard/src/features/environments/types/environment-alias.ts new file mode 100644 index 00000000..fb9897c6 --- /dev/null +++ b/apps/dashboard/src/features/environments/types/environment-alias.ts @@ -0,0 +1,53 @@ +import type { Environment } from "@/generated/api" + +/** + * The Environment as it appears in an address: `prod`, `staging`, `dev` rather than + * `env_01H8X...`. A URL an operator can read and type is worth more than an opaque + * id, and the id is an implementation detail the address has no reason to leak. + * + * A Project's Environments are seeded once, on creation, with the keys below and no + * endpoint adds more, so this mapping is total in practice. The fallback to the raw + * key keeps an address resolvable if that ever changes. + */ +const ALIAS_BY_KEY: Record = { + development: "dev", + production: "prod", + staging: "staging", +} + +/** + * Where a link lands when the linker has no Environment to go on — workspace entry, + * for instance, which resolves before any Environment list is read. Development on + * purpose: an address that guesses must never guess production. + */ +export const DEFAULT_ENVIRONMENT_ALIAS = "dev" + +const KEY_BY_ALIAS: Record = { + dev: "development", + prod: "production", + staging: "staging", +} + +export function environmentAlias(environment: Environment) { + return ALIAS_BY_KEY[environment.key] ?? environment.key +} + +/** + * Resolves an address segment to the Environment it names, by alias or by the + * underlying key. Ids are deliberately not accepted: the address does not carry + * them, so a segment that looks like one is a malformed link, not a scope. + */ +export function environmentForAlias( + environments: readonly Environment[], + alias: string | undefined, +): Environment | undefined { + if (!alias) return undefined + + const key = KEY_BY_ALIAS[alias] + return environments.find( + (environment) => + environmentAlias(environment) === alias || + environment.key === alias || + (key !== undefined && environment.key === key), + ) +} diff --git a/apps/dashboard/src/features/environments/types/environment-path.test.ts b/apps/dashboard/src/features/environments/types/environment-path.test.ts new file mode 100644 index 00000000..b0da2c1d --- /dev/null +++ b/apps/dashboard/src/features/environments/types/environment-path.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest" + +import { switchEnvironmentPath } from "@/features/environments/types/environment-path" + +const base = "/orgs/org_01/projects/prj_01" + +describe("switchEnvironmentPath", () => { + it("keeps the operator on the same surface", () => { + const cases: [string, string][] = [ + [`${base}/monetization/env_dev/paywalls`, `${base}/monetization/env_prod/paywalls`], + [ + `${base}/monetization/env_dev/experiments/exp_01`, + `${base}/monetization/env_prod/experiments/exp_01`, + ], + [`${base}/analytics/env_dev/funnel`, `${base}/analytics/env_prod/funnel`], + [`${base}/billing/env_dev/customers/cus_01`, `${base}/billing/env_prod/customers/cus_01`], + ] + + for (const [pathname, expected] of cases) { + expect(switchEnvironmentPath(pathname, "env_dev", "env_prod")).toBe(expected) + } + }) + + it("rewrites the addressed form by alias", () => { + const addressed = "/orgs/org_01/projects/prj_01/env" + + expect(switchEnvironmentPath(`${addressed}/dev/catalog/products`, "dev", "prod")).toBe( + `${addressed}/prod/catalog/products`, + ) + expect( + switchEnvironmentPath(`${addressed}/prod/billing/quarantine/rec_01`, "prod", "dev"), + ).toBe(`${addressed}/dev/billing/quarantine/rec_01`) + // The alias, not the id, is what the address carries, so an id must not match. + expect(switchEnvironmentPath(`${addressed}/prod/apps`, "env_03", "env_01")).toBeNull() + }) + + it("reports nothing to switch on a surface with no Environment", () => { + for (const pathname of [base, `${base}/apps`, `${base}/catalog/products`, "/workspace"]) { + expect(switchEnvironmentPath(pathname, "env_dev", "env_prod")).toBeNull() + } + }) + + it("does not mistake a literal segment for an Environment", () => { + // Anchoring on position alone would rewrite "connections" and "migrations", + // producing a route that does not exist. + for (const pathname of [ + `${base}/billing/connections/cred_01`, + `${base}/billing/migrations/prog_01`, + ]) { + expect(switchEnvironmentPath(pathname, "env_dev", "env_prod")).toBeNull() + } + }) + + it("rewrites only the Environment, even when another segment repeats its id", () => { + expect( + switchEnvironmentPath(`${base}/billing/env_dev/subscriptions/env_dev`, "env_dev", "env_prod"), + ).toBe(`${base}/billing/env_prod/subscriptions/env_dev`) + }) + + it("refuses an empty Environment id rather than building a path with an empty segment", () => { + expect(switchEnvironmentPath(`${base}/analytics/env_dev/funnel`, "env_dev", "")).toBeNull() + expect(switchEnvironmentPath(`${base}/analytics//funnel`, "", "env_prod")).toBeNull() + }) +}) diff --git a/apps/dashboard/src/features/environments/types/environment-path.ts b/apps/dashboard/src/features/environments/types/environment-path.ts new file mode 100644 index 00000000..a6b6ed58 --- /dev/null +++ b/apps/dashboard/src/features/environments/types/environment-path.ts @@ -0,0 +1,30 @@ +// Every Project surface names its Environment the same way: the segment after +// `env`, carrying a readable alias rather than an id. +// +// /orgs/O/projects/P/env/prod/billing/quarantine/rec_01 +const ENVIRONMENT_SEGMENT = "env" + +/** + * Rewrites the Environment alias in place, so switching keeps the operator on the + * surface they are reading. Returns null when the path names no Environment, which + * is how the caller knows there is nothing to switch. + * + * The match is anchored on the current alias rather than on position alone, so a + * path that happens to contain the word `env` elsewhere cannot be rewritten by + * accident. + */ +export function switchEnvironmentPath( + pathname: string, + currentAlias: string, + nextAlias: string, +): string | null { + if (currentAlias === "" || nextAlias === "") return null + + const segments = pathname.split("/") + const index = segments.indexOf(ENVIRONMENT_SEGMENT) + if (index === -1 || segments[index + 1] !== currentAlias) return null + + const rewritten = [...segments] + rewritten[index + 1] = nextAlias + return rewritten.join("/") +} diff --git a/apps/dashboard/src/features/experiments/components/experiment-builder.tsx b/apps/dashboard/src/features/experiments/components/experiment-builder.tsx index ff300186..8757f940 100644 --- a/apps/dashboard/src/features/experiments/components/experiment-builder.tsx +++ b/apps/dashboard/src/features/experiments/components/experiment-builder.tsx @@ -10,11 +10,18 @@ import { Button } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button-variants" import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { describeApiError } from "@/lib/api/errors" import { useOrganizationAccess } from "@/hooks/use-organization-access" import { useHostedPublishingAdapter } from "@/features/publishing/api/use-hosted-publishing-adapter" import { placementsQueryOptions } from "@/features/placements/queries/placement-queries" -import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { WorkflowPanel } from "@/features/orgs/components/workspace-page" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" import { ExperimentDraftConflictError } from "../api/experiment-adapter" import { useExperimentAdapter } from "../api/use-experiment-adapter" @@ -48,6 +55,12 @@ function splitFor(count: number) { ) } +const ASSIGNMENT_IDENTITY_OPTIONS = [ + { label: "Identified user (requires identity)", value: "identified_user" }, + { label: "Identified user, otherwise installation", value: "identified_user_or_installation" }, + { label: "Installation", value: "installation" }, +] + export function ExperimentBuilder({ environmentId, experiment, @@ -200,8 +213,8 @@ export function ExperimentBuilder({ } } await navigate({ - params: { environmentId, experimentId: target.id, organizationId, projectId }, - to: "/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/$experimentId", + params: (prev) => ({ ...prev, experimentId: target.id }), + to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/experiments/$experimentId", }) }, }) @@ -254,6 +267,33 @@ export function ExperimentBuilder({ } const versions = resources.data.paywallVersions + const placementOptions = [ + { label: "Choose Placement", value: "" }, + ...placements.data.map((placement) => ({ label: placement.name, value: placement.id })), + ] + const versionOptions = [ + { label: "Choose immutable Version", value: "" }, + ...versions.map((version) => ({ + label: `${version.paywallName} · v${version.versionNumber}`, + value: version.id, + })), + ] + const metricOptions = [ + { disabled: false, label: "Choose metric", value: "" }, + ...resources.data.metrics + .filter((metric) => metric.eligibleAsPrimary) + .map((metric) => ({ + disabled: !canSelectMetric(metric), + label: `${metric.name} · ${metric.authority.replace("_", " ")}${ + canSelectMetric(metric) ? "" : " · trusted source unavailable" + }`, + value: metric.versionId, + })), + ] + const groupOptions = [ + { label: "No group", value: "" }, + ...resources.data.groups.map((group) => ({ label: group.name, value: group.versionId })), + ] const error = saveMutation.error ?? createMutation.error const conflict = error instanceof ExperimentDraftConflictError ? error : undefined function applyEvenSplit(nextTreatmentCount = treatmentCount) { @@ -309,20 +349,26 @@ export function ExperimentBuilder({ {(field) => ( Placement - + + + + + {placementOptions.map((option) => ( + + {option.label} + + ))} + + )} @@ -355,8 +401,8 @@ export function ExperimentBuilder({

prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/paywalls" > Open Paywalls @@ -386,19 +432,22 @@ export function ExperimentBuilder({ {(field) => ( {role} Paywall Version - + + + + + {versionOptions.map((option) => ( + + {option.label} + + ))} + + )} @@ -470,20 +519,22 @@ export function ExperimentBuilder({ {(field) => ( Assignment identity - + + + + + {ASSIGNMENT_IDENTITY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + )} @@ -491,26 +542,26 @@ export function ExperimentBuilder({ {(field) => ( Primary metric - + + {resources.data.metrics.find( (metric) => metric.versionId === field.state.value, ) ? ( @@ -570,20 +621,22 @@ export function ExperimentBuilder({ {(field) => ( Mutual-exclusion group - + + + + + {groupOptions.map((option) => ( + + {option.label} + + ))} + +

{experiment ? "The selected immutable Group Version must contain this stable Experiment ID." diff --git a/apps/dashboard/src/features/experiments/components/experiment-results.tsx b/apps/dashboard/src/features/experiments/components/experiment-results.tsx index 790c4274..167802cb 100644 --- a/apps/dashboard/src/features/experiments/components/experiment-results.tsx +++ b/apps/dashboard/src/features/experiments/components/experiment-results.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query" import { LoadingState } from "@/components/feedback/loading-state" import { ErrorState } from "@/components/feedback/error-state" -import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { WorkflowPanel } from "@/features/orgs/components/workspace-page" import { useExperimentAdapter } from "../api/use-experiment-adapter" import { experimentResultsQueryOptions } from "../queries/experiment-queries" import { describeMetricEventFilter, type ExperimentScope } from "../types/experiment" diff --git a/apps/dashboard/src/features/experiments/components/experiment-workspace.tsx b/apps/dashboard/src/features/experiments/components/experiment-workspace.tsx index ea26a9e9..3b3a43ad 100644 --- a/apps/dashboard/src/features/experiments/components/experiment-workspace.tsx +++ b/apps/dashboard/src/features/experiments/components/experiment-workspace.tsx @@ -11,9 +11,16 @@ import { Button } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button-variants" import { Field, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { MonetizationWorkspace } from "@/features/environments/components/monetization-workspace" import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { WorkflowPanel } from "@/features/orgs/components/workspace-page" import { useOrganizationAccess } from "@/hooks/use-organization-access" import { cn } from "@/lib/utils" import { useExperimentAdapter } from "../api/use-experiment-adapter" @@ -179,13 +186,8 @@ function ImmutableActiveDefinition({

({ ...prev, paywallId: variant.paywallId })} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/paywalls/$paywallId" > Open Paywall @@ -222,8 +224,8 @@ function ImmutableActiveDefinition({

prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/experiments/new" > Create new Experiment @@ -569,6 +571,11 @@ function HistoryPanel({ ) } +const QA_IDENTITY_OPTIONS = [ + { label: "Identified user", value: "identified_user" }, + { label: "Installation", value: "installation" }, +] + function QaPanel({ activeVersionId, canManage, @@ -606,6 +613,10 @@ function QaPanel({ formApi.reset() }, }) + const variantOptions = variants.map((variant) => ({ + label: variant.name, + value: variant.id ?? "", + })) if (qa.isPending) return if (qa.error) return void qa.refetch()} /> @@ -646,18 +657,22 @@ function QaPanel({ {(field) => ( Forced Variant - + + + + + {variantOptions.map((option) => ( + + {option.label} + + ))} + + )} @@ -665,17 +680,22 @@ function QaPanel({ {(field) => ( Identity type - + + + + + {QA_IDENTITY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + )} diff --git a/apps/dashboard/src/features/experiments/components/experiments-page.tsx b/apps/dashboard/src/features/experiments/components/experiments-page.tsx index 8e70726d..1e18100e 100644 --- a/apps/dashboard/src/features/experiments/components/experiments-page.tsx +++ b/apps/dashboard/src/features/experiments/components/experiments-page.tsx @@ -1,4 +1,4 @@ -import { Plus } from "@phosphor-icons/react" +import { PlusIcon } from "@phosphor-icons/react" import { useQuery } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" @@ -41,10 +41,10 @@ export function ExperimentsPage({ const createLink = access.canManage ? ( prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/experiments/new" > - New Experiment + New Experiment ) : undefined @@ -66,8 +66,8 @@ export function ExperimentsPage({ ({ ...prev, experimentId: experiment.id })} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/experiments/$experimentId" >
diff --git a/apps/dashboard/src/features/experiments/components/mutual-exclusion-group-manager.tsx b/apps/dashboard/src/features/experiments/components/mutual-exclusion-group-manager.tsx index f47225a9..39bd1920 100644 --- a/apps/dashboard/src/features/experiments/components/mutual-exclusion-group-manager.tsx +++ b/apps/dashboard/src/features/experiments/components/mutual-exclusion-group-manager.tsx @@ -5,7 +5,14 @@ import { useState } from "react" import { Button } from "@/components/ui/button" import { Field, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" -import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { WorkflowPanel } from "@/features/orgs/components/workspace-page" import { useExperimentAdapter } from "../api/use-experiment-adapter" import { createExperimentGroupMutationOptions, @@ -18,6 +25,12 @@ import { } from "../queries/experiment-queries" import { validateMutualExclusionAllocation, type ExperimentScope } from "../types/experiment" +const GROUP_IDENTITY_OPTIONS = [ + { label: "Identified user", value: "identified_user" }, + { label: "Identified user, otherwise installation", value: "identified_user_or_installation" }, + { label: "Installation", value: "installation" }, +] + export function MutualExclusionGroupManager({ scope }: { scope: ExperimentScope }) { const adapter = useExperimentAdapter() const queryClient = useQueryClient() @@ -181,20 +194,22 @@ export function MutualExclusionGroupManager({ scope }: { scope: ExperimentScope {(field) => ( Group assignment identity - + + + + + {GROUP_IDENTITY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + )} diff --git a/apps/dashboard/src/features/members/components/members-page.tsx b/apps/dashboard/src/features/members/components/members-page.tsx index 0c6dee0e..81acc00b 100644 --- a/apps/dashboard/src/features/members/components/members-page.tsx +++ b/apps/dashboard/src/features/members/components/members-page.tsx @@ -1,25 +1,50 @@ +import { useState } from "react" import { useForm } from "@tanstack/react-form" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" import { addMemberMutationOptions } from "@/features/members/mutations/member-mutations" import { membersQueryOptions } from "@/features/members/queries/members-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" + +const ROLE_OPTIONS = [ + { label: "Member", value: "member" }, + { label: "Admin", value: "admin" }, +] export function MembersPage({ organizationId }: { organizationId: string }) { const queryClient = useQueryClient() const members = useQuery(membersQueryOptions(organizationId)) const mutation = useMutation(addMemberMutationOptions(organizationId, queryClient)) const items = members.data?.items ?? [] + const [addOpen, setAddOpen] = useState(false) const form = useForm({ defaultValues: { actorId: "", role: "member" as "admin" | "member" }, onSubmit: async ({ value }) => { await mutation.mutateAsync({ actorId: value.actorId.trim(), role: value.role }) form.reset() + setAddOpen(false) }, }) const state = resolveHostedQueryState({ @@ -35,38 +60,34 @@ export function MembersPage({ organizationId }: { organizationId: string }) { }) const canManageMembers = state.kind === "empty" || state.kind === "ready" - return ( - { + setAddOpen(open) + if (!open) { + form.reset() + mutation.reset() + } + }} + open={addOpen} > - - -
    - {items.map((membership) => ( -
  • - {membership.actorId} - - {membership.role} - -
  • - ))} -
-
-
- {canManageMembers ? ( - }>Add member + +
{ + event.preventDefault() + event.stopPropagation() + void form.handleSubmit() + }} > - { - event.preventDefault() - event.stopPropagation() - void form.handleSubmit() - }} - > + + Add member + + Invitations and email delivery remain deferred; this accepts an existing Actor ID + only. + + +
{(field) => ( @@ -85,31 +106,62 @@ export function MembersPage({ organizationId }: { organizationId: string }) { {(field) => ( Role - field.handleChange(value as "admin" | "member")} value={field.state.value} > - - - + + + + + {ROLE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + )} + {mutation.error ? ( +

+ {mutation.error.message} +

+ ) : null} +
+ + }>Cancel - - {mutation.error ? ( -

- {mutation.error.message} -

- ) : null} +
+ +
+ + ) + + return ( + + + +
    + {items.map((membership) => ( +
  • + {membership.actorId} + + {membership.role} + +
  • + ))} +
- ) : null} +
) } diff --git a/apps/dashboard/src/features/organizations/components/cloud-workspace-shell.tsx b/apps/dashboard/src/features/organizations/components/cloud-workspace-shell.tsx deleted file mode 100644 index 1eb90083..00000000 --- a/apps/dashboard/src/features/organizations/components/cloud-workspace-shell.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import { CodeIcon } from "@phosphor-icons/react/dist/ssr/Code" -import { ChartLineUpIcon } from "@phosphor-icons/react/dist/ssr/ChartLineUp" -import { GearSixIcon } from "@phosphor-icons/react/dist/ssr/GearSix" -import { KeyIcon } from "@phosphor-icons/react/dist/ssr/Key" -import { PackageIcon } from "@phosphor-icons/react/dist/ssr/Package" -import { ReceiptIcon } from "@phosphor-icons/react/dist/ssr/Receipt" -import { StorefrontIcon } from "@phosphor-icons/react/dist/ssr/Storefront" -import { SquaresFourIcon } from "@phosphor-icons/react/dist/ssr/SquaresFour" -import { UsersThreeIcon } from "@phosphor-icons/react/dist/ssr/UsersThree" -import { useQuery } from "@tanstack/react-query" -import { Link, useRouterState } from "@tanstack/react-router" - -import { NavMain } from "@/components/navigation/nav-main" -import { dashboardBuildInfo } from "@/config/environment" -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarRail, -} from "@/components/ui/sidebar" -import { readWorkspaceScope } from "@/features/organizations/types/workspace-navigation" -import { UserMenu } from "@/features/auth/components/user-menu" -import { billingSettingsQueryOptions } from "@/features/store-connections/queries/billing-settings-queries" -import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { useOrganizationAccess } from "@/hooks/use-organization-access" -import type { NavigationItem } from "@/components/navigation/nav-main" -import { OrganizationSwitcher } from "./organization-switcher" - -export function CloudWorkspaceShell() { - const pathname = useRouterState({ select: (state) => state.location.pathname }) - const scope = readWorkspaceScope(pathname) - const access = useOrganizationAccess(scope.organizationId ?? "") - - // Administrative surfaces are hidden until membership confirms management - // rights. The API remains the authority; this only prevents dead-end links. - const canManage = access.canManage - - // Mosaic Billing is per-Project opt-in. While it is off, its Environment - // surfaces have nothing to show, so the group collapses to the one page that - // can turn it on. Hiding the group outright would make billing unreachable. - const environments = useQuery({ - ...environmentsQueryOptions(scope.projectId ?? ""), - enabled: canManage && Boolean(scope.projectId), - }) - const probeEnvironmentId = scope.environmentId ?? environments.data?.items[0]?.id ?? "" - const billingSettings = useQuery({ - ...billingSettingsQueryOptions(scope.projectId ?? "", probeEnvironmentId), - enabled: canManage && Boolean(scope.projectId) && probeEnvironmentId.length > 0, - }) - // Unknown state shows the full group: a nav that hides itself because a probe - // failed is worse than one item too many. - const billingEnabled = billingSettings.data?.billingEnabled !== false - - function withManagement(items: NavigationItem[]) { - return canManage ? items : [] - } - - return ( - - - - - - {scope.projectId && ( - , - title: "Overview", - }, - { - to: scope.environmentId - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/monetization/${scope.environmentId}/paywalls` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, - icon: , - title: "Monetization", - }, - { - to: scope.environmentId - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/analytics/${scope.environmentId}/overview` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, - icon: , - title: "Analytics", - }, - { - to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/apps`, - icon: , - title: "Apps", - }, - { - icon: , - title: "Catalog", - subItems: [ - { - to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/catalog/plans`, - title: "Plans", - icon: <>, - }, - { - to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/catalog/products`, - title: "Products", - icon: <>, - }, - { - to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/catalog/entitlements`, - title: "Access", - icon: <>, - }, - // What a Product grants is versioned and immutable, so it is - // its own surface rather than a set of buttons on Product - // detail. It sits in Catalog because grant versions relate two - // Project-scoped things, Products and Entitlements. - { - to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/catalog/grant-versions`, - title: "Grant versions", - icon: <>, - }, - { - to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/catalog/providers`, - title: "Purchase setup", - icon: <>, - }, - ], - }, - // Mosaic Billing is per-Project opt-in and owner/admin only. The - // group is hidden from members who could not act on it, so it is - // never a dead end; Environment-scoped items fall back to the - // Project overview exactly as Monetization and Analytics do. - // "Store Server Credentials" is the frozen term, and every - // recovery link in billing uses it, so the nav does too. - ...withManagement([ - { - icon: , - title: "Billing", - subItems: [ - ...(billingEnabled - ? [ - // Customers leads the group. It answers the question - // operators actually arrive with — "does this person - // have access?" — which the ledger deliberately - // cannot. - { - to: scope.environmentId - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/customers` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, - title: "Customers", - icon: <>, - }, - { - to: scope.environmentId - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/identity-conflicts` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, - title: "Identity conflicts", - icon: <>, - }, - { - to: scope.environmentId - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/restores` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, - title: "Restores", - icon: <>, - }, - { - to: scope.environmentId - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/transactions` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, - title: "Transactions", - icon: <>, - }, - { - to: scope.environmentId - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/quarantine` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, - title: "Quarantine", - icon: <>, - }, - { - to: scope.environmentId - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/reconciliation` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, - title: "Reconciliation", - icon: <>, - }, - { - to: scope.environmentId - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/health` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, - title: "Billing health", - icon: <>, - }, - // A sibling of billing health, never a tab inside it: - // store input can be arriving perfectly while every - // customer is being told the wrong thing. - { - to: scope.environmentId - ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/projection-health` - : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, - title: "Projection health", - icon: <>, - }, - ] - : []), - { - to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/connections`, - title: billingEnabled ? "Store Server Credentials" : "Set up Mosaic Billing", - icon: <>, - }, - ], - }, - { - to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/settings/environments`, - icon: , - title: "Settings", - }, - { - to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/settings/api-keys`, - icon: , - title: "API keys", - }, - ]), - ]} - /> - )} - {scope.organizationId && canManage && ( - , - title: "Members", - }, - ]} - /> - )} - - - - - Mosaic {dashboardBuildInfo.version} · diagnostics - - - - - ) -} diff --git a/apps/dashboard/src/features/organizations/components/organization-switcher.test.tsx b/apps/dashboard/src/features/organizations/components/organization-switcher.test.tsx deleted file mode 100644 index 0ccb0801..00000000 --- a/apps/dashboard/src/features/organizations/components/organization-switcher.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from "@tanstack/react-router" -import { fireEvent, render, screen } from "@testing-library/react" -import { beforeAll, describe, expect, it } from "vitest" - -import { SidebarProvider } from "@/components/ui/sidebar" -import { OrganizationSwitcher } from "@/features/organizations/components/organization-switcher" -import { organizationKeys } from "@/features/organizations/queries/organizations-query" -import { ApiError } from "@/lib/api/errors" - -// The sidebar reads a media query to decide between its desktop and mobile -// presentation; jsdom does not implement matchMedia. -beforeAll(() => { - if (typeof window.matchMedia === "function") return - window.matchMedia = (query: string) => - ({ - addEventListener: () => {}, - addListener: () => {}, - dispatchEvent: () => false, - matches: false, - media: query, - onchange: null, - removeEventListener: () => {}, - removeListener: () => {}, - }) as MediaQueryList -}) - -function renderSwitcher(queryClient: QueryClient, organizationId?: string) { - const rootRoute = createRootRoute() - const routeTree = rootRoute.addChildren([ - createRoute({ - getParentRoute: () => rootRoute, - path: "/", - component: () => ( - - - - ), - }), - createRoute({ getParentRoute: () => rootRoute, path: "/organizations/new" }), - createRoute({ getParentRoute: () => rootRoute, path: "/organizations/$organizationId" }), - createRoute({ - getParentRoute: () => rootRoute, - path: "/organizations/$organizationId/projects/new", - }), - ]) - - const router = createRouter({ history: createMemoryHistory(), routeTree }) - - return render( - - - , - ) -} - -function seededClient(seed: (client: QueryClient) => void) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) - seed(client) - return client -} - -/** - * The switcher shipped non-functional: its items were not links, so an operator - * could not change Organization at all, and a failed list request rendered the - * "no organizations yet" branch, which reads as "your data is gone". - */ -describe("OrganizationSwitcher", () => { - it("navigates to each Organization and names the current one", async () => { - const client = seededClient((queryClient) => { - queryClient.setQueryData(organizationKeys.list(), { - items: [ - { id: "org_01", name: "Northwind" }, - { id: "org_02", name: "Contoso" }, - ], - }) - }) - - renderSwitcher(client, "org_02") - - const trigger = await screen.findByRole("button", { - name: /Current organization: Contoso/, - }) - fireEvent.click(trigger) - - const northwind = await screen.findByRole("menuitem", { name: "Northwind" }) - expect(northwind).toHaveAttribute("href", "/organizations/org_01") - expect(screen.getByRole("menuitem", { name: "Contoso" })).toHaveAttribute( - "href", - "/organizations/org_02", - ) - expect(screen.getByRole("menuitem", { name: "Add project" })).toHaveAttribute( - "href", - "/organizations/org_02/projects/new", - ) - }) - - it("offers a retry instead of claiming there are no Organizations when the list fails", async () => { - const client = seededClient((queryClient) => { - queryClient.setQueryDefaults(organizationKeys.list(), { - queryFn: () => - Promise.reject( - new ApiError("boom", { - code: "internal_error", - correlationId: "request_test", - retryable: true, - status: 500, - }), - ), - retry: false, - }) - }) - - renderSwitcher(client) - - const trigger = await screen.findByRole("button", { name: /Switch organization/ }) - fireEvent.click(trigger) - - expect(await screen.findByRole("button", { name: "Retry loading organizations" })).toBeVisible() - expect(screen.queryByText("You do not belong to an organization yet.")).toBeNull() - // "Add project" needs an Organization in scope; offering it here would be a - // dead link. - expect(screen.queryByRole("menuitem", { name: "Add project" })).toBeNull() - }) -}) diff --git a/apps/dashboard/src/features/organizations/components/organization-switcher.tsx b/apps/dashboard/src/features/organizations/components/organization-switcher.tsx deleted file mode 100644 index e5b37827..00000000 --- a/apps/dashboard/src/features/organizations/components/organization-switcher.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import * as React from "react" - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar" -import { CaretUpDownIcon } from "@phosphor-icons/react/dist/ssr/CaretUpDown" -import { PlusIcon } from "@phosphor-icons/react/dist/ssr/Plus" -import { organizationsQueryOptions } from "../queries/organizations-query" -import { useQuery } from "@tanstack/react-query" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" -import { Link } from "@tanstack/react-router" -import { describeApiError } from "@/lib/api/errors" - -const AVATAR_CLASSNAMES = - "bg-sidebar-primary text-sidebar-primary-foreground size-4 rounded data-[size=lg]:size-4 data-[size=sm]:size-4 after:rounded-none" - -// Base UI exposes the trigger width as --anchor-width on positioned popups. -const DROPDOWN_CLASSNAMES = "w-(--anchor-width) min-w-56 rounded" - -export function OrganizationSwitcher({ organizationId }: { organizationId?: string }) { - const { isMobile } = useSidebar() - const organizationsQuery = useQuery(organizationsQueryOptions()) - - const organizations = React.useMemo( - () => organizationsQuery.data?.items ?? [], - [organizationsQuery.data?.items], - ) - - const currentOrganization = React.useMemo( - () => organizations.find((organization) => organization.id === organizationId), - [organizations, organizationId], - ) - - const triggerLabel = currentOrganization - ? currentOrganization.name - : organizationsQuery.isPending - ? "Loading organizations" - : "Select organization" - - return ( - - - - - -
- - - {triggerLabel.charAt(0).toUpperCase()} - - -
- {triggerLabel} -
-
- - - } - /> - - - - Organizations - - - {organizationsQuery.isPending ? ( -

- Loading organizations… -

- ) : organizationsQuery.isError ? ( -
-

- {describeApiError(organizationsQuery.error).description} -

- -
- ) : organizations.length === 0 ? ( -

- You do not belong to an organization yet. -

- ) : ( - organizations.map((organization) => ( - - } - > - - - {organization.name.charAt(0).toUpperCase()} - - - {organization.name} - - )) - )} - - - - {organizationId ? ( - - } - > -
- -
-
- Add project -
-
- ) : null} - - }> -
- -
-
- Add organization -
-
-
-
-
-
-
- ) -} diff --git a/apps/dashboard/src/features/orgs/components/cloud-workspace-shell.test.tsx b/apps/dashboard/src/features/orgs/components/cloud-workspace-shell.test.tsx new file mode 100644 index 00000000..838a468a --- /dev/null +++ b/apps/dashboard/src/features/orgs/components/cloud-workspace-shell.test.tsx @@ -0,0 +1,109 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router" +import { render, screen } from "@testing-library/react" +import { beforeAll, describe, expect, it } from "vitest" + +import { SidebarProvider } from "@/components/ui/sidebar" +import { CloudWorkspaceShell } from "@/features/orgs/components/cloud-workspace-shell" +import { environmentKeys } from "@/features/environments/queries/environments-query" +import { memberKeys } from "@/features/members/queries/members-query" +import { sessionKeys } from "@/features/auth/queries/session-query" +import { workspaceBootstrapKeys } from "@/features/orgs/queries/workspace-bootstrap-query" + +beforeAll(() => { + if (typeof window.matchMedia === "function") return + window.matchMedia = (query: string) => + ({ + addEventListener: () => {}, + addListener: () => {}, + dispatchEvent: () => false, + matches: false, + media: query, + onchange: null, + removeEventListener: () => {}, + removeListener: () => {}, + }) as MediaQueryList +}) + +const timestamps = { createdAt: "2026-07-30T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z" } + +const PATHS = [ + "/orgs/$organizationId/projects/$projectId/env/$environmentKey", + "/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/paywalls", +] as const + +function seededClient() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client.setQueryData(sessionKeys.current, { email: "operator@example.test", id: "actor_01" }) + client.setQueryData(memberKeys.list("org_01"), { + items: [{ ...timestamps, actorId: "actor_01", organizationId: "org_01", role: "owner" }], + page: { nextCursor: "" }, + }) + client.setQueryData(environmentKeys.list("prj_01"), { + items: [ + { + ...timestamps, + id: "env_dev", + key: "development", + mode: "development", + name: "Development", + projectId: "prj_01", + }, + ], + page: { nextCursor: "" }, + }) + client.setQueryData(workspaceBootstrapKeys.all, { organizations: [] }) + return client +} + +function renderShell(pathname: string) { + const rootRoute = createRootRoute() + const routeTree = rootRoute.addChildren( + PATHS.map((path) => + createRoute({ component: CloudWorkspaceShell, getParentRoute: () => rootRoute, path }), + ), + ) + const router = createRouter({ + history: createMemoryHistory({ initialEntries: [pathname] }), + routeTree, + }) + + return render( + + + + + , + ) +} + +/** + * The switcher being wired into the shell is not the same as the shell rendering + * it: a client-only guard once compiled fine and still never ran. These assert on + * the mounted output. + */ +describe("CloudWorkspaceShell", () => { + it("mounts the Environment switcher on an Environment-scoped surface", async () => { + renderShell("/orgs/org_01/projects/prj_01/monetization/env_dev/paywalls") + + expect( + await screen.findByRole("button", { name: /Current environment: Development/ }), + ).toBeInTheDocument() + }) + + it("still offers it where the path carries no Environment", async () => { + renderShell("/orgs/org_01/projects/prj_01") + + // Present on every Project surface. The Project overview names no Environment + // in its address, so the switcher says so rather than implying the link would + // reproduce it. + const trigger = await screen.findByRole("button", { name: /Current environment: Development/ }) + expect(trigger).toHaveTextContent("not in this page's address") + }) +}) diff --git a/apps/dashboard/src/features/orgs/components/cloud-workspace-shell.tsx b/apps/dashboard/src/features/orgs/components/cloud-workspace-shell.tsx new file mode 100644 index 00000000..adf50ee7 --- /dev/null +++ b/apps/dashboard/src/features/orgs/components/cloud-workspace-shell.tsx @@ -0,0 +1,279 @@ +import { CodeIcon } from "@phosphor-icons/react/dist/ssr/Code" +import { ChartLineUpIcon } from "@phosphor-icons/react/dist/ssr/ChartLineUp" +import { GearSixIcon } from "@phosphor-icons/react/dist/ssr/GearSix" +import { KeyIcon } from "@phosphor-icons/react/dist/ssr/Key" +import { PackageIcon } from "@phosphor-icons/react/dist/ssr/Package" +import { ReceiptIcon } from "@phosphor-icons/react/dist/ssr/Receipt" +import { StorefrontIcon } from "@phosphor-icons/react/dist/ssr/Storefront" +import { SquaresFourIcon } from "@phosphor-icons/react/dist/ssr/SquaresFour" +import { UsersThreeIcon } from "@phosphor-icons/react/dist/ssr/UsersThree" +import { useQuery } from "@tanstack/react-query" +import { Link, useRouterState } from "@tanstack/react-router" + +import { NavMain } from "@/components/navigation/nav-main" +import { dashboardBuildInfo } from "@/config/environment" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarRail, +} from "@/components/ui/sidebar" +import { readWorkspaceScope } from "@/features/orgs/types/workspace-navigation" +import { UserMenu } from "@/features/auth/components/user-menu" +import { billingSettingsQueryOptions } from "@/features/store-connections/queries/billing-settings-queries" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { useActiveEnvironment } from "@/features/environments/hooks/use-active-environment" +import { EnvironmentSwitcher } from "@/features/environments/components/environment-switcher" +import { useOrganizationAccess } from "@/hooks/use-organization-access" +import type { NavigationItem } from "@/components/navigation/nav-main" +import { OrganizationSwitcher } from "./organization-switcher" + +export function CloudWorkspaceShell() { + const pathname = useRouterState({ select: (state) => state.location.pathname }) + const scope = readWorkspaceScope(pathname) + const access = useOrganizationAccess(scope.organizationId ?? "") + // The address names the Environment by alias; the id stays internal. + const { activeId, alias } = useActiveEnvironment() + const projectBase = `/orgs/${scope.organizationId}/projects/${scope.projectId}/env/${alias}` + + // Administrative surfaces are hidden until membership confirms management + // rights. The API remains the authority; this only prevents dead-end links. + const canManage = access.canManage + + // Mosaic Billing is per-Project opt-in. While it is off, its Environment + // surfaces have nothing to show, so the group collapses to the one page that + // can turn it on. Hiding the group outright would make billing unreachable. + // const environments = useQuery({ + // ...environmentsQueryOptions(scope.projectId ?? ""), + // enabled: canManage && Boolean(scope.projectId), + // }) + const probeEnvironmentId = activeId ?? "" + const billingSettings = useQuery({ + ...billingSettingsQueryOptions(scope.projectId ?? "", probeEnvironmentId), + enabled: canManage && Boolean(scope.projectId) && probeEnvironmentId.length > 0, + }) + // Unknown state shows the full group: a nav that hides itself because a probe + // failed is worse than one item too many. + const billingEnabled = billingSettings.data?.billingEnabled !== false + + function withManagement(items: NavigationItem[]) { + return canManage ? items : [] + } + + return ( + + + + + + + {scope.projectId && ( + , + title: "Overview", + }, + { + icon: , + title: "Monetization", + subItems: [ + { + to: `${projectBase}/monetization/paywalls`, + title: "Paywalls", + icon: <> + }, + { + to: `${projectBase}/monetization/experiments`, + title: "Experiments", + icon: <> + }, + { + to: `${projectBase}/monetization/placements`, + title: "Placements", + icon: <> + }, + { + to: `${projectBase}/monetization/assets`, + title: "Assets", + icon: <> + }, + { + to: `${projectBase}/monetization/releases`, + title: "Publish history", + icon: <> + }, + ], + }, + { + to: `${projectBase}/analytics/overview`, + icon: , + title: "Analytics", + }, + { + to: `${projectBase}/apps`, + icon: , + title: "Apps", + }, + { + icon: , + title: "Catalog", + subItems: [ + { + to: `${projectBase}/catalog/plans`, + title: "Plans", + icon: <>, + }, + { + to: `${projectBase}/catalog/products`, + title: "Products", + icon: <>, + }, + { + to: `${projectBase}/catalog/entitlements`, + title: "Entitlements", + icon: <>, + }, + // What a Product grants is versioned and immutable, so it is + // its own surface rather than a set of buttons on Product + // detail. It sits in Catalog because grant versions relate two + // Project-scoped things, Products and Entitlements. + { + to: `${projectBase}/catalog/grant-versions`, + title: "Grant versions", + icon: <>, + }, + { + to: `${projectBase}/catalog/providers`, + title: "Purchase setup", + icon: <>, + }, + ], + }, + // Mosaic Billing is per-Project opt-in and owner/admin only. The + // group is hidden from members who could not act on it, so it is + // never a dead end; Environment-scoped items fall back to the + // Project overview exactly as Monetization and Analytics do. + // "Store Server Credentials" is the frozen term, and every + // recovery link in billing uses it, so the nav does too. + ...(access.membership + ? [ + { + icon: , + title: "Billing", + subItems: [ + { + to: `${projectBase}/billing/migrations`, + title: "Migration Programs", + icon: <>, + }, + ...(billingEnabled && canManage + ? [ + // Customers leads the group. It answers the question + // operators actually arrive with — "does this person + // have access?" — which the ledger deliberately + // cannot. + { + to: `${projectBase}/billing/customers`, + title: "Customers", + icon: <>, + }, + { + to: `${projectBase}/billing/identity-conflicts`, + title: "Identity conflicts", + icon: <>, + }, + { + to: `${projectBase}/billing/restores`, + title: "Restores", + icon: <>, + }, + { + to: `${projectBase}/billing/transactions`, + title: "Transactions", + icon: <>, + }, + { + to: `${projectBase}/billing/quarantine`, + title: "Quarantine", + icon: <>, + }, + { + to: `${projectBase}/billing/reconciliation`, + title: "Reconciliation", + icon: <>, + }, + { + to: `${projectBase}/billing/health`, + title: "Billing health", + icon: <>, + }, + // A sibling of billing health, never a tab inside it: + // store input can be arriving perfectly while every + // customer is being told the wrong thing. + { + to: `${projectBase}/billing/projection-health`, + title: "Projection health", + icon: <>, + }, + ] + : []), + ...(canManage + ? [ + { + to: `${projectBase}/billing/connections`, + title: billingEnabled + ? "Store Server Credentials" + : "Set up Mosaic Billing", + icon: <>, + }, + ] + : []), + ], + }, + ] + : []), + ...withManagement([ + { + to: `${projectBase}/settings/environments`, + icon: , + title: "Settings", + }, + { + to: `${projectBase}/settings/api-keys`, + icon: , + title: "API keys", + }, + ]), + ]} + /> + )} + {scope.organizationId && canManage && ( + , + title: "Members", + }, + ]} + /> + )} + + + + + Mosaic {dashboardBuildInfo.version} · diagnostics + + + + + ) +} diff --git a/apps/dashboard/src/features/organizations/components/create-organization-page.tsx b/apps/dashboard/src/features/orgs/components/create-organization-page.tsx similarity index 94% rename from apps/dashboard/src/features/organizations/components/create-organization-page.tsx rename to apps/dashboard/src/features/orgs/components/create-organization-page.tsx index 8fda3cf7..339727bb 100644 --- a/apps/dashboard/src/features/organizations/components/create-organization-page.tsx +++ b/apps/dashboard/src/features/orgs/components/create-organization-page.tsx @@ -6,8 +6,8 @@ import { Button } from "@/components/ui/button" import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { HostedAccessBanner } from "@/features/auth/components/hosted-access-banner" -import { createOrganizationMutationOptions } from "@/features/organizations/mutations/organization-mutations" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { createOrganizationMutationOptions } from "@/features/orgs/mutations/organization-mutations" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" import { ApiError } from "@/lib/api/errors" export function CreateOrganizationPage() { @@ -20,7 +20,7 @@ export function CreateOrganizationPage() { const organization = await mutation.mutateAsync({ name: value.name.trim() }) await navigate({ params: { organizationId: organization.id }, - to: "/organizations/$organizationId", + to: "/orgs/$organizationId", }) }, }) diff --git a/apps/dashboard/src/features/organizations/components/organization-overview-page.tsx b/apps/dashboard/src/features/orgs/components/organization-overview-page.tsx similarity index 88% rename from apps/dashboard/src/features/organizations/components/organization-overview-page.tsx rename to apps/dashboard/src/features/orgs/components/organization-overview-page.tsx index 12641d00..72b91157 100644 --- a/apps/dashboard/src/features/organizations/components/organization-overview-page.tsx +++ b/apps/dashboard/src/features/orgs/components/organization-overview-page.tsx @@ -4,8 +4,8 @@ import { Link } from "@tanstack/react-router" import { buttonVariants } from "@/components/ui/button-variants" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { organizationQueryOptions } from "@/features/organizations/queries/organizations-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { organizationQueryOptions } from "@/features/orgs/queries/organizations-query" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" import { projectsQueryOptions } from "@/features/projects/queries/projects-query" interface OrganizationOverviewPageProps { @@ -29,7 +29,7 @@ export function OrganizationOverviewPage({ Create project @@ -57,14 +57,14 @@ export function OrganizationOverviewPage({ Members New project @@ -104,8 +104,8 @@ export function OrganizationOverviewPage({

{project.key}

prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" > Open project diff --git a/apps/dashboard/src/features/orgs/components/organization-switcher.test.tsx b/apps/dashboard/src/features/orgs/components/organization-switcher.test.tsx new file mode 100644 index 00000000..cbfd7569 --- /dev/null +++ b/apps/dashboard/src/features/orgs/components/organization-switcher.test.tsx @@ -0,0 +1,243 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router" +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeAll, describe, expect, it } from "vitest" + +import { SidebarProvider } from "@/components/ui/sidebar" +import type { BootstrapOrganization, Project, Role } from "@/generated/api" +import { OrganizationSwitcher } from "@/features/orgs/components/organization-switcher" +import { workspaceBootstrapKeys } from "@/features/orgs/queries/workspace-bootstrap-query" +import { ApiError } from "@/lib/api/errors" + +// The sidebar reads a media query to decide between its desktop and mobile +// presentation; jsdom does not implement matchMedia. +beforeAll(() => { + if (typeof window.matchMedia === "function") return + window.matchMedia = (query: string) => + ({ + addEventListener: () => {}, + addListener: () => {}, + dispatchEvent: () => false, + matches: false, + media: query, + onchange: null, + removeEventListener: () => {}, + removeListener: () => {}, + }) as MediaQueryList +}) + +const timestamps = { createdAt: "2026-07-30T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z" } + +function project(id: string, name: string, organizationId: string): Project { + return { ...timestamps, id, key: id, name, organizationId, status: "active" } +} + +function organization( + id: string, + name: string, + { + projectCount, + projects = [], + role = "owner" as Role, + }: { projectCount?: number; projects?: Project[]; role?: Role } = {}, +): BootstrapOrganization { + return { + organization: { ...timestamps, id, name }, + projectCount: projectCount ?? projects.length, + projects, + projectsTruncated: (projectCount ?? projects.length) > projects.length, + role, + } +} + +function renderSwitcher( + queryClient: QueryClient, + { organizationId, pathname }: { organizationId?: string; pathname: string }, +) { + const component = () => ( + + + + ) + const rootRoute = createRootRoute() + const routeTree = rootRoute.addChildren([ + createRoute({ getParentRoute: () => rootRoute, path: "/", component }), + createRoute({ getParentRoute: () => rootRoute, path: "/orgs/new" }), + createRoute({ + getParentRoute: () => rootRoute, + path: "/orgs/$organizationId", + component, + }), + createRoute({ + getParentRoute: () => rootRoute, + path: "/orgs/$organizationId/projects/new", + }), + createRoute({ + getParentRoute: () => rootRoute, + path: "/orgs/$organizationId/projects/$projectId/env/$environmentKey", + component, + }), + ]) + + const router = createRouter({ + history: createMemoryHistory({ initialEntries: [pathname] }), + routeTree, + }) + + return render( + + + , + ) +} + +function seededClient(seed: (client: QueryClient) => void) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + seed(client) + return client +} + +function withBootstrap(organizations: BootstrapOrganization[]) { + return (client: QueryClient) => { + client.setQueryData(workspaceBootstrapKeys.all, { organizations }) + } +} + +async function openSwitcher() { + fireEvent.click(await screen.findByRole("button", { name: /Switch project or organization/ })) +} + +/** + * The switcher shipped non-functional: its items were not links, so an operator + * could not change Organization at all, and a failed list request rendered the + * "no organizations yet" branch, which reads as "your data is gone". Projects, + * the unit of daily work, were not reachable from it at all. + */ +describe("OrganizationSwitcher", () => { + it("lists the current Organization's Projects and names the one in scope", async () => { + const client = seededClient( + withBootstrap([ + organization("org_01", "Northwind", { + projects: [project("prj_01", "Mobile", "org_01"), project("prj_02", "Web", "org_01")], + }), + ]), + ) + + renderSwitcher(client, { + organizationId: "org_01", + pathname: "/orgs/org_01/projects/prj_02", + }) + + const trigger = await screen.findByRole("button", { name: /Current project: Web/ }) + expect(trigger).toHaveAccessibleName(/Current organization: Northwind/) + fireEvent.click(trigger) + + expect(await screen.findByRole("menuitem", { name: "Mobile" })).toHaveAttribute( + "href", + "/orgs/org_01/projects/prj_01", + ) + expect(screen.getByRole("menuitem", { name: "Web" })).toHaveAttribute( + "href", + "/orgs/org_01/projects/prj_02", + ) + expect(screen.getByRole("menuitem", { name: "Add project" })).toHaveAttribute( + "href", + "/orgs/org_01/projects/new", + ) + }) + + it("moves between Organizations from the switch submenu", async () => { + const client = seededClient( + withBootstrap([ + organization("org_01", "Northwind", { projects: [project("prj_01", "Mobile", "org_01")] }), + organization("org_02", "Contoso"), + ]), + ) + + renderSwitcher(client, { organizationId: "org_01", pathname: "/orgs/org_01" }) + await openSwitcher() + + fireEvent.click(await screen.findByRole("menuitem", { name: /Switch organization/ })) + + expect(await screen.findByRole("menuitem", { name: "Contoso" })).toHaveAttribute( + "href", + "/orgs/org_02", + ) + expect(screen.getByRole("menuitem", { name: "Add organization" })).toHaveAttribute( + "href", + "/orgs/new", + ) + }) + + it("offers a retry instead of claiming there are no Projects when the read fails", async () => { + const client = seededClient((queryClient) => { + queryClient.setQueryDefaults(workspaceBootstrapKeys.all, { + queryFn: () => + Promise.reject( + new ApiError("boom", { + code: "internal_error", + correlationId: "request_test", + retryable: true, + status: 500, + }), + ), + retry: false, + }) + }) + + renderSwitcher(client, { pathname: "/" }) + await openSwitcher() + + expect(await screen.findByRole("button", { name: "Retry loading organizations" })).toBeVisible() + expect(screen.queryByText("You do not belong to an organization yet.")).toBeNull() + // "Add project" needs an Organization in scope; offering it here would be a + // dead link. + expect(screen.queryByRole("menuitem", { name: "Add project" })).toBeNull() + }) + + it("withholds Add project from a member, who cannot create one", async () => { + const client = seededClient( + withBootstrap([ + organization("org_01", "Northwind", { + projects: [project("prj_01", "Mobile", "org_01")], + role: "member", + }), + ]), + ) + + renderSwitcher(client, { organizationId: "org_01", pathname: "/orgs/org_01" }) + await openSwitcher() + + // Polled rather than read once: the popup remounts as the router settles, so + // a handle taken from findBy can be detached by the time it is asserted on. + await expect + .poll(() => screen.queryByRole("menuitem", { name: "Mobile" })?.getAttribute("href")) + .toBe("/orgs/org_01/projects/prj_01") + expect(screen.queryByRole("menuitem", { name: "Add project" })).toBeNull() + }) + + it("says how many Projects exist when the snapshot is capped", async () => { + const client = seededClient( + withBootstrap([ + organization("org_01", "Northwind", { + projectCount: 40, + projects: [project("prj_01", "Mobile", "org_01")], + }), + ]), + ) + + renderSwitcher(client, { organizationId: "org_01", pathname: "/orgs/org_01" }) + await openSwitcher() + + expect(await screen.findByRole("menuitem", { name: "View all 40 projects" })).toHaveAttribute( + "href", + "/orgs/org_01", + ) + }) +}) diff --git a/apps/dashboard/src/features/orgs/components/organization-switcher.tsx b/apps/dashboard/src/features/orgs/components/organization-switcher.tsx new file mode 100644 index 00000000..624482bb --- /dev/null +++ b/apps/dashboard/src/features/orgs/components/organization-switcher.tsx @@ -0,0 +1,274 @@ +import * as React from "react" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" +import { ArrowsLeftRightIcon } from "@phosphor-icons/react/dist/ssr/ArrowsLeftRight" +import { CaretUpDownIcon } from "@phosphor-icons/react/dist/ssr/CaretUpDown" +import { PlusIcon } from "@phosphor-icons/react/dist/ssr/Plus" +import { useQuery } from "@tanstack/react-query" +import { Link, useRouterState } from "@tanstack/react-router" + +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { workspaceBootstrapQueryOptions } from "../queries/workspace-bootstrap-query" +import { readWorkspaceScope } from "../types/workspace-navigation" +import { describeApiError } from "@/lib/api/errors" + +const AVATAR_CLASSNAMES = + "bg-sidebar-primary text-sidebar-primary-foreground size-4 rounded data-[size=lg]:size-4 data-[size=sm]:size-4 after:rounded-none" + +// Base UI exposes the trigger width as --anchor-width on positioned popups. +const DROPDOWN_CLASSNAMES = "w-(--anchor-width) min-w-56 rounded" + +function Initial({ value }: { value: string }) { + return ( + + + {value.charAt(0).toUpperCase()} + + + ) +} + +/** + * The switcher is where an operator moves between Projects, which is the unit of + * daily work; changing Organization is the rarer move and lives one level in. + * Both read the single bootstrap snapshot entry already fetched, so opening the + * menu costs no request. + */ +export function OrganizationSwitcher({ organizationId }: { organizationId?: string }) { + const { isMobile } = useSidebar() + const bootstrap = useQuery(workspaceBootstrapQueryOptions()) + // The shell passes the Organization it read from the path; the Project comes + // from the same place, so the switcher does not need a second prop threaded + // through every caller. + const projectId = useRouterState({ + select: (state) => readWorkspaceScope(state.location.pathname).projectId, + }) + + const organizations = React.useMemo( + () => bootstrap.data?.organizations ?? [], + [bootstrap.data?.organizations], + ) + + const current = React.useMemo( + () => organizations.find((entry) => entry.organization.id === organizationId), + [organizations, organizationId], + ) + + const currentProject = React.useMemo( + () => current?.projects.find((project) => project.id === projectId), + [current, projectId], + ) + + const organizationLabel = current + ? current.organization.name + : bootstrap.isPending + ? "Loading organizations" + : "Select organization" + const triggerLabel = currentProject ? currentProject.name : organizationLabel + const canCreateProject = current?.role === "owner" || current?.role === "admin" + + const failure = bootstrap.isError ? ( +
+

+ {describeApiError(bootstrap.error).description} +

+ +
+ ) : null + + return ( + + + + + +
+ +
+ {triggerLabel} + {currentProject ? ( + + {organizationLabel} + + ) : null} +
+
+ + + } + /> + + + + Projects + + + {bootstrap.isPending ? ( +

+ Loading projects… +

+ ) : ( + (failure ?? + (!current ? ( +

+ Choose an organization to see its projects. +

+ ) : current.projects.length === 0 ? ( +

+ {current.organization.name} has no projects yet. +

+ ) : ( + <> + {current.projects.map((project) => ( + prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" + /> + } + > + + {project.name} + + ))} + {current.projectsTruncated ? ( + // Presenting a capped list as the whole list would hide + // Projects the operator owns. + + } + > + + View all {current.projectCount} projects + + + ) : null} + + ))) + )} + + {/* Only owners and admins may create a Project, so offering the + form to a member would be a guaranteed refusal. */} + {current && canCreateProject ? ( + + } + > +
+ +
+
+ Add project +
+
+ ) : null} + + + + + + + + Switch organization + + + + + Organizations + + + {bootstrap.isPending ? ( +

+ Loading organizations… +

+ ) : ( + (failure ?? + (organizations.length === 0 ? ( +

+ You do not belong to an organization yet. +

+ ) : ( + organizations.map((entry) => ( + + } + > + + {entry.organization.name} + + )) + ))) + )} + + + + }> +
+ +
+
+ Add organization +
+
+
+
+
+
+
+
+
+ ) +} diff --git a/apps/dashboard/src/features/organizations/components/scope-mismatch-recovery.tsx b/apps/dashboard/src/features/orgs/components/scope-mismatch-recovery.tsx similarity index 86% rename from apps/dashboard/src/features/organizations/components/scope-mismatch-recovery.tsx rename to apps/dashboard/src/features/orgs/components/scope-mismatch-recovery.tsx index 83321e9e..79536d0f 100644 --- a/apps/dashboard/src/features/organizations/components/scope-mismatch-recovery.tsx +++ b/apps/dashboard/src/features/orgs/components/scope-mismatch-recovery.tsx @@ -1,7 +1,7 @@ import { Link } from "@tanstack/react-router" import { buttonVariants } from "@/components/ui/button-variants" -import type { NestedScopeMismatch } from "@/features/organizations/types/nested-scope" +import type { NestedScopeMismatch } from "@/features/orgs/types/nested-scope" interface ScopeMismatchRecoveryProps { mismatch: Exclude @@ -34,8 +34,8 @@ export function ScopeMismatchRecovery({ {mismatch === "resource" ? ( prev} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" > Return to Project @@ -43,7 +43,7 @@ export function ScopeMismatchRecovery({ Return to Organization diff --git a/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.test.tsx b/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.test.tsx new file mode 100644 index 00000000..ca2cf343 --- /dev/null +++ b/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.test.tsx @@ -0,0 +1,163 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router" +import { render, screen, waitFor } from "@testing-library/react" +import { beforeAll, describe, expect, it } from "vitest" + +import { SidebarProvider } from "@/components/ui/sidebar" +import type { BootstrapOrganization, Project } from "@/generated/api" +import { WorkspaceEntryRedirect } from "@/features/orgs/components/workspace-entry-redirect" +import { workspaceBootstrapKeys } from "@/features/orgs/queries/workspace-bootstrap-query" +import { ApiError } from "@/lib/api/errors" + +// The degraded branch renders the workspace shell's Organization list, which +// reads a media query jsdom does not implement. +beforeAll(() => { + if (typeof window.matchMedia === "function") return + window.matchMedia = (query: string) => + ({ + addEventListener: () => {}, + addListener: () => {}, + dispatchEvent: () => false, + matches: false, + media: query, + onchange: null, + removeEventListener: () => {}, + removeListener: () => {}, + }) as MediaQueryList +}) + +const timestamps = { createdAt: "2026-07-30T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z" } + +function project(id: string, organizationId: string): Project { + return { ...timestamps, id, key: id, name: id, organizationId, status: "active" } +} + +function organization(id: string, projects: Project[] = []): BootstrapOrganization { + return { + organization: { ...timestamps, id, name: id }, + projectCount: projects.length, + projects, + projectsTruncated: false, + role: "owner", + } +} + +function renderEntry(queryClient: QueryClient) { + const rootRoute = createRootRoute() + const routeTree = rootRoute.addChildren([ + createRoute({ + component: WorkspaceEntryRedirect, + getParentRoute: () => rootRoute, + path: "/workspace", + }), + createRoute({ getParentRoute: () => rootRoute, path: "/orgs/new" }), + createRoute({ getParentRoute: () => rootRoute, path: "/orgs/$organizationId" }), + createRoute({ + getParentRoute: () => rootRoute, + path: "/orgs/$organizationId/projects/new", + }), + createRoute({ + getParentRoute: () => rootRoute, + path: "/orgs/$organizationId/projects/$projectId/env/$environmentKey", + }), + ]) + + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/workspace"] }), + routeTree, + }) + + render( + + + + + , + ) + + return router +} + +function seededClient(seed: (client: QueryClient) => void) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + seed(client) + return client +} + +/** + * Entry shipped as a beforeLoad that returned early under `import.meta.env.SSR`. + * On a hard load that guard ran only on the server, and Start does not re-run it + * after hydration, so the redirect never happened and the operator sat on + * /workspace. These assertions are on the resulting location, which is the part + * that was broken while the resolver itself was correct. + */ +describe("WorkspaceEntryRedirect", () => { + it("leaves /workspace for the first Project", async () => { + const client = seededClient((queryClient) => { + queryClient.setQueryData(workspaceBootstrapKeys.all, { + organizations: [organization("org_01", [project("prj_01", "org_01")])], + }) + }) + + const router = renderEntry(client) + + await waitFor(() => { + expect(router.state.location.pathname).toBe("/orgs/org_01/projects/prj_01") + }) + }) + + it("sends an operator with no Organizations to create one", async () => { + const client = seededClient((queryClient) => { + queryClient.setQueryData(workspaceBootstrapKeys.all, { organizations: [] }) + }) + + const router = renderEntry(client) + + await waitFor(() => { + expect(router.state.location.pathname).toBe("/orgs/new") + }) + }) + + it("replaces the entry route so Back does not bounce forward again", async () => { + const client = seededClient((queryClient) => { + queryClient.setQueryData(workspaceBootstrapKeys.all, { + organizations: [organization("org_01", [project("prj_01", "org_01")])], + }) + }) + + const router = renderEntry(client) + await waitFor(() => { + expect(router.state.location.pathname).toBe("/orgs/org_01/projects/prj_01") + }) + + expect(router.history.canGoBack()).toBe(false) + }) + + it("shows the Organization list with a retry when the snapshot cannot be read", async () => { + const client = seededClient((queryClient) => { + queryClient.setQueryDefaults(workspaceBootstrapKeys.all, { + queryFn: () => + Promise.reject( + new ApiError("boom", { + code: "internal_error", + correlationId: "request_test", + retryable: true, + status: 500, + }), + ), + retry: false, + }) + }) + + const router = renderEntry(client) + + expect(await screen.findByRole("heading", { name: "Organizations" })).toBeVisible() + expect(router.state.location.pathname).toBe("/workspace") + }) +}) diff --git a/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.tsx b/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.tsx new file mode 100644 index 00000000..3164ac43 --- /dev/null +++ b/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.tsx @@ -0,0 +1,45 @@ +import * as React from "react" + +import { useNavigate } from "@tanstack/react-router" +import { useQuery } from "@tanstack/react-query" + +import { RoutePendingState } from "@/components/feedback/route-feedback" +import { WorkspaceHome } from "@/features/orgs/components/workspace-home" +import { + resolveWorkspaceEntry, + workspaceEntryNavigation, +} from "@/features/orgs/types/workspace-entry" +import { workspaceBootstrapQueryOptions } from "@/features/orgs/queries/workspace-bootstrap-query" + +/** + * Entry resolves here rather than in the route's beforeLoad. The session lives in + * an HttpOnly cookie the SSR pass cannot read, so the guard has to be + * client-side; but a beforeLoad that returns early under `import.meta.env.SSR` + * runs only on the server for a hard load, and Start does not re-run it after + * hydration. The redirect silently never happened. A component runs on every + * hydration, so this holds for both a typed URL and an in-app navigation. + */ +export function WorkspaceEntryRedirect() { + const navigate = useNavigate() + const bootstrap = useQuery(workspaceBootstrapQueryOptions()) + + // Memoised on the cached snapshot: resolveWorkspaceEntry returns a fresh + // object each call, which would re-arm the effect on every render. + const target = React.useMemo( + () => (bootstrap.data ? resolveWorkspaceEntry(bootstrap.data) : undefined), + [bootstrap.data], + ) + + React.useEffect(() => { + if (!target) return + // Replaced, not pushed: Back must leave the workspace rather than land on + // this route again and bounce forward. + void navigate({ ...workspaceEntryNavigation(target), replace: true }) + }, [navigate, target]) + + // A failed bootstrap must not strand the operator on a blank redirect. The + // Organization list reports the outage and offers a retry. + if (bootstrap.isError) return + + return +} diff --git a/apps/dashboard/src/features/organizations/components/workspace-home.tsx b/apps/dashboard/src/features/orgs/components/workspace-home.tsx similarity index 87% rename from apps/dashboard/src/features/organizations/components/workspace-home.tsx rename to apps/dashboard/src/features/orgs/components/workspace-home.tsx index 19922be2..c0d74d8f 100644 --- a/apps/dashboard/src/features/organizations/components/workspace-home.tsx +++ b/apps/dashboard/src/features/orgs/components/workspace-home.tsx @@ -6,15 +6,15 @@ import { Link } from "@tanstack/react-router" import { buttonVariants } from "@/components/ui/button-variants" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { organizationsQueryOptions } from "@/features/organizations/queries/organizations-query" -import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { organizationsQueryOptions } from "@/features/orgs/queries/organizations-query" +import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" export function WorkspaceHome() { const organizations = useQuery(organizationsQueryOptions()) const items = organizations.data?.items ?? [] const state = resolveHostedQueryState({ emptyAction: ( - + Create organization ), @@ -31,7 +31,7 @@ export function WorkspaceHome() { return ( + New organization @@ -50,7 +50,7 @@ export function WorkspaceHome() { {organization.name} diff --git a/apps/dashboard/src/features/organizations/components/workspace-page.tsx b/apps/dashboard/src/features/orgs/components/workspace-page.tsx similarity index 100% rename from apps/dashboard/src/features/organizations/components/workspace-page.tsx rename to apps/dashboard/src/features/orgs/components/workspace-page.tsx diff --git a/apps/dashboard/src/features/organizations/mutations/organization-mutations.ts b/apps/dashboard/src/features/orgs/mutations/organization-mutations.ts similarity index 88% rename from apps/dashboard/src/features/organizations/mutations/organization-mutations.ts rename to apps/dashboard/src/features/orgs/mutations/organization-mutations.ts index 429f53bd..8d180753 100644 --- a/apps/dashboard/src/features/organizations/mutations/organization-mutations.ts +++ b/apps/dashboard/src/features/orgs/mutations/organization-mutations.ts @@ -1,7 +1,7 @@ import { mutationOptions, type QueryClient } from "@tanstack/react-query" import { createOrganization, type CreateOrganizationRequest } from "@/generated/api" -import { organizationKeys } from "@/features/organizations/queries/organizations-query" +import { organizationKeys } from "@/features/orgs/queries/organizations-query" import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" export function createOrganizationMutationOptions(queryClient: QueryClient) { diff --git a/apps/dashboard/src/features/organizations/queries/organizations-query.ts b/apps/dashboard/src/features/orgs/queries/organizations-query.ts similarity index 100% rename from apps/dashboard/src/features/organizations/queries/organizations-query.ts rename to apps/dashboard/src/features/orgs/queries/organizations-query.ts diff --git a/apps/dashboard/src/features/orgs/queries/workspace-bootstrap-query.ts b/apps/dashboard/src/features/orgs/queries/workspace-bootstrap-query.ts new file mode 100644 index 00000000..b56f66c6 --- /dev/null +++ b/apps/dashboard/src/features/orgs/queries/workspace-bootstrap-query.ts @@ -0,0 +1,28 @@ +import { queryOptions } from "@tanstack/react-query" + +import { getWorkspaceBootstrap } from "@/generated/api" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +export const workspaceBootstrapKeys = { + all: ["workspace-bootstrap"] as const, +} + +/** + * One read that answers both "which Organizations does this operator have?" and + * "what is in them?". Entry and the Organization switcher share it, so opening + * the switcher costs nothing and entry resolves without a request per + * Organization. + */ +export function workspaceBootstrapQueryOptions() { + return queryOptions({ + queryKey: workspaceBootstrapKeys.all, + queryFn: async ({ signal }) => { + const result = await getWorkspaceBootstrap({ + client: generatedDashboardClient, + signal, + throwOnError: true, + }) + return result.data.data + }, + }) +} diff --git a/apps/dashboard/src/features/organizations/types/nested-scope.test.ts b/apps/dashboard/src/features/orgs/types/nested-scope.test.ts similarity index 93% rename from apps/dashboard/src/features/organizations/types/nested-scope.test.ts rename to apps/dashboard/src/features/orgs/types/nested-scope.test.ts index 27ca81b0..42b61cd3 100644 --- a/apps/dashboard/src/features/organizations/types/nested-scope.test.ts +++ b/apps/dashboard/src/features/orgs/types/nested-scope.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { detectNestedScopeMismatch } from "@/features/organizations/types/nested-scope" +import { detectNestedScopeMismatch } from "@/features/orgs/types/nested-scope" describe("nested hosted-route scope", () => { it("enables a project-scoped list only when the Project belongs to the routed Organization", () => { diff --git a/apps/dashboard/src/features/organizations/types/nested-scope.ts b/apps/dashboard/src/features/orgs/types/nested-scope.ts similarity index 100% rename from apps/dashboard/src/features/organizations/types/nested-scope.ts rename to apps/dashboard/src/features/orgs/types/nested-scope.ts diff --git a/apps/dashboard/src/features/orgs/types/workspace-entry.test.ts b/apps/dashboard/src/features/orgs/types/workspace-entry.test.ts new file mode 100644 index 00000000..d7f8db71 --- /dev/null +++ b/apps/dashboard/src/features/orgs/types/workspace-entry.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest" + +import type { BootstrapOrganization, Project, Role } from "@/generated/api" +import { resolveWorkspaceEntry } from "@/features/orgs/types/workspace-entry" + +const timestamps = { createdAt: "2026-07-30T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z" } + +function project(id: string, organizationId: string): Project { + return { + ...timestamps, + id, + key: id, + name: id, + organizationId, + status: "active", + } +} + +function organization( + id: string, + { projects = [], role = "owner" as Role }: { projects?: Project[]; role?: Role } = {}, +): BootstrapOrganization { + return { + organization: { ...timestamps, id, name: id }, + projectCount: projects.length, + projects, + projectsTruncated: false, + role, + } +} + +describe("resolveWorkspaceEntry", () => { + it("sends an operator with no Organizations to create one", () => { + expect(resolveWorkspaceEntry({ organizations: [] })).toEqual({ + reason: "no-organizations", + to: "/orgs/new", + }) + }) + + it("lands in the first Project of the first Organization", () => { + const entry = resolveWorkspaceEntry({ + organizations: [ + organization("org_01", { + projects: [project("prj_01", "org_01"), project("prj_02", "org_01")], + }), + organization("org_02", { projects: [project("prj_03", "org_02")] }), + ], + }) + + expect(entry).toEqual({ + params: { organizationId: "org_01", projectId: "prj_01" }, + reason: "resolved", + to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey", + }) + }) + + it("sends an owner of an empty first Organization to create a Project there", () => { + // The first Organization decides even though a later one already has a + // Project: entry must not silently move an owner off the Organization they + // created. + const entry = resolveWorkspaceEntry({ + organizations: [ + organization("org_01"), + organization("org_02", { projects: [project("prj_01", "org_02")] }), + ], + }) + + expect(entry).toEqual({ + params: { organizationId: "org_01" }, + reason: "no-projects", + to: "/orgs/$organizationId/projects/new", + }) + }) + + it("routes a member of an empty Organization past a create form they cannot submit", () => { + const entry = resolveWorkspaceEntry({ + organizations: [ + organization("org_01", { role: "member" }), + organization("org_02", { projects: [project("prj_01", "org_02")], role: "member" }), + ], + }) + + expect(entry).toEqual({ + params: { organizationId: "org_02", projectId: "prj_01" }, + reason: "resolved", + to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey", + }) + }) + + it("stops at the Organization overview when a member has nowhere to land", () => { + const entry = resolveWorkspaceEntry({ + organizations: [organization("org_01", { role: "member" })], + }) + + expect(entry).toEqual({ + params: { organizationId: "org_01" }, + reason: "cannot-create-project", + to: "/orgs/$organizationId", + }) + }) +}) diff --git a/apps/dashboard/src/features/orgs/types/workspace-entry.ts b/apps/dashboard/src/features/orgs/types/workspace-entry.ts new file mode 100644 index 00000000..a6153a92 --- /dev/null +++ b/apps/dashboard/src/features/orgs/types/workspace-entry.ts @@ -0,0 +1,89 @@ +import type { BootstrapOrganization, WorkspaceBootstrap } from "@/generated/api" +import { DEFAULT_ENVIRONMENT_ALIAS } from "@/features/environments/types/environment-alias" + +/** + * Where entry sends an operator, as a router target so the decision is asserted + * against real paths rather than against an intermediate vocabulary. + */ +export type WorkspaceEntryTarget = + | { reason: "no-organizations"; to: "/orgs/new" } + | { + params: { organizationId: string } + reason: "no-projects" + to: "/orgs/$organizationId/projects/new" + } + | { + params: { organizationId: string } + reason: "cannot-create-project" + to: "/orgs/$organizationId" + } + | { + params: { environmentKey: string; organizationId: string; projectId: string } + reason: "resolved" + to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey" + } + +/** + * The router payload for a target, without the `reason` the resolver carries for + * the benefit of readers and tests. + */ +export function workspaceEntryNavigation(target: WorkspaceEntryTarget) { + return target.to === "/orgs/new" + ? { to: target.to } + : { params: target.params, to: target.to } +} + +function canCreateProject(entry: BootstrapOrganization) { + return entry.role === "owner" || entry.role === "admin" +} + +/** + * Resolves entry from a single bootstrap read: no Organizations means create + * one, otherwise the first Organization decides, and a first Organization + * without Projects means create one there. + * + * The one place this looks past the first Organization is a member of an empty + * Organization: only owners and admins may create a Project, so sending a member + * to the create form would be a guaranteed 403. They fall through to an + * Organization that does have a Project, and land on the Organization overview + * when none does. + */ +export function resolveWorkspaceEntry(bootstrap: WorkspaceBootstrap): WorkspaceEntryTarget { + const organizations = bootstrap.organizations + const [first] = organizations + if (!first) { + return { reason: "no-organizations", to: "/orgs/new" } + } + + const populated = organizations.find((entry) => entry.projects.length > 0) + const target = first.projects.length > 0 || canCreateProject(first) ? first : (populated ?? first) + + const [project] = target.projects + if (project) { + return { + params: { + // Entry resolves before any Environment list is read, so it addresses the + // one Environment every Project is guaranteed to have. + environmentKey: DEFAULT_ENVIRONMENT_ALIAS, + organizationId: target.organization.id, + projectId: project.id, + }, + reason: "resolved", + to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey", + } + } + + if (canCreateProject(target)) { + return { + params: { organizationId: target.organization.id }, + reason: "no-projects", + to: "/orgs/$organizationId/projects/new", + } + } + + return { + params: { organizationId: target.organization.id }, + reason: "cannot-create-project", + to: "/orgs/$organizationId", + } +} diff --git a/apps/dashboard/src/features/organizations/types/workspace-navigation.test.ts b/apps/dashboard/src/features/orgs/types/workspace-navigation.test.ts similarity index 74% rename from apps/dashboard/src/features/organizations/types/workspace-navigation.test.ts rename to apps/dashboard/src/features/orgs/types/workspace-navigation.test.ts index 8b927529..386fe1e3 100644 --- a/apps/dashboard/src/features/organizations/types/workspace-navigation.test.ts +++ b/apps/dashboard/src/features/orgs/types/workspace-navigation.test.ts @@ -4,11 +4,11 @@ import { isEnvironmentSurface, isProjectWideSurface, readWorkspaceScope, -} from "@/features/organizations/types/workspace-navigation" +} from "@/features/orgs/types/workspace-navigation" describe("hosted workspace route scope", () => { it("derives organization and project identity from the URL without a client store", () => { - const productRoute = "/organizations/org_one/projects/project_one/catalog/products/product_one" + const productRoute = "/orgs/org_one/projects/project_one/catalog/products/product_one" expect(readWorkspaceScope(productRoute)).toEqual({ environmentId: undefined, @@ -20,18 +20,18 @@ describe("hosted workspace route scope", () => { }) it("does not treat creation sentinels as selected scope", () => { - expect(readWorkspaceScope("/organizations/new")).toEqual({ + expect(readWorkspaceScope("/orgs/new")).toEqual({ environmentId: undefined, organizationId: undefined, projectId: undefined, }) expect( - isEnvironmentSurface("/organizations/org_one/projects/project_one/settings/api-keys"), + isEnvironmentSurface("/orgs/org_one/projects/project_one/settings/api-keys"), ).toBe(true) }) it("keeps monetization Environment identity URL-owned", () => { - const route = "/organizations/org_one/projects/project_one/monetization/env_staging/paywalls" + const route = "/orgs/org_one/projects/project_one/monetization/env_staging/paywalls" expect(readWorkspaceScope(route)).toEqual({ environmentId: "env_staging", @@ -43,7 +43,7 @@ describe("hosted workspace route scope", () => { }) it("keeps Analytics Environment identity URL-owned", () => { - const route = "/organizations/org_one/projects/project_one/analytics/env_production/paywalls" + const route = "/orgs/org_one/projects/project_one/analytics/env_production/paywalls" expect(readWorkspaceScope(route)).toEqual({ environmentId: "env_production", @@ -56,7 +56,7 @@ describe("hosted workspace route scope", () => { it("keeps Billing Environment identity URL-owned", () => { const route = - "/organizations/org_one/projects/project_one/billing/env_production/projection-health" + "/orgs/org_one/projects/project_one/billing/env_production/projection-health" expect(readWorkspaceScope(route)).toEqual({ environmentId: "env_production", diff --git a/apps/dashboard/src/features/organizations/types/workspace-navigation.ts b/apps/dashboard/src/features/orgs/types/workspace-navigation.ts similarity index 52% rename from apps/dashboard/src/features/organizations/types/workspace-navigation.ts rename to apps/dashboard/src/features/orgs/types/workspace-navigation.ts index 30494424..7b44f286 100644 --- a/apps/dashboard/src/features/organizations/types/workspace-navigation.ts +++ b/apps/dashboard/src/features/orgs/types/workspace-navigation.ts @@ -1,26 +1,22 @@ export interface WorkspaceScope { - environmentId?: string + /** + * The Environment as the address names it: the alias under `/env`, e.g. `prod`. + * Resolving it to an Environment is useActiveEnvironment's job, because only the + * Project's own Environment list can say whether the segment means anything. + */ + environmentSegment?: string organizationId?: string projectId?: string } export function readWorkspaceScope(pathname: string): WorkspaceScope { const segments = pathname.split("/").filter(Boolean) - const organizationIndex = segments.indexOf("organizations") + const organizationIndex = segments.indexOf("orgs") const projectIndex = segments.indexOf("projects") - const monetizationIndex = segments.indexOf("monetization") - const analyticsIndex = segments.indexOf("analytics") - const billingIndex = segments.indexOf("billing") + const environmentIndex = segments.indexOf("env") return { - environmentId: - monetizationIndex >= 0 - ? segments[monetizationIndex + 1] - : analyticsIndex >= 0 - ? segments[analyticsIndex + 1] - : billingIndex >= 0 - ? segments[billingIndex + 1] - : undefined, + environmentSegment: environmentIndex >= 0 ? segments[environmentIndex + 1] : undefined, organizationId: organizationIndex >= 0 && segments[organizationIndex + 1] !== "new" ? segments[organizationIndex + 1] @@ -36,11 +32,7 @@ export function isProjectWideSurface(pathname: string) { return pathname.includes("/apps") || pathname.includes("/catalog/") } +/** Every surface under a Project now names an Environment, uniformly, under `/env`. */ export function isEnvironmentSurface(pathname: string) { - return ( - pathname.endsWith("/settings/api-keys") || - pathname.includes("/monetization/") || - pathname.includes("/analytics/") || - pathname.includes("/billing/") - ) + return pathname.includes("/env/") } diff --git a/apps/dashboard/src/features/paywall-editor/components/design-system-controls.tsx b/apps/dashboard/src/features/paywall-editor/components/design-system-controls.tsx index 8684a7e5..1543e80d 100644 --- a/apps/dashboard/src/features/paywall-editor/components/design-system-controls.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/design-system-controls.tsx @@ -7,6 +7,13 @@ import { PlusIcon } from "@phosphor-icons/react/dist/ssr/Plus" import { TrashIcon } from "@phosphor-icons/react/dist/ssr/Trash" import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { InspectorColorControl } from "@/features/paywall-editor/components/inspector-color-control" import { useEditorActions } from "@/features/paywall-editor/stores/editor-store-context" import type { @@ -245,6 +252,19 @@ export function ColorControl({ ) } +const BACKGROUND_KIND_OPTIONS = [ + { label: "Colour", value: "color" }, + { label: "Linear gradient", value: "linearGradient" }, + { label: "Radial gradient", value: "radialGradient" }, + { label: "Image", value: "image" }, + { label: "Video", value: "video" }, +] + +const CONTENT_MODE_OPTIONS = [ + { label: "Fit", value: "fit" }, + { label: "Fill", value: "fill" }, +] + export function BackgroundEditor({ document, id, @@ -287,28 +307,43 @@ export function BackgroundEditor({ if (videos[0]) onChange(defaultMediaBackground(type, videos[0].id)) } } + const assetOptions = (value.type === "image" ? images : videos).map((asset) => ({ + label: asset.id, + value: asset.id, + })) + const posterOptions = [ + { label: "No poster", value: "" }, + ...images.map((asset) => ({ label: asset.id, value: asset.id })), + ] const selectedMediaExists = value.type === "image" || value.type === "video" ? document.assets.some((asset) => asset.type === value.type && asset.id === value.assetId) : true return (
- changeType(kind as ProtocolBackground["type"])} value={value.type} > - - - - - - + + + + + {BACKGROUND_KIND_OPTIONS.map((option) => ( + + {option.label} + + ))} + + {images.length === 0 || videos.length === 0 || !selectedMediaExists ? (
{!selectedMediaExists ? ( @@ -456,45 +491,59 @@ export function BackgroundEditor({ ) : null} {value.type === "image" || value.type === "video" ? ( <> - onChange({ ...value, assetId })} value={value.assetId} > - {(value.type === "image" ? images : videos).map((asset) => ( - - ))} - - + + + + + + {CONTENT_MODE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + {value.type === "video" ? ( - + onChange({ ...value, posterAssetId: posterAssetId || undefined }) } value={value.posterAssetId ?? ""} > - - {images.map((asset) => ( - - ))} - + + + + + {posterOptions.map((option) => ( + + {option.label} + + ))} + + ) : null} + isSafeTokenReplacement(designSystem, pendingDelete.category, pendingDelete.id, token.id), + ) + .map((token) => ({ label: `Replace usages with ${token.name}`, value: token.id })), + ] +} + export function DesignSystemPanel() { const { document } = useEditorStore() const editor = useEditorActions() @@ -321,30 +347,25 @@ export function DesignSystemPanel() { tone="warning" >

This style is in use.

- + + + + + {tokenReplacementOptions(designSystem, pendingDelete).map((option) => ( + + {option.label} + + ))} + + +
{document.products.map((reference) => { const selected = products.find((product) => product.id === reference.productId) const label = resolveLocalizedText(document, reference.label, currentLocale) return ( -
@@ -403,7 +446,7 @@ function ConnectedProductBindingContext({ : `${providerLabel ?? "Provider"} · ${mapping.availability} · ${mapping.syncState.replaceAll("_", " ")} · readiness ${readiness.data?.state ?? "unavailable"}` const diagnosticsHref = source.kind === "hosted" - ? `/organizations/${encodeURIComponent(source.organizationId)}/projects/${encodeURIComponent(source.projectId)}/catalog/products/${encodeURIComponent(productId)}?environmentId=${encodeURIComponent(environmentId)}&applicationId=${encodeURIComponent(applicationId)}&returnTo=${encodeURIComponent(hostedStudioHref(source))}` + ? `/orgs/${encodeURIComponent(source.organizationId)}/projects/${encodeURIComponent(source.projectId)}/catalog/products/${encodeURIComponent(productId)}?environmentId=${encodeURIComponent(environmentId)}&applicationId=${encodeURIComponent(applicationId)}&returnTo=${encodeURIComponent(hostedStudioHref(source))}` : undefined return ( @@ -488,18 +531,22 @@ export function MockCommercePanel({ > Preview state - + + + + + {MOCK_PURCHASE_STATES.map((state) => ( + + {state.label} + + ))} + +

Mock product bindings

diff --git a/apps/dashboard/src/features/paywall-editor/components/preview-canvas.test.tsx b/apps/dashboard/src/features/paywall-editor/components/preview-canvas.test.tsx index ff035d48..f56a61b2 100644 --- a/apps/dashboard/src/features/paywall-editor/components/preview-canvas.test.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/preview-canvas.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { chooseSelectOption } from "@/test/select" import { useLayoutEffect } from "react" import { describe, expect, it } from "vitest" @@ -413,22 +414,25 @@ describe("PreviewCanvas layer metadata", () => { expect(screen.getByTestId("device-status-bar")).toHaveTextContent("82") expect(screen.getByTestId("device-status-bar")).toHaveStyle({ paddingInline: "35px" }) - fireEvent.change(screen.getByRole("combobox", { name: "Preview device" }), { - target: { value: "iphone-17-pro-max" }, - }) + await chooseSelectOption( + screen.getByRole("combobox", { name: "Preview device" }), + "iPhone 17 Pro Max", + ) expect(screen.getByTestId("device-status-bar")).toHaveStyle({ paddingInline: "40px" }) - fireEvent.change(screen.getByRole("combobox", { name: "Preview device" }), { - target: { value: "iphone-17-pro" }, - }) + await chooseSelectOption( + screen.getByRole("combobox", { name: "Preview device" }), + "iPhone 17 Pro", + ) fireEvent.click(screen.getByRole("button", { name: "Use landscape orientation" })) device = region.querySelector("[data-device-width]") expect(device).toHaveAttribute("data-device-width", "874") expect(device).toHaveAttribute("data-device-height", "402") - fireEvent.change(screen.getByRole("combobox", { name: "Preview device" }), { - target: { value: "pixel-10-pro" }, - }) + await chooseSelectOption( + screen.getByRole("combobox", { name: "Preview device" }), + "Pixel 10 Pro", + ) device = region.querySelector("[data-device-width]") expect(device).toHaveAttribute("data-device-width", "920") expect(device).toHaveAttribute("data-device-height", "412") @@ -445,9 +449,7 @@ describe("PreviewCanvas layer metadata", () => { expect(device).toHaveAttribute("data-canvas-fit-mode", "fit") fireEvent.click(screen.getByRole("button", { name: "Open preview settings" })) - fireEvent.change(screen.getByRole("combobox", { name: "Preview locale" }), { - target: { value: "ar" }, - }) + await chooseSelectOption(screen.getByRole("combobox", { name: "Preview locale" }), /^ar · /) await waitFor(() => expect(screen.getByTestId("preview-locale")).toHaveTextContent("ar")) fireEvent.change(screen.getByRole("slider", { name: /Preview text scale/i }), { target: { value: "1.5" }, diff --git a/apps/dashboard/src/features/paywall-editor/components/preview-controls.test.tsx b/apps/dashboard/src/features/paywall-editor/components/preview-controls.test.tsx index 4aca0af2..20d65d56 100644 --- a/apps/dashboard/src/features/paywall-editor/components/preview-controls.test.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/preview-controls.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react" +import { chooseSelectOption } from "@/test/select" import { useEffect } from "react" import { describe, expect, it } from "vitest" @@ -57,7 +58,7 @@ describe("preview controls", () => { expect(screen.getByText("150%")).toBeInTheDocument() }) - it("changes the portable default locale in one history step without corrupting defaults", () => { + it("changes the portable default locale in one history step without corrupting defaults", async () => { render( @@ -66,9 +67,7 @@ describe("preview controls", () => { , ) - fireEvent.change(screen.getByRole("combobox", { name: "Default locale" }), { - target: { value: "de" }, - }) + await chooseSelectOption(screen.getByRole("combobox", { name: "Default locale" }), "de") expect(screen.getByTestId("document-default-locale")).toHaveTextContent("de") expect(screen.getByTestId("headline-default")).toHaveTextContent( "Erstelle eine Bezahlschranke, die Menschen sofort verstehen", diff --git a/apps/dashboard/src/features/paywall-editor/components/preview-controls.tsx b/apps/dashboard/src/features/paywall-editor/components/preview-controls.tsx index bf1a9299..bc1ec876 100644 --- a/apps/dashboard/src/features/paywall-editor/components/preview-controls.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/preview-controls.tsx @@ -12,6 +12,15 @@ import { useReactFlow, useViewport } from "@xyflow/react" import type { ReactNode } from "react" import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Popover, PopoverContent, @@ -81,6 +90,14 @@ function IconControl({ ) } +const DEVICE_ITEM_GROUPS = CANVAS_DEVICE_GROUPS.map((group) => ({ + items: CANVAS_DEVICE_PRESETS.filter((entry) => entry.group === group).map((entry) => ({ + label: entry.label, + value: entry.id, + })), + label: group, +})) + function DeviceSelect({ toolbar }: { toolbar: boolean }) { const canvas = useStudioWorkspaceSelector(selectCanvasPreferences) const workspace = useStudioWorkspaceActions() @@ -104,29 +121,34 @@ function DeviceSelect({ toolbar }: { toolbar: boolean }) { className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" /> )} - + workspace.setCanvasPreference("device", value as StudioCanvasDevice) } value={canvas.device} > - {CANVAS_DEVICE_GROUPS.map((group) => ( - - {CANVAS_DEVICE_PRESETS.flatMap((entry) => - entry.group === group ? ( - - ) : ( - [] - ), - )} - - ))} - + + + + + {DEVICE_ITEM_GROUPS.map((group) => ( + + {group.label} + {group.items.map((option) => ( + + {option.label} + + ))} + + ))} + + ) @@ -150,27 +172,38 @@ function SecondaryPreviewSettings({ toolbar }: { toolbar: boolean }) { if (next) setPreference("countdownPreviewAt", next) } + const previewLocaleOptions = Object.entries(document.localization.locales).map( + ([locale, catalog]) => ({ + label: `${locale} · ${catalog.direction.toUpperCase()}`, + value: locale, + }), + ) + const countdownFieldId = toolbar ? "canvas-toolbar-countdown-preview-at" : "preview-panel-countdown-preview-at" return (
-
+
+ + - + + + + + {localeOptions.map((option) => ( + + {option.label} + + ))} + + +
) diff --git a/apps/dashboard/src/features/paywall-editor/components/property-inspector-accessibility.tsx b/apps/dashboard/src/features/paywall-editor/components/property-inspector-accessibility.tsx index 3b2e90da..b235f069 100644 --- a/apps/dashboard/src/features/paywall-editor/components/property-inspector-accessibility.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/property-inspector-accessibility.tsx @@ -27,6 +27,7 @@ import { NumberField, SelectField, } from "@/features/paywall-editor/components/property-inspector-fields" +import { SelectItem } from "@/components/ui/select" export function seedOptionalLocalizedText(options: { defaultValue: string @@ -141,8 +142,8 @@ export function TextAccessibilitySection({ } value={node.accessibility.role} > - - + Text + Heading {node.accessibility.role === "heading" ? ( {document.designSystem.backgrounds.map((token) => ( - + ))} ) : null} @@ -443,9 +444,9 @@ export function DocumentBackgroundEditor({ value={value.assetId} > {(value.type === "image" ? imageAssets : videoAssets).map((asset) => ( - + ))} - - + Fit + Fill {value.type === "video" ? ( - + No poster {document.assets.flatMap((asset) => asset.type === "image" ? ( - + ) : ( [] ), @@ -558,11 +559,11 @@ export function ShadowSection({ node }: { node: ProtocolNode }) { } value={shadow?.type ?? "none"} > - - - + {shadow?.type === "shadowToken" ? ( {document.designSystem.shadows.map((token) => ( - + ))} ) : null} diff --git a/apps/dashboard/src/features/paywall-editor/components/property-inspector-basic-nodes.tsx b/apps/dashboard/src/features/paywall-editor/components/property-inspector-basic-nodes.tsx index 02433af1..28c9829b 100644 --- a/apps/dashboard/src/features/paywall-editor/components/property-inspector-basic-nodes.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/property-inspector-basic-nodes.tsx @@ -57,6 +57,7 @@ import { TypographySection, VisibilitySection, } from "@/features/paywall-editor/components/property-inspector-layout" +import { SelectItem } from "@/components/ui/select" export function ScrollContainerInspector({ layout }: { layout: ScrollContainer }) { const { document } = useInspectorContext() @@ -90,10 +91,10 @@ export function ScrollContainerInspector({ layout }: { layout: ScrollContainer } }} value={presentation} > - - + {screen && !isInitial && presentation === "screen" ? (
)} diff --git a/apps/dashboard/src/features/paywall-editor/components/property-inspector-layout.tsx b/apps/dashboard/src/features/paywall-editor/components/property-inspector-layout.tsx index 53e45fca..91a13205 100644 --- a/apps/dashboard/src/features/paywall-editor/components/property-inspector-layout.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/property-inspector-layout.tsx @@ -1,4 +1,3 @@ -import { CaretDownIcon } from "@phosphor-icons/react/dist/ssr/CaretDown" import type { ReactNode } from "react" import { useEditorActions } from "@/features/paywall-editor/stores/editor-store-context" @@ -28,6 +27,19 @@ import { NumberField, SelectField, } from "@/features/paywall-editor/components/property-inspector-fields" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +const AXIS_MODE_OPTIONS = [ + { label: "Fit", value: "fit" }, + { label: "Fill", value: "fill" }, + { label: "Fixed", value: "fixed" }, +] export function AppearanceSection({ children, @@ -283,22 +295,30 @@ export function SizingAxisField({ className="border-input relative h-full w-8 shrink-0 border-s" title={`${axisLabel} behaviour`} > - onModeChange(next as "fill" | "fit" | "fixed")} value={mode} > - - - - - + + {/* The caret alone is the affordance here: the chosen mode is + already spelled out in the value beside this control. */} + + + + {AXIS_MODE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + )} @@ -354,10 +374,10 @@ export function TypographyFields({ value={typography.style} > {["display", "title", "heading", "body", "label", "caption"].map((style) => ( - + ))} {["regular", "medium", "semibold", "bold"].map((weight) => ( - + ))} @@ -408,9 +428,9 @@ export function TypographyFields({ onChange={(alignment) => update({ ...typography, alignment } as TypographyValue)} value={typography.alignment} > - - - + Start + Centre + End {supportsMaxLines ? ( @@ -449,8 +469,8 @@ export function TypographyFields({ } value={extended.overflow ?? "ellipsis"} > - - + Ellipsis + Clip ) : null} @@ -523,11 +543,11 @@ export function VisibilitySection({ node }: { node: ProtocolNode }) { }} value={visibility.mode} > - - - + {visibility.mode === "switch" ? ( <> @@ -538,13 +558,13 @@ export function VisibilitySection({ node }: { node: ProtocolNode }) { value={visibility.switchId} > {switches.map((candidate) => ( - + ))} @@ -272,11 +273,11 @@ export function ProductLayerStyleSection({ node }: { node: ProductLayerNode }) { } value={resolved.shadow?.type ?? "none"} > - - - + {resolved.shadow?.type === "shadowToken" ? ( {document.designSystem.shadows.map((token) => ( - + ))} ) : null} diff --git a/apps/dashboard/src/features/paywall-editor/components/property-inspector-products.tsx b/apps/dashboard/src/features/paywall-editor/components/property-inspector-products.tsx index c58c83f8..1f045a80 100644 --- a/apps/dashboard/src/features/paywall-editor/components/property-inspector-products.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/property-inspector-products.tsx @@ -46,6 +46,7 @@ import { ProductLayerLayoutSection, ProductLayerStyleSection, } from "@/features/paywall-editor/components/property-inspector-product-styles" +import { SelectItem } from "@/components/ui/select" export function ProductCardInspector({ node, @@ -83,13 +84,13 @@ export function ProductCardInspector({ value={node.productReferenceId} > {document.products.map((candidate) => ( - + ))} - - + Nested in card + Overlaid on card {node.placement.mode === "overlay" ? ( @@ -248,10 +249,10 @@ export function ProductBadgeInspector({ } value={node.placement.anchor} > - - - - + Top start + Top end + Bottom start + Bottom end { ) const presentation = await screen.findByRole("combobox", { name: "Presentation" }) - expect(within(presentation).getByRole("option", { name: "Sheet" })).toBeEnabled() - fireEvent.change(presentation, { target: { value: "sheet" } }) + await chooseSelectOption(presentation, "Sheet") await waitFor(() => expect(screen.getByTestId("inspector-document")).toHaveTextContent( '"id":"main","presentation":{"type":"sheet"}', @@ -72,7 +72,7 @@ describe("property inspector safety", () => { expect(screen.getByRole("spinbutton", { name: "Aspect ratio" })).toHaveAttribute("max", "10") openInspectorSection("Advanced") expectReadOnlyField("image-1", "type", "image") - expect(screen.getByRole("combobox", { name: "Width behaviour" })).toHaveValue("fill") + expect(screen.getByRole("combobox", { name: "Width behaviour" })).toBeVisible() }) it("exposes production Protocol 0.2 text controls progressively", async () => { @@ -106,15 +106,15 @@ describe("property inspector safety", () => { const productInspector = await screen.findByRole("region", { name: "Properties" }) expect(within(productInspector).queryByText("monthly-plan")).not.toBeInTheDocument() expect(within(productInspector).queryByText("yearly-plan")).not.toBeInTheDocument() - expect(within(productInspector).getByRole("option", { name: "Monthly" })).toHaveValue( - "monthly-plan", - ) + expect(within(productInspector).getByLabelText("Product")).toHaveTextContent("Monthly") productRender.unmount() renderInspector("purchase") openInspectorSection("Actions") expect(screen.queryByText("plans")).not.toBeInTheDocument() - expect(screen.getByRole("option", { name: "Product Selector" })).toHaveValue("plans") + expect(screen.getByRole("combobox", { name: "Product selector" })).toHaveTextContent( + "Product Selector", + ) }) it("keeps structure in Layers while using group semantics for product sets", async () => { diff --git a/apps/dashboard/src/features/paywall-editor/components/property-inspector.test.tsx b/apps/dashboard/src/features/paywall-editor/components/property-inspector.test.tsx index edb2f3e1..2eb98e0a 100644 --- a/apps/dashboard/src/features/paywall-editor/components/property-inspector.test.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/property-inspector.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react" +import { chooseSelectOption } from "@/test/select" import { describe, expect, it } from "vitest" import { EDITOR_TEMPLATES } from "@/features/paywall-editor/constants/templates" @@ -20,8 +21,13 @@ describe("property inspector safety", () => { it("keeps product binding contextual to the selected Product Card", async () => { renderInspector("monthly-card") const product = await screen.findByLabelText("Product") - expect(product).toHaveValue("monthly-plan") - expect(within(product).getByRole("option", { name: "Yearly" })).toBeDisabled() + expect(product).toHaveTextContent("Monthly") + fireEvent.click(product) + expect(await screen.findByRole("option", { name: "Yearly" })).toHaveAttribute( + "data-disabled", + "", + ) + fireEvent.keyDown(document.body, { key: "Escape" }) expect(screen.getByTestId("bindings")).toHaveTextContent("monthly-plan,yearly-plan") }) @@ -105,19 +111,17 @@ describe("property inspector safety", () => { renderInspector("monthly-card") const size = within(await waitFor(() => openInspectorSection("Layout"))) - fireEvent.change(size.getByRole("combobox", { name: "Width behaviour" }), { - target: { value: "fixed" }, - }) + await chooseSelectOption(size.getByRole("combobox", { name: "Width behaviour" }), "Fixed") expect(size.getByRole("spinbutton", { name: "Fixed width" })).toHaveValue(320) expect(screen.getByTestId("inspector-document")).toHaveTextContent( '"width":{"mode":"fixed","value":320}', ) - fireEvent.change(size.getByRole("combobox", { name: "Width behaviour" }), { - target: { value: "fill" }, - }) - expect(size.getByRole("combobox", { name: "Width behaviour" })).toHaveValue("fill") + await chooseSelectOption(size.getByRole("combobox", { name: "Width behaviour" }), "Fill") + expect(size.getByRole("combobox", { name: "Width behaviour" })).toHaveAccessibleName( + "Width behaviour", + ) expect(size.getByText(/Width Fill is unbounded/)).toBeVisible() expect(screen.getByTestId("inspector-document")).toHaveTextContent('"sizing":{"width":"fill"') }) diff --git a/apps/dashboard/src/features/paywall-editor/components/studio-automated-workflow.test.tsx b/apps/dashboard/src/features/paywall-editor/components/studio-automated-workflow.test.tsx index b4ca4b19..b0905202 100644 --- a/apps/dashboard/src/features/paywall-editor/components/studio-automated-workflow.test.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/studio-automated-workflow.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react" +import { chooseSelectOption } from "@/test/select" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { PaywallEditorWorkspace } from "@/features/paywall-editor/components/paywall-editor-workspace" @@ -151,15 +152,16 @@ describe("Studio automated workflow", () => { ) expect(actionSection).not.toBeNull() fireEvent.click(actionSection!.querySelector("summary")!) - expect(screen.getByRole("combobox", { name: "Product selector" })).toHaveValue("plans") + expect(screen.getByRole("combobox", { name: "Product selector" })).toHaveTextContent( + "Product Selector", + ) - fireEvent.change(screen.getByRole("combobox", { name: "Preview device" }), { - target: { value: "pixel-10-pro" }, - }) + await chooseSelectOption( + screen.getByRole("combobox", { name: "Preview device" }), + "Pixel 10 Pro", + ) fireEvent.click(screen.getByRole("button", { name: "Open preview settings" })) - fireEvent.change(screen.getByRole("combobox", { name: "Preview locale" }), { - target: { value: "ar" }, - }) + await chooseSelectOption(screen.getByRole("combobox", { name: "Preview locale" }), /^ar · /) fireEvent.click(screen.getByRole("checkbox", { name: "Force RTL" })) const device = canvas.querySelector('[data-device-id="pixel-10-pro"]') @@ -206,9 +208,11 @@ describe("Studio automated workflow", () => { fireEvent.click(await screen.findByRole("button", { name: "Resume autosave" })) await screen.findByTestId("studio-editor-shell") - expect(screen.getByRole("combobox", { name: "Preview device" })).toHaveValue("pixel-10-pro") + expect(screen.getByRole("combobox", { name: "Preview device" })).toHaveTextContent( + "Pixel 10 Pro", + ) fireEvent.click(screen.getByRole("button", { name: "Open preview settings" })) - expect(screen.getByRole("combobox", { name: "Preview locale" })).toHaveValue("ar") + expect(screen.getByRole("combobox", { name: "Preview locale" })).toHaveTextContent(/^ar · /) expect(screen.getByRole("checkbox", { name: "Force RTL" })).toBeChecked() const restoredCanvas = screen.getByRole("region", { name: "Browser editing preview" }) expect(within(restoredCanvas).getByRole("heading", { level: 1 })).toHaveTextContent( diff --git a/apps/dashboard/src/features/paywall-editor/components/studio-tool-panel.test.tsx b/apps/dashboard/src/features/paywall-editor/components/studio-tool-panel.test.tsx index 3569096b..2460c13c 100644 --- a/apps/dashboard/src/features/paywall-editor/components/studio-tool-panel.test.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/studio-tool-panel.test.tsx @@ -1,4 +1,5 @@ import { useLayoutEffect } from "react" +import { chooseSelectOption } from "@/test/select" import type { RefObject } from "react" import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" @@ -163,7 +164,7 @@ describe("StudioToolPanel", () => { expect(screen.queryByRole("textbox", { name: "Name for Colour 1" })).not.toBeInTheDocument() expect(screen.getByRole("textbox", { name: "Name for Background 1" })).toBeVisible() const kind = screen.getByRole("combobox", { name: "Background kind" }) - fireEvent.change(kind, { target: { value: "linearGradient" } }) + await chooseSelectOption(kind, "Linear gradient") fireEvent.click(screen.getByRole("button", { name: "Add stop" })) expect(screen.getByTestId("tool-document-json")).toHaveTextContent('"position":0.5') @@ -180,7 +181,7 @@ describe("StudioToolPanel", () => { fireEvent.click(screen.getByRole("button", { name: "Add image" })) await waitFor(() => - expect(screen.getByRole("combobox", { name: "Background kind" })).toHaveValue("image"), + expect(screen.getByRole("combobox", { name: "Background kind" })).toHaveTextContent("Image"), ) expect(screen.getByTestId("tool-document-json")).toHaveTextContent( '"type":"image","id":"image-1"', diff --git a/apps/dashboard/src/features/paywall-editor/components/studio-tool-panel.tsx b/apps/dashboard/src/features/paywall-editor/components/studio-tool-panel.tsx index f939ab02..6b2bf31d 100644 --- a/apps/dashboard/src/features/paywall-editor/components/studio-tool-panel.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/studio-tool-panel.tsx @@ -6,6 +6,13 @@ import type { RefObject } from "react" import { useQuery } from "@tanstack/react-query" import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { buttonVariants } from "@/components/ui/button-variants" import { generatedAssetAdapter } from "@/features/assets/api/generated-asset-adapter" import { assetsQueryOptions } from "@/features/assets/queries/asset-queries" @@ -132,7 +139,16 @@ function HostedManagedAssets({ ...assetsQueryOptions(source.projectId, generatedAssetAdapter), }) const readyManagedAssets = managedAssets.data?.filter((asset) => asset.status === "ready") ?? [] - const assetsHref = `/organizations/${encodeURIComponent(source.organizationId)}/projects/${encodeURIComponent(source.projectId)}/monetization/${encodeURIComponent(source.environmentId)}/assets?returnTo=${encodeURIComponent(hostedStudioHref(source))}` + const assetsHref = `/orgs/${encodeURIComponent(source.organizationId)}/projects/${encodeURIComponent(source.projectId)}/monetization/${encodeURIComponent(source.environmentId)}/assets?returnTo=${encodeURIComponent(hostedStudioHref(source))}` + + function managedAssetOptions(kind: Asset["type"]) { + return [ + { label: "Choose a managed Asset", value: "" }, + ...readyManagedAssets + .filter((candidate) => candidate.kind === kind) + .map((candidate) => ({ label: candidate.name, value: candidate.id })), + ] + } function selectedManagedAssetId(asset: Asset) { if (asset.source.type !== "remote") return "" @@ -166,14 +182,14 @@ function HostedManagedAssets({ {readyManagedAssets.length > 0 && assets.length > 0 ? (
{assets.map((asset) => ( - + + +
))} ) : null} @@ -298,15 +316,17 @@ function AssetsPanel({ assets }: { assets: readonly Asset[] }) { - + + + + + {ASSET_SOURCE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +