diff --git a/.claude/agents/mosaic-backend.md b/.claude/agents/mosaic-backend.md index ccbc66a6..7a2fd093 100644 --- a/.claude/agents/mosaic-backend.md +++ b/.claude/agents/mosaic-backend.md @@ -1,7 +1,7 @@ --- name: mosaic-backend description: Owns Mosaic's Go modular-monolith backend, REST APIs, persistence, publishing, telemetry, and workers. Use for any change under apps/api, apps/worker, or migrations. -model: claude-fable-5 +model: claude-opus-5 --- You are the Mosaic backend owner. diff --git a/.claude/agents/mosaic-dashboard.md b/.claude/agents/mosaic-dashboard.md index f31b7a8e..7149c97d 100644 --- a/.claude/agents/mosaic-dashboard.md +++ b/.claude/agents/mosaic-dashboard.md @@ -1,7 +1,7 @@ --- name: mosaic-dashboard description: Owns Mosaic Studio and dashboard using TanStack Start, Tailwind CSS, shadcn/ui, and Base UI. Use for any change under apps/dashboard or frontend documentation. -model: claude-fable-5 +model: claude-opus-5 --- You are the Mosaic dashboard owner. diff --git a/.gitignore b/.gitignore index a6c511be..04612766 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .env xcuserdata/ *.xcuserstate + +# compiled binaries +apps/api/worker +apps/api/api diff --git a/CHANGELOG.md b/CHANGELOG.md index f04f2a87..5444d73e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +- **iOS SDK: authoritative customer entitlements** (Authoritative Entitlement + Contract v1, draft). `Mosaic.configure` gains an optional + `customerTokenProvider`; a new `MosaicCustomer…` surface reports what Mosaic + has validated for a Billing Customer, alongside — never replacing — the + existing provider-observed entitlement surface, which is unchanged. + `checkCustomerEntitlement(_:)`, `customerEntitlementSnapshot()`, + `customerEntitlementUpdates()`, `refreshCustomerEntitlements()`, + `customerEntitlementDiagnostics()`, `restoreAndSyncCustomerEntitlements()`, + and `clearCustomerState()`. Requires an application backend to mint Customer + Access Tokens; there is no anonymous mode. Tokens are memory-only, never + logged, never parsed. Bounded-grace offline policy with server-issued windows, + a per-customer backup-excluded cache, and the normative rule that any + rejection yields `unknown` and never `inactive`. + - Protocol schemas are embedded in the API binary (`go:embed`), so a released image can no longer be built without them. Filesystem overrides still work for operators pinning a schema; a drift test fails the build if an embedded copy @@ -32,6 +46,25 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm (including the 304 path) and analytics ingestion. - `healthcheck` probe binary, so the distroless image can answer container healthchecks. +- iOS: transaction-observation submissions now carry the current Customer Access + Token in a `Mosaic-Customer-Token` header when one is held, binding an + identified user's purchase to their Billing Customer server-side. The token is + read at send time, never persisted with the queue, and never logged; without + one a submission still succeeds and anchors anonymously. The 9A observation + record is unchanged — this is transport-level only. +- iOS: `MosaicDiagnosticStage` gains `entitlementTransport`, + `entitlementValidation`, `entitlementCache`, `entitlementAuthentication`, and + `entitlementRestore`. This is source-breaking for a host that switches + exhaustively over the enumeration, which is accepted at `0.1.0-dev`. +- iOS: the StoreKit adapter now emits transaction observations on the **restore** + path from `Transaction.currentEntitlements`, not only on purchase. A + fresh-device restore previously submitted nothing, so a restored purchase was + never associated with a Billing Customer. Idempotent through the existing + acceptance store and submission-identifier de-duplication. +- iOS: the StoreKit acceptance store and the identity store are now excluded from + backup. Restoring either onto a second device corrupted behaviour that is + supposed to be per-install: duplicate-delivery suppression and installation + identity. - Trusted-proxy middleware: `X-Forwarded-For`/`X-Real-IP` are honoured only from a peer inside `MOSAIC_TRUSTED_PROXY_CIDRS` (default: none). - Baseline rate limits for authenticated dashboard APIs and for Placement and diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 397a931d..33490064 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -16,6 +16,14 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/analytics" "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/billingdiagnostics" + "github.com/Mujhtech/mosaic/apps/api/internal/billinggrant" + "github.com/Mujhtech/mosaic/apps/api/internal/billingoperator" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" + "github.com/Mujhtech/mosaic/apps/api/internal/billingrestore" + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" "github.com/Mujhtech/mosaic/apps/api/internal/browserauth" "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" "github.com/Mujhtech/mosaic/apps/api/internal/experiment" @@ -25,7 +33,17 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstorejws" "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstoreserver" "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingaccesspostgres" + "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/billinggrantpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingkeys" + "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" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingrestorepostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingseam" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingwebhookpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/browserauthpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/buildinfo" "github.com/Mujhtech/mosaic/apps/api/internal/platform/cloudworkspacepostgres" @@ -255,7 +273,15 @@ func run() (runErr error) { analyticsEventLimiter := ratelimit.New(cfg.Analytics.KeyEventsPerMinute, cfg.Analytics.KeyEventBurst, cfg.Analytics.LimiterEntries) var billingService *billing.Service - var billingIPLimiter, billingKeyLimiter *ratelimit.Limiter + var billingAccessService *billingaccess.Service + var billingDiagnosticsService *billingdiagnostics.Service + var billingGrantService *billinggrant.Service + var billingRestoreService *billingrestore.Service + var billingCustomerService *billingcustomer.Service + var billingOperatorService *billingoperator.Service + var billingProjectionService *billingprojection.Service + var billingWebhookService *billingwebhook.Service + var billingIPLimiter, billingKeyLimiter, entitlementSyncLimiter *ratelimit.Limiter if cfg.Billing.Enabled { billingCipher, err := providercredential.NewAESGCMCipher(cfg.Providers.CredentialKeyring, rand.Reader) if err != nil { @@ -287,12 +313,58 @@ func run() (runErr error) { if err != nil { return fmt.Errorf("configure Google Play client: %w", err) } + billingIPLimiter = ratelimit.New(cfg.Billing.ObservationsPerMinute, cfg.Billing.ObservationBurst, cfg.Billing.LimiterEntries) + billingKeyLimiter = ratelimit.New(cfg.Billing.ObservationsPerMinute, cfg.Billing.ObservationBurst, cfg.Billing.LimiterEntries) + billingAccessService = billingaccess.NewService( + billingaccesspostgres.New(databasePool), + billingaccesspostgres.NewKeyAuthenticator(billingpostgres.New(databasePool)), + billingaccess.WithIssuer(cfg.Telemetry.ServiceName), + billingaccess.WithFreshness(billingaccess.Freshness{ + RefreshAfter: cfg.Billing.EntitlementRefreshAfter, + ValidFor: cfg.Billing.EntitlementValidFor, + StaleGrace: cfg.Billing.EntitlementStaleGrace(), + })) + entitlementSyncLimiter = ratelimit.New(cfg.Billing.EntitlementSyncPerMinute, + cfg.Billing.EntitlementSyncBurst, cfg.Billing.LimiterEntries) + // The API process runs no projection jobs; it constructs the projection + // service only to enqueue triggers (identity movements) and to run + // bounded operator replays. Both go through the same command the worker + // runs, so there is no second write path. + projectionRepository := billingprojectionpostgres.New(databasePool) + billingProjectionService = billingprojection.NewService(projectionRepository) + billingDiagnosticsService = billingdiagnostics.NewService( + billingdiagnosticspostgres.New(databasePool), + billingdiagnostics.WithReplay(billingProjectionService, projectionRepository)) + billingKeys := billingkeys.New(billingpostgres.New(databasePool)) + billingRestoreService = billingrestore.NewService( + billingrestorepostgres.New(databasePool), billingKeys.Restore()) + billingCustomerService = billingcustomer.NewService( + billingcustomerpostgres.New(databasePool), billingKeys.Identity(), billingProjectionService) + // The Phase 9A→9B seam. The ingestion service is constructed last + // because it depends on it: an observation submitted with a Customer + // Access Token records the association that lets a first purchase reach + // an identified customer, and a committed fact hands its lineage to the + // identity service. Without this the 9B read model is unreachable from a + // purchase, which was defect D-1. billingService = billing.NewService(billingpostgres.New(databasePool), billingCipher, verifier, billing.WithProviders(appleClient, googleClient), billing.WithRetention(cfg.Billing.RawRetention()), - billing.WithNotificationBaseURL(cfg.Billing.NotificationBaseURL)) - billingIPLimiter = ratelimit.New(cfg.Billing.ObservationsPerMinute, cfg.Billing.ObservationBurst, cfg.Billing.LimiterEntries) - billingKeyLimiter = ratelimit.New(cfg.Billing.ObservationsPerMinute, cfg.Billing.ObservationBurst, cfg.Billing.LimiterEntries) + billing.WithNotificationBaseURL(cfg.Billing.NotificationBaseURL), + billing.WithSeam(billingseam.New(billingCustomerService, billingAccessService), + billingseam.New(billingCustomerService, billingAccessService))) + 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 + // answer derived once. Its own repository holds only the read model the + // dashboard needs and no writer at all. + billingOperatorService = billingoperator.NewService( + billingoperatorpostgres.New(databasePool), + billingaccesspostgres.New(databasePool), + billingCustomerService) + billingWebhookService = billingwebhook.NewService( + billingwebhookpostgres.New(databasePool), billingCipher, + billingwebhook.NewPolicy(billingwebhook.WithSelfHostedAllowlist( + cfg.Billing.WebhookAllowPrivateDestinations))) } readiness := health.NewReadiness( @@ -320,27 +392,35 @@ 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, - BillingIPLimiter: billingIPLimiter, - BillingKeyLimiter: billingKeyLimiter, - 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, + 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/billingdemo/demo9b.go b/apps/api/cmd/billingdemo/demo9b.go new file mode 100644 index 00000000..6b047123 --- /dev/null +++ b/apps/api/cmd/billingdemo/demo9b.go @@ -0,0 +1,1513 @@ +//go:build billingdemo + +// This file belongs to the build-tagged demonstration driver and is excluded +// from every ordinary build. See demo9b_stubs.go for why that matters. +// +// It drives the fourteen Phase 9B demonstrations against the real Mosaic +// router, the real Phase 9B application services, the real worker job +// functions, and a real PostgreSQL database. +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" + billinghttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billing" +) + +func (d *demo) stages9B() []func() error { + return []func() error{ + d.stage9BSetup, + d.demo1InitialSubscription, + d.demo2Renewal, + d.demo3Cancellation, + d.demo4Expiration, + d.demo5MultipleSources, + d.demo6Refund, + d.demo7GraceAndRecovery, + d.demo8OutOfOrder, + d.demo9UpgradeDowngrade, + d.demo10Restore, + d.demo11OfflineCache, + d.demo12IdentityConflict, + d.demo13WebhookRetry, + d.demo14ReplayAndRuleVersions, + } +} + +// --------------------------------------------------------------------------- +// Scenario clock and transaction identifiers +// --------------------------------------------------------------------------- + +// Every effective time in this demonstration is expressed as an offset from one +// baseline instant and travels inside a provider payload. Nothing waits for the +// wall clock to advance and nothing manipulates a clock: a subscription expires +// because the provider says its period ended, which is the only thing that ever +// expires a subscription in production either. +func (d *demo) at(offset time.Duration) time.Time { + return d.scenarioBaseline.Add(offset).UTC().Truncate(time.Millisecond) +} + +const ( + // One original transaction id per purchase lineage. Apple's chain digest is + // derived from it, so it is the lineage's identity. + lineageSubscription = "2000000900000001" // demos 1-4 + lineageResubscribe = "2000000900000002" // demo 5 + lineageLifetime = "2000000900000003" // demos 5-6 + lineageGrace = "2000000900000004" // demo 7 + lineageOutOfOrder = "2000000900000005" // demo 8 + lineageUpgrade = "2000000900000006" // demo 9 + lineageRestore = "2000000900000007" // demo 10 + lineageConflict = "2000000900000008" // demo 12 + lineageOneMinuteSub = "2000000900000009" // one-minute demo + lineageOneMinuteLifer = "2000000900000010" // one-minute demo +) + +// --------------------------------------------------------------------------- +// Stage 0 — tenant, grants, destination +// --------------------------------------------------------------------------- + +func (d *demo) stage9BSetup() error { + d.section("9B-0", "Environment, grant versions, and webhook destination") + d.scenarioBaseline = time.Now().UTC().Truncate(time.Millisecond) + + publicKey, serverKey, err := seedTenant9B(d.ctx, d.pool) + if err != nil { + return err + } + d.publicKey9B, d.serverKey9B = publicKey, serverKey + d.note("seeded %s / %s / %s (mode=production), application %s (%s)", + organizationID9B, projectID9B, environmentID9B, iosApplicationID9B, appleBundleID9B) + d.note("scenario baseline T = %s; every effective time below is T ± an offset carried in a provider payload", + d.scenarioBaseline.Format(time.RFC3339Nano)) + + d.step("Enable Mosaic Billing for the Project") + status, body := d.actor9B(http.MethodPut, "/v1/projects/"+projectID9B+"/billing/settings", + map[string]any{"billingEnabled": true}) + d.http("PUT /v1/projects/{projectId}/billing/settings", status, body) + if status != http.StatusOK { + return fmt.Errorf("enable billing returned %d", status) + } + + d.step("Create the Apple Store Server Credential for this Project") + secret, err := newApplePrivateKeyPEM() + if err != nil { + return err + } + status, body = d.actor9B(http.MethodPost, "/v1/projects/"+projectID9B+"/billing/store-credentials", map[string]any{ + "environmentId": environmentID9B, "provider": "app_store", "storeEnvironment": "production", + "name": "Demo 9B Apple team key", "secret": string(secret), + "appleIssuerId": "57246542-96fe-1a63-e053-0824d011072a", "appleKeyId": "2X9R4HXF34", + "applications": []map[string]string{{ + "applicationId": iosApplicationID9B, "platform": "ios", "providerApplicationIdentifier": appleBundleID9B, + }}, + }) + d.http("POST /v1/projects/{projectId}/billing/store-credentials", status, redactEndpoint(body)) + if status != http.StatusCreated { + return fmt.Errorf("credential create returned %d", status) + } + var created struct { + Data struct { + ID string `json:"id"` + NotificationEndpointURL string `json:"notificationEndpointUrl"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(body), &created); err != nil { + return err + } + d.credentialID9B = created.Data.ID + d.intakePath9B = strings.TrimPrefix(created.Data.NotificationEndpointURL, demoNotificationOrigin) + + d.step("Publish a Product-to-Entitlement Grant Version for each Product") + for _, product := range []struct { + id string + types []string + }{ + {productMonthly9B, []string{"auto_renewable_subscription"}}, + {productYearly9B, []string{"auto_renewable_subscription"}}, + {productLifetime9B, []string{"non_consumable"}}, + } { + status, body = d.actor9B(http.MethodPost, + "/v1/projects/"+projectID9B+"/billing/grant-versions", map[string]any{ + "productId": product.id, "entitlementId": entitlementID9B, + // Prospective, as the accepted policy requires: a grant version + // takes effect now or later unless it is explicitly marked + // retroactive. Purchases that predate every recorded version + // select the earliest one by the documented backfill rule. + "effectiveStart": time.Now().UTC().Add(time.Second).Format(time.RFC3339), + "supportedPurchaseTypes": product.types, + "reason": "Phase 9B integrated demonstration", + }) + d.http("POST /v1/projects/{projectId}/billing/grant-versions ("+product.id+")", status, body) + if status != http.StatusCreated { + return fmt.Errorf("grant publish for %s returned %d", product.id, status) + } + } + d.query("published grant versions (immutable, one open interval per pair)", + `SELECT product_id, entitlement_id, version, grant_policy_version, grants_in_active, grants_in_trial, + grants_in_grace, grants_in_billing_retry, grants_in_one_time_ownership, + supported_purchase_types, effective_end IS NULL AS open + FROM product_entitlement_grant_versions WHERE project_id=$1 ORDER BY product_id`, projectID9B) + + d.step("Refuse an in-place edit of a published grant version") + var grantVersionID string + if err := d.pool.QueryRow(d.ctx, + `SELECT id FROM product_entitlement_grant_versions WHERE project_id=$1 AND product_id=$2`, + projectID9B, productMonthly9B).Scan(&grantVersionID); err != nil { + return err + } + status, body = d.actor9B(http.MethodPatch, + "/v1/projects/"+projectID9B+"/billing/grant-versions/"+grantVersionID, map[string]any{}) + d.http("PATCH /v1/projects/{projectId}/billing/grant-versions/{versionId}", status, body) + + d.step("Register the application webhook destination") + status, body = d.actor9B(http.MethodPost, + "/v1/projects/"+projectID9B+"/environments/"+environmentID9B+"/billing/webhook-destinations", + map[string]any{"url": d.destination.url, "description": "Phase 9B demonstration destination"}) + d.http("POST .../billing/webhook-destinations", status, redactSecret(body)) + if status != http.StatusCreated { + return fmt.Errorf("destination create returned %d", status) + } + var destination struct { + Data struct { + ID string `json:"id"` + Secret string `json:"secret"` + SecretID string `json:"secretId"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(body), &destination); err != nil { + return err + } + d.destinationID = destination.Data.ID + d.destination.addSecret(destination.Data.Secret) + d.note("destination %s registered at %s; the signing secret was returned once and is held only by the stub", + d.destinationID, d.destination.url) + d.note("the destination is https and the delivery policy verifies its certificate chain — no verification is skipped") + + d.step("Verify Mosaic's signing function against the shared cross-implementation vectors") + vectors, err := loadSignatureVectors(webhookVectorFile) + if err != nil { + return err + } + for _, vector := range vectors { + produced := billingWebhookSign(vector.Secret, vector.Timestamp, vector.EventID, []byte(vector.RawBody)) + d.note("vector %-32s produced==published: %v", vector.ID, produced == vector.Signature) + if produced != vector.Signature { + return fmt.Errorf("signature vector %s disagrees with the published value", vector.ID) + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Demonstration 1 — initial subscription +// --------------------------------------------------------------------------- + +func (d *demo) demo1InitialSubscription() error { + d.demonstration(1, "Initial subscription") + + d.step("Create the Billing Customer through the trusted identity API") + status, body := d.server9B(http.MethodPost, "/v1/billing/identity/customers", + map[string]any{"applicationUserId": applicationUserA9B}) + d.http("POST /v1/billing/identity/customers", status, body) + if status != http.StatusCreated && status != http.StatusOK { + return fmt.Errorf("customer create returned %d", status) + } + var customer struct { + Data struct { + BillingCustomerID string `json:"billingCustomerId"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(body), &customer); err != nil { + return err + } + d.customerA = customer.Data.BillingCustomerID + + d.step("Issue a Customer Access Token for the SDK sync audience") + token, err := d.issueToken(d.customerA, "demo-9b-token-a") + if err != nil { + return err + } + d.tokenA = token + d.bindToken = token + d.query("the token is stored as a digest, never as a value", + `SELECT audience, scopes, octet_length(token_digest) AS digest_bytes, + (expires_at > issued_at) AS bounded, (revoked_at IS NULL) AS live, + round(extract(epoch from (expires_at - issued_at)))::text AS ttl_seconds + FROM customer_access_tokens WHERE project_id=$1`, projectID9B) + + d.step("Validated Apple purchase through the real 9A ingestion and validation path") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000001-0000-4000-8000-000000000001", + NotificationType: "SUBSCRIBED", Subtype: "INITIAL_BUY", + SignedAt: d.at(-25 * 24 * time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000001", OriginalTransactionID: lineageSubscription, + ProductID: appleMonthly9B, PurchaseDate: d.at(-25 * 24 * time.Hour), + ExpiresDate: timePointer(d.at(5 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageSubscription, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-25 * 24 * time.Hour)}, + }); err != nil { + return err + } + d.query("the validated Transaction Fact", + `SELECT fact_kind, transaction_type, resolution_state, mosaic_product_id, + period_start_at, period_end_at, renewal_expected, validator_version, + encode(purchase_chain_digest,'hex') AS chain_digest + FROM billing_transaction_facts WHERE project_id=$1 ORDER BY recorded_at`, projectID9B) + + d.step("Associate the purchase lineage with the Billing Customer (submission-context evidence)") + d.query("purchase lineage and its association evidence", + `SELECT l.provider, l.lineage_type, l.projection_frozen, l.diagnostic_status, + (l.billing_customer_id = $2) AS attached_to_customer, + (SELECT string_agg(e.evidence_type||'/'||e.outcome, ', ' ORDER BY e.id) + FROM billing_association_evidence e WHERE e.purchase_lineage_id = l.id) AS evidence + FROM purchase_lineages l WHERE l.project_id=$1`, projectID9B, d.customerA) + + d.step("Project the Subscription Snapshot and the Customer Entitlement Snapshot") + if err := d.project(d.customerA); err != nil { + return err + } + d.showSubscriptionState() + d.showCustomerSnapshot() + d.query("the Entitlement Source names (lineage, product, grant version) — never a fact id", + `SELECT s.source_type, s.source_state, s.explanation_code, s.is_test_source, + (s.grant_version_id IS NOT NULL) AS has_grant_version, + (s.subscription_instance_id IS NOT NULL) AS from_subscription + FROM entitlement_sources s WHERE s.project_id=$1 ORDER BY s.created_at DESC LIMIT 5`, projectID9B) + + d.step("Fetch through the trusted server API") + status, body = d.server9B(http.MethodGet, + "/v1/billing/server/customers/"+d.customerA+"/entitlements?environmentId="+environmentID9B, nil) + d.http("GET /v1/billing/server/customers/{customerId}/entitlements", status, body) + + d.step("Fetch through the SDK sync wire (POST entitlementSyncRequest)") + syncStatus, syncBody, headers := d.sdkSync(d.tokenA, 0, "", []string{entitlementKey9B}) + d.http("POST /v1/sdk/billing/entitlements", syncStatus, syncBody) + d.note("freshness headers: refresh-after=%s valid-until=%s stale-grace-seconds=%s etag=%s", + headers.Get("Mosaic-Refresh-After"), headers.Get("Mosaic-Valid-Until"), + headers.Get("Mosaic-Stale-Grace-Seconds"), headers.Get("ETag")) + d.note("this is the wire the three SDKs consume; the Flutter, iOS, and Android clients are proven against") + d.note("the same contract fixtures by their own conformance suites, which this driver does not re-run") + + d.step("Deliver the signed webhook to the local destination stub") + if err := d.drainWebhooks(6); err != nil { + return err + } + d.showDeliveries() + return nil +} + +// --------------------------------------------------------------------------- +// Demonstration 2 — renewal +// --------------------------------------------------------------------------- + +func (d *demo) demo2Renewal() error { + d.demonstration(2, "Renewal") + + priorVersion, priorEnd := d.snapshotVersion(d.customerA), d.subscriptionPeriodEnd(lineageSubscription) + d.note("prior snapshot version %d, prior effective end %s", priorVersion, priorEnd) + + d.step("Ingest the validated renewal (new period begins at T-3h)") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000002-0000-4000-8000-000000000002", + NotificationType: "DID_RENEW", SignedAt: d.at(-3 * time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000002", OriginalTransactionID: lineageSubscription, + ProductID: appleMonthly9B, TransactionReason: "RENEWAL", + PurchaseDate: d.at(-3 * time.Hour), ExpiresDate: timePointer(d.at(30 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageSubscription, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-3 * time.Hour)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + + d.showSubscriptionState() + d.query("prior Subscription Snapshots are preserved, never rewritten", + `SELECT s.projection_version, s.access_state, s.lifecycle_state, s.period_end_at, + (s.id = i.current_snapshot_id) AS is_current + FROM subscription_snapshots s JOIN subscription_instances i ON i.id = s.subscription_instance_id + WHERE s.project_id=$1 ORDER BY s.projection_version`, projectID9B) + d.note("effective end moved from %s to %s", priorEnd, d.subscriptionPeriodEnd(lineageSubscription)) + d.note("customer snapshot version %d → %d (monotonic; a no-change projection does not advance it)", + priorVersion, d.snapshotVersion(d.customerA)) + d.query("customer entitlement snapshot history", + `SELECT snapshot_version, change_reason, encode(checksum,'hex') AS checksum + FROM customer_entitlement_snapshots WHERE billing_customer_id=$1 ORDER BY snapshot_version`, d.customerA) + + d.step("Webhook policy for this change") + if err := d.drainWebhooks(6); err != nil { + return err + } + d.query("webhook events created so far (one per customer entitlement snapshot)", + `SELECT e.event_type, e.snapshot_version, e.payload->'payload'->>'sourceReason' AS source_reason, + e.payload->'payload'->'changedEntitlements' AS changed + FROM webhook_events e WHERE e.project_id=$1 ORDER BY e.created_at`, projectID9B) + d.note("a renewal that extends a period without changing which Entitlements are held is a no-change") + d.note("projection: no snapshot is minted and no webhook is emitted for it") + + d.step("Refresh the SDK wire") + status, body, _ := d.sdkSync(d.tokenA, 0, "", nil) + d.http("POST /v1/sdk/billing/entitlements", status, body) + return nil +} + +// --------------------------------------------------------------------------- +// Demonstration 3 — cancellation without immediate revocation +// --------------------------------------------------------------------------- + +func (d *demo) demo3Cancellation() error { + d.demonstration(3, "Cancellation without immediate revocation") + + d.step("Ingest the validated auto-renew-disabled fact (effective T-2h)") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000003-0000-4000-8000-000000000003", + NotificationType: "DID_CHANGE_RENEWAL_STATUS", Subtype: "AUTO_RENEW_DISABLED", + SignedAt: d.at(-2 * time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000003", OriginalTransactionID: lineageSubscription, + ProductID: appleMonthly9B, TransactionReason: "RENEWAL", + PurchaseDate: d.at(-3 * time.Hour), ExpiresDate: timePointer(d.at(30 * 24 * time.Hour)), + SignedDate: d.at(-2 * time.Hour), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageSubscription, AutoRenewStatus: 0, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-2 * time.Hour)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + + d.showSubscriptionState() + d.query("renewal intent is off, access is still active, and the scheduled expiration is visible", + `SELECT s.access_state, s.renewal_intent, s.billing_state, s.cancellation_effective_at, + s.period_end_at AS scheduled_expiration + FROM subscription_snapshots s JOIN subscription_instances i ON i.current_snapshot_id = s.id + WHERE s.project_id=$1`, projectID9B) + d.showCustomerSnapshot() + d.showTimeline(lineageSubscription) + if err := d.drainWebhooks(6); err != nil { + return err + } + d.note("cancellation changed no Entitlement state, so it produced no entitlements-changed event —") + d.note("the access-change vocabulary is deliberately about access, not about provider intent") + d.showDeliveries() + return nil +} + +// --------------------------------------------------------------------------- +// Demonstration 4 — expiration +// --------------------------------------------------------------------------- + +func (d *demo) demo4Expiration() error { + d.demonstration(4, "Expiration") + + d.step("The validated period end passes (driven by the provider's effective time, not a wall-clock wait)") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000004-0000-4000-8000-000000000004", + NotificationType: "EXPIRED", Subtype: "VOLUNTARY", SignedAt: d.at(-time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000004", OriginalTransactionID: lineageSubscription, + ProductID: appleMonthly9B, TransactionReason: "RENEWAL", + PurchaseDate: d.at(-3 * time.Hour), ExpiresDate: timePointer(d.at(-time.Hour)), + SignedDate: d.at(-time.Hour), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageSubscription, AutoRenewStatus: 0, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-time.Hour)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + + d.showSubscriptionState() + d.showCustomerSnapshot() + d.query("the subscription Entitlement Source is no longer granting", + `SELECT s.source_type, s.source_state, s.explanation_code, s.source_end + FROM entitlement_sources s + WHERE s.customer_entitlement_snapshot_id = ( + SELECT current_snapshot_id FROM customer_entitlement_pointers + WHERE billing_customer_id=$1 AND environment_id=$2)`, d.customerA, environmentID9B) + if err := d.drainWebhooks(6); err != nil { + return err + } + d.showDeliveries() + + d.step("The SDK wire reflects the inactive state") + status, body, _ := d.sdkSync(d.tokenA, 0, "", []string{entitlementKey9B}) + d.http("POST /v1/sdk/billing/entitlements", status, body) + return nil +} + +// --------------------------------------------------------------------------- +// Demonstration 5 — multiple sources +// --------------------------------------------------------------------------- + +func (d *demo) demo5MultipleSources() error { + d.demonstration(5, "Multiple sources granting one Entitlement") + + d.step("A new subscription (resubscribe) and a lifetime one-time purchase, both granting `pro`") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000005-0000-4000-8000-000000000005", + NotificationType: "SUBSCRIBED", Subtype: "RESUBSCRIBE", SignedAt: d.at(-30 * time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000005", OriginalTransactionID: lineageResubscribe, + ProductID: appleMonthly9B, PurchaseDate: d.at(-30 * time.Minute), + ExpiresDate: timePointer(d.at(30 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageResubscribe, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-30 * time.Minute)}, + }); err != nil { + return err + } + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000006-0000-4000-8000-000000000006", + NotificationType: "ONE_TIME_CHARGE", SignedAt: d.at(-20 * time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000006", OriginalTransactionID: lineageLifetime, + ProductID: appleLifetime9B, ProductType: "Non-Consumable", + PurchaseDate: d.at(-20 * time.Minute), + }, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + d.showCustomerSnapshot() + d.showSources() + + d.step("Expire the subscription source; the lifetime source keeps access active") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000007-0000-4000-8000-000000000007", + NotificationType: "EXPIRED", Subtype: "VOLUNTARY", SignedAt: d.at(-10 * time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000007", OriginalTransactionID: lineageResubscribe, + ProductID: appleMonthly9B, TransactionReason: "RENEWAL", + PurchaseDate: d.at(-30 * time.Minute), ExpiresDate: timePointer(d.at(-10 * time.Minute)), + SignedDate: d.at(-10 * time.Minute), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageResubscribe, AutoRenewStatus: 0, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-10 * time.Minute)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + d.note("Defect D-4 is fixed. The state below is what a deployed worker produces after the") + d.note("expiration, through the queued path alone: the lifetime source still grants `pro`, and") + d.note("both subscription sources remain in the aggregate. The projection job carries a customer") + d.note("id and no lineage id, so the aggregate is computed from every lineage the customer owns.") + d.showCustomerSnapshot() + d.showSources() + d.query("the job the worker actually ran, and what it was scoped to", + `SELECT kind, scope_key, detail->>'lineageId' AS lineage_in_detail, status + FROM projection_jobs WHERE project_id=$1 ORDER BY created_at DESC LIMIT 3`, projectID9B) + + d.step("Both source histories remain inspectable") + d.query("every lineage this customer owns and its current state", + `SELECT l.lineage_type, l.diagnostic_status, + COALESCE(ss.access_state, oi.validity_state) AS state, + COALESCE(ss.lifecycle_state, '-') AS lifecycle_state + FROM purchase_lineages l + LEFT JOIN subscription_instances si ON si.purchase_lineage_id = l.id + LEFT JOIN subscription_snapshots ss ON ss.id = si.current_snapshot_id + LEFT JOIN one_time_purchase_instances oi ON oi.purchase_lineage_id = l.id + WHERE l.billing_customer_id=$1 ORDER BY l.created_at`, d.customerA) + return d.drainWebhooks(8) +} + +// --------------------------------------------------------------------------- +// Demonstration 6 — refund or revocation +// --------------------------------------------------------------------------- + +func (d *demo) demo6Refund() error { + d.demonstration(6, "Refund or revocation") + + d.step("Ingest a validated Apple refund for the lifetime purchase only") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000008-0000-4000-8000-000000000008", + NotificationType: "REFUND", SignedAt: d.at(-5 * time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000008", OriginalTransactionID: lineageLifetime, + ProductID: appleLifetime9B, ProductType: "Non-Consumable", + PurchaseDate: d.at(-20 * time.Minute), + RevocationDate: timePointer(d.at(-5 * time.Minute)), RevocationReason: intPointer(0), + SignedDate: d.at(-5 * time.Minute), + }, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + + d.query("the refunded source, and the unrelated sources beside it", + `SELECT l.lineage_type, + COALESCE(ss.access_state, oi.validity_state) AS state, + COALESCE(oi.refund_effective_at::text, '-') AS refund_effective_at, + COALESCE(oi.revocation_effective_at::text, '-') AS revocation_effective_at + FROM purchase_lineages l + LEFT JOIN subscription_instances si ON si.purchase_lineage_id = l.id + LEFT JOIN subscription_snapshots ss ON ss.id = si.current_snapshot_id + LEFT JOIN one_time_purchase_instances oi ON oi.purchase_lineage_id = l.id + WHERE l.billing_customer_id=$1 ORDER BY l.created_at`, d.customerA) + d.showCustomerSnapshot() + d.query("history is intact: every fact for the refunded lineage is still recorded", + `SELECT fact_kind, occurred_at, refunded_at, revoked_at, COALESCE(refund_type,'-') AS refund_type + FROM billing_transaction_facts + WHERE project_id=$1 AND provider_original_transaction_id=$2 ORDER BY recorded_at`, + projectID9B, lineageLifetime) + if err := d.drainWebhooks(8); err != nil { + return err + } + d.showDeliveries() + + d.step("The SDK wire reports the change") + status, body, _ := d.sdkSync(d.tokenA, 0, "", []string{entitlementKey9B}) + d.http("POST /v1/sdk/billing/entitlements", status, body) + return nil +} + +// --------------------------------------------------------------------------- +// Demonstration 7 — grace period and recovery +// --------------------------------------------------------------------------- + +func (d *demo) demo7GraceAndRecovery() error { + d.demonstration(7, "Grace period and recovery") + + d.step("A subscription whose period has ended, with a provider-confirmed grace period") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000009-0000-4000-8000-000000000009", + NotificationType: "SUBSCRIBED", Subtype: "INITIAL_BUY", SignedAt: d.at(-40 * 24 * time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000009", OriginalTransactionID: lineageGrace, + ProductID: appleMonthly9B, PurchaseDate: d.at(-40 * 24 * time.Hour), + ExpiresDate: timePointer(d.at(-10 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageGrace, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-40 * 24 * time.Hour)}, + }); err != nil { + return err + } + // Apple has no separate grace notification: grace arrives as DID_FAIL_TO_RENEW + // carrying gracePeriodExpiresDate in the renewal info. + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b00000a-0000-4000-8000-00000000000a", + NotificationType: "DID_FAIL_TO_RENEW", Subtype: "GRACE_PERIOD", SignedAt: d.at(-10 * 24 * time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000010", OriginalTransactionID: lineageGrace, + ProductID: appleMonthly9B, TransactionReason: "RENEWAL", + PurchaseDate: d.at(-40 * 24 * time.Hour), ExpiresDate: timePointer(d.at(-10 * 24 * time.Hour)), + SignedDate: d.at(-10 * 24 * time.Hour), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageGrace, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, + IsInBillingRetry: true, + GracePeriodExpiresAt: timePointer(d.at(5 * 24 * time.Hour)), + SignedAt: d.at(-10 * 24 * time.Hour)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + d.query("grace is active, access is granted by the approved policy, and the grace end is recorded", + `SELECT ss.access_state, ss.lifecycle_state, ss.billing_state, ss.grace_period_end_at + FROM subscription_snapshots ss + JOIN subscription_instances si ON si.current_snapshot_id = ss.id + JOIN purchase_lineages l ON l.id = si.purchase_lineage_id + WHERE l.project_id=$1 AND l.lineage_key_digest = $2`, + projectID9B, billing.AppleTransactionKey("production", lineageGrace)) + d.showCustomerSnapshot() + + d.step("Payment recovers") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b00000b-0000-4000-8000-00000000000b", + NotificationType: "DID_RENEW", Subtype: "BILLING_RECOVERY", SignedAt: d.at(-2 * time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000011", OriginalTransactionID: lineageGrace, + ProductID: appleMonthly9B, TransactionReason: "RENEWAL", + PurchaseDate: d.at(-2 * time.Minute), ExpiresDate: timePointer(d.at(28 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageGrace, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-2 * time.Minute)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + d.query("recovered: active again, grace cleared", + `SELECT ss.access_state, ss.lifecycle_state, ss.billing_state, ss.grace_period_end_at, ss.period_end_at + FROM subscription_snapshots ss + JOIN subscription_instances si ON si.current_snapshot_id = ss.id + JOIN purchase_lineages l ON l.id = si.purchase_lineage_id + WHERE l.project_id=$1 AND l.lineage_key_digest = $2`, + projectID9B, billing.AppleTransactionKey("production", lineageGrace)) + d.showTimeline(lineageGrace) + return d.drainWebhooks(8) +} + +// --------------------------------------------------------------------------- +// Demonstration 8 — out-of-order fact +// --------------------------------------------------------------------------- + +func (d *demo) demo8OutOfOrder() error { + d.demonstration(8, "Out-of-order fact") + + d.step("Project a lineage with a purchase and a renewal, in order") + for index, event := range []appleEvent{ + { + NotificationUUID: "9b00000c-0000-4000-8000-00000000000c", + NotificationType: "SUBSCRIBED", SignedAt: d.at(-60 * 24 * time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000012", OriginalTransactionID: lineageOutOfOrder, + ProductID: appleMonthly9B, PurchaseDate: d.at(-60 * 24 * time.Hour), + ExpiresDate: timePointer(d.at(-30 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageOutOfOrder, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-60 * 24 * time.Hour)}, + }, + { + NotificationUUID: "9b00000d-0000-4000-8000-00000000000d", + NotificationType: "DID_RENEW", SignedAt: d.at(-30 * 24 * time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000013", OriginalTransactionID: lineageOutOfOrder, + ProductID: appleMonthly9B, TransactionReason: "RENEWAL", + PurchaseDate: d.at(-30 * 24 * time.Hour), ExpiresDate: timePointer(d.at(30 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageOutOfOrder, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-30 * 24 * time.Hour)}, + }, + } { + if err := d.deliverApple(event); err != nil { + return fmt.Errorf("event %d: %w", index, err) + } + } + if err := d.project(d.customerA); err != nil { + return err + } + d.query("checkpoint before the late fact", + `SELECT c.high_watermark, c.facts_projected, NOT c.invalidated AS valid, + encode(c.checksum,'hex') AS checksum + FROM projection_checkpoints c + JOIN subscription_instances si ON si.id = c.subscription_instance_id + JOIN purchase_lineages l ON l.id = si.purchase_lineage_id + WHERE l.lineage_key_digest=$1`, billing.AppleTransactionKey("production", lineageOutOfOrder)) + + d.step("A late EXPIRED for the earlier period arrives, with an effective time before the watermark") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b00000e-0000-4000-8000-00000000000e", + NotificationType: "EXPIRED", Subtype: "BILLING_RETRY", SignedAt: d.at(-45 * 24 * time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000014", OriginalTransactionID: lineageOutOfOrder, + ProductID: appleMonthly9B, TransactionReason: "RENEWAL", + PurchaseDate: d.at(-60 * 24 * time.Hour), ExpiresDate: timePointer(d.at(-45 * 24 * time.Hour)), + SignedDate: d.at(-45 * 24 * time.Hour), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageOutOfOrder, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-45 * 24 * time.Hour)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + d.query("the checkpoint was invalidated and the lineage was reprojected from zero", + `SELECT c.high_watermark, c.facts_projected, c.invalidated, + encode(c.checksum,'hex') AS checksum + FROM projection_checkpoints c + JOIN subscription_instances si ON si.id = c.subscription_instance_id + JOIN purchase_lineages l ON l.id = si.purchase_lineage_id + WHERE l.lineage_key_digest=$1`, billing.AppleTransactionKey("production", lineageOutOfOrder)) + d.query("every Subscription Snapshot for this lineage — priors are preserved", + `SELECT ss.projection_version, ss.access_state, ss.lifecycle_state, ss.period_start_at, ss.period_end_at, + (ss.id = si.current_snapshot_id) AS is_current + FROM subscription_snapshots ss + JOIN subscription_instances si ON si.id = ss.subscription_instance_id + JOIN purchase_lineages l ON l.id = si.purchase_lineage_id + WHERE l.lineage_key_digest=$1 ORDER BY ss.projection_version`, + billing.AppleTransactionKey("production", lineageOutOfOrder)) + d.note("the late fact is folded in its canonical position, not appended. The renewal at T-30d") + d.note("still sorts last, so the deterministic result is byte-identical to the pre-invalidation") + d.note("snapshot: the checkpoint advanced from 2 facts to 3, the checksum did not move, and no") + d.note("new snapshot was minted. That is the intended no-change outcome, and it is the strongest") + d.note("form of the determinism claim — a checkpoint is an optimization, never a source of truth.") + return d.drainWebhooks(8) +} + +// --------------------------------------------------------------------------- +// Demonstration 9 — upgrade or downgrade +// --------------------------------------------------------------------------- + +func (d *demo) demo9UpgradeDowngrade() error { + d.demonstration(9, "Upgrade with supersession") + + d.step("Start on the monthly Product") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b00000f-0000-4000-8000-00000000000f", + NotificationType: "SUBSCRIBED", SignedAt: d.at(-20 * 24 * time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000015", OriginalTransactionID: lineageUpgrade, + ProductID: appleMonthly9B, PurchaseDate: d.at(-20 * 24 * time.Hour), + ExpiresDate: timePointer(d.at(10 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageUpgrade, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-20 * 24 * time.Hour)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + d.query("current Product before the transition", + `SELECT ss.current_product_id, ss.access_state, ss.period_end_at + FROM subscription_snapshots ss + JOIN subscription_instances si ON si.current_snapshot_id = ss.id + JOIN purchase_lineages l ON l.id = si.purchase_lineage_id + WHERE l.lineage_key_digest=$1`, billing.AppleTransactionKey("production", lineageUpgrade)) + + d.step("Ingest the validated Product transition to the yearly Product") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000010-0000-4000-8000-000000000010", + NotificationType: "DID_CHANGE_RENEWAL_PREF", Subtype: "UPGRADE", SignedAt: d.at(-time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000016", OriginalTransactionID: lineageUpgrade, + ProductID: appleYearly9B, TransactionReason: "PURCHASE", + PurchaseDate: d.at(-time.Minute), ExpiresDate: timePointer(d.at(365 * 24 * time.Hour)), + IsUpgraded: true, SignedDate: d.at(-time.Minute), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageUpgrade, AutoRenewStatus: 1, + AutoRenewProductID: appleYearly9B, ProductID: appleYearly9B, SignedAt: d.at(-time.Minute)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + d.query("the new Product is current, the prior Product is preserved on the snapshot", + `SELECT ss.current_product_id, ss.prior_product_id, ss.access_state, + ss.period_start_at, ss.period_end_at, ss.scheduled_product_identifier + FROM subscription_snapshots ss + JOIN subscription_instances si ON si.current_snapshot_id = ss.id + JOIN purchase_lineages l ON l.id = si.purchase_lineage_id + WHERE l.lineage_key_digest=$1`, billing.AppleTransactionKey("production", lineageUpgrade)) + d.query("exactly one Entitlement Source per (lineage, entitlement, grant version) — no double grant", + `SELECT s.purchase_lineage_id, s.entitlement_id, s.grant_version_id, count(*) AS sources + FROM entitlement_sources s + WHERE s.customer_entitlement_snapshot_id = ( + SELECT current_snapshot_id FROM customer_entitlement_pointers + WHERE billing_customer_id=$1 AND environment_id=$2) + GROUP BY 1,2,3 ORDER BY 1`, d.customerA, environmentID9B) + d.query("provider mapping history for both Products", + `SELECT r.provider_product_identifier, r.mosaic_product_id, r.outcome, r.mapping_version + FROM billing_product_resolutions r WHERE r.project_id=$1 + AND r.provider_product_identifier IN ($2,$3) ORDER BY r.resolved_at`, + projectID9B, appleMonthly9B, appleYearly9B) + d.showTimeline(lineageUpgrade) + return d.drainWebhooks(8) +} + +// --------------------------------------------------------------------------- +// Demonstration 10 — restore across devices +// --------------------------------------------------------------------------- + +func (d *demo) demo10Restore() error { + d.demonstration(10, "Restore across devices") + + d.step("Device A observes a purchase and submits it through the public SDK endpoint") + signed, err := d.chain.signJWS(transactionVector{ + TransactionID: "3000000900000017", OriginalTransactionID: lineageRestore, + ProductID: appleMonthly9B, PurchaseDate: d.at(-15 * time.Minute), + ExpiresDate: timePointer(d.at(30 * 24 * time.Hour)), + }.payload()) + if err != nil { + return err + } + d.apple.addTransaction("3000000900000017", signed) + // Device A is a signed-in user, so its SDK carries the customer's token. That + // is what makes the restored purchase resolve to Customer A rather than to a + // purchase-anchored customer of its own. + status, body := d.raw(http.MethodPost, "/v1/sdk/billing/observations", + encode(observation9B("obs_demo9b_device_a", "sub_demo9b_device_a", "3000000900000017")), + map[string]string{ + "Authorization": "Bearer " + d.publicKey9B.raw, + billinghttp.CustomerTokenHeader: d.tokenA, + }) + d.http("POST /v1/sdk/billing/observations (device A)", status, body) + d.boundLineages[lineageRestore] = true + if err := d.drainValidation(6); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + versionAfterDeviceA := d.snapshotVersion(d.customerA) + d.note("snapshot version after device A: %d", versionAfterDeviceA) + + d.step("Device B syncs and observes the same snapshot version") + statusB, bodyB, headersB := d.sdkSync(d.tokenA, 0, "", nil) + d.http("POST /v1/sdk/billing/entitlements (device B)", statusB, truncate(bodyB, 600)) + d.note("device B ETag %s; snapshot version %d", headersB.Get("ETag"), versionAfterDeviceA) + + d.step("Device C runs a restore and submits duplicate observations") + status, body = d.public(http.MethodPost, "/v1/sdk/billing/observations", d.publicKey9B.raw, + observation9B("obs_demo9b_device_c", "sub_demo9b_device_c", "3000000900000017")) + d.http("POST /v1/sdk/billing/observations (device C, same transaction)", status, body) + if err := d.drainValidation(6); err != nil { + return err + } + d.query("duplicate-safe validation: one fact for the restored transaction, however many devices submit it", + `SELECT provider_transaction_id, count(*) AS facts, count(DISTINCT encode(fact_digest,'hex')) AS digests + FROM billing_transaction_facts WHERE project_id=$1 AND provider_transaction_id=$2 + GROUP BY provider_transaction_id`, projectID9B, "3000000900000017") + + status, body = d.sdkRaw(http.MethodPost, "/v1/sdk/billing/restores", map[string]any{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "restoreRequest", + "payload": map[string]any{ + "storePlatform": "apple_app_store", + "providerOutcome": "completed", + "observationSubmissionIds": []string{"sub_demo9b_device_a", "sub_demo9b_device_c"}, + "correlationId": "demo-9b-restore-device-c", + }, + }) + d.http("POST /v1/sdk/billing/restores (device C)", status, body) + if status != http.StatusAccepted { + return fmt.Errorf("restore submit returned %d", status) + } + var restore struct { + Payload struct { + RestoreID string `json:"restoreId"` + } `json:"payload"` + } + if err := json.Unmarshal([]byte(body), &restore); err != nil { + return err + } + + d.step("Run the restore-sync worker job (billingrestore.Service.ProcessNextRestoreSync)") + for attempt := 0; attempt < 5; attempt++ { + processed, err := d.restores.ProcessNextRestoreSync(d.ctx, "demo-worker") + if err != nil { + d.note("DEFECT: ProcessNextRestoreSync returned an error on attempt %d: %v", attempt+1, err) + break + } + d.note("ProcessNextRestoreSync attempt %d processed=%v", attempt+1, processed) + if !processed { + time.Sleep(3 * time.Second) + } + } + d.step("The restore settles: the chain read resolves facts to lineages by chain digest") + d.note("Defect D-2 is fixed. The stage-3 identity read used to join") + d.note("billing_transaction_facts.purchase_lineage_id, a column no migration creates, so every") + d.note("restore failed with SQLSTATE 42703, burned its attempts, and reported validation_pending") + d.note("forever. The probe below runs the relationship the repository now uses.") + d.probe("billingrestorepostgres stage-3 identity chain read (repository.go)", + `SELECT count(*) FROM restore_sync_job_inputs i + JOIN billing_transaction_facts f ON f.source_raw_input_id = i.raw_input_id + JOIN purchase_lineages l + ON l.environment_id = f.environment_id + AND l.provider = f.provider + AND l.lineage_key_digest = f.purchase_chain_digest + WHERE i.project_id = $1`, projectID9B) + d.query("restore job state", + `SELECT status, attempt_count, COALESCE(outcome,'-') AS outcome, + COALESCE(uncertainty_reason,'-') AS uncertainty_reason, + observed_transaction_count, baseline_snapshot_version, snapshot_version + FROM restore_sync_jobs WHERE project_id=$1 ORDER BY requested_at`, projectID9B) + status, body = d.sdkRaw(http.MethodGet, "/v1/sdk/billing/restores/"+restore.Payload.RestoreID, nil) + d.http("GET /v1/sdk/billing/restores/{restoreId}", status, body) + return nil +} + +// --------------------------------------------------------------------------- +// Demonstration 11 — offline cache, at the wire level +// --------------------------------------------------------------------------- + +func (d *demo) demo11OfflineCache() error { + d.demonstration(11, "Offline cache bounds, demonstrated at the wire level") + d.note("client-side cache state machines (fresh / refreshRecommended / staleWithinGrace / expired /") + d.note("missing / invalid / differentCustomer) are proven by the Flutter, iOS, and Android conformance") + d.note("suites against the shared fixtures. This driver demonstrates the wire those suites consume.") + + d.step("Fetch a fresh snapshot and read its freshness bounds") + status, body, headers := d.sdkSync(d.tokenA, 0, "", nil) + d.http("POST /v1/sdk/billing/entitlements", status, truncate(body, 900)) + var snapshot struct { + Payload struct { + SnapshotVersion int64 `json:"snapshotVersion"` + EntityTag string `json:"entityTag"` + IssuedAt string `json:"issuedAt"` + RefreshAfter string `json:"refreshAfter"` + ValidUntil string `json:"validUntil"` + StaleGraceSeconds int `json:"staleGraceSeconds"` + ContentDigest string `json:"contentDigest"` + } `json:"payload"` + } + if err := json.Unmarshal([]byte(body), &snapshot); err != nil { + return err + } + d.note("issuedAt=%s refreshAfter=%s validUntil=%s staleGraceSeconds=%d", + snapshot.Payload.IssuedAt, snapshot.Payload.RefreshAfter, + snapshot.Payload.ValidUntil, snapshot.Payload.StaleGraceSeconds) + d.note("contentDigest=%s (the integrity value every SDK recomputes before accepting a snapshot)", + snapshot.Payload.ContentDigest) + d.note("headers carry the same window so a bodyless answer still slides it: %s / %s / %s", + headers.Get("Mosaic-Refresh-After"), headers.Get("Mosaic-Valid-Until"), + headers.Get("Mosaic-Stale-Grace-Seconds")) + + d.step("Re-sync with the known version: the canonical snapshotUnchanged record slides the window") + status, body, headers = d.sdkSync(d.tokenA, snapshot.Payload.SnapshotVersion, snapshot.Payload.EntityTag, nil) + d.http("POST /v1/sdk/billing/entitlements (knownSnapshotVersion set)", status, body) + d.note("still 200 with a body: the negotiated SDK form never relies on freshness that lives only in headers") + + d.step("The GET form: a plain full-snapshot read") + status, body, headers = d.sdkConditionalGet(d.tokenA, snapshot.Payload.EntityTag) + d.http("GET /v1/sdk/billing/entitlements (If-None-Match)", status, truncate(body, 400)) + d.note("answered %d with refresh-after=%s valid-until=%s stale-grace-seconds=%s", + status, headers.Get("Mosaic-Refresh-After"), headers.Get("Mosaic-Valid-Until"), + headers.Get("Mosaic-Stale-Grace-Seconds")) + if status != http.StatusOK { + return fmt.Errorf("the GET form must answer 200 with a full snapshot; got %d", status) + } + d.note("Defect D-5 is fixed by removal: the GET form is a plain full-snapshot read. It carries no") + d.note("way to state a snapshot version, version equality is a precondition of `unchanged`, and") + d.note("the 304 branch was therefore dead on every request that could have taken it. The POST") + d.note("body's knownSnapshotVersion is the one conditional mechanism, and it is the one all") + d.note("three SDKs use.") + + d.step("A stale known version is answered with the current snapshot, never with the older one") + status, body, _ = d.sdkSync(d.tokenA, 1, "", nil) + d.http("POST /v1/sdk/billing/entitlements (knownSnapshotVersion=1)", status, truncate(body, 400)) + return nil +} + +// --------------------------------------------------------------------------- +// Demonstration 12 — identity conflict +// --------------------------------------------------------------------------- + +func (d *demo) demo12IdentityConflict() error { + d.demonstration(12, "Identity conflict") + + d.step("Create Billing Customer B") + status, body := d.server9B(http.MethodPost, "/v1/billing/identity/customers", + map[string]any{"applicationUserId": applicationUserB9B}) + d.http("POST /v1/billing/identity/customers", status, body) + var customer struct { + Data struct { + BillingCustomerID string `json:"billingCustomerId"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(body), &customer); err != nil { + return err + } + d.customerB = customer.Data.BillingCustomerID + tokenB, err := d.issueToken(d.customerB, "demo-9b-token-b") + if err != nil { + return err + } + d.tokenB = tokenB + + d.step("A purchase lineage is associated with Customer A") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000011-0000-4000-8000-000000000011", + NotificationType: "SUBSCRIBED", SignedAt: d.at(-9 * time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000018", OriginalTransactionID: lineageConflict, + ProductID: appleMonthly9B, PurchaseDate: d.at(-9 * time.Minute), + ExpiresDate: timePointer(d.at(30 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageConflict, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-9 * time.Minute)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + conflictLineage, err := d.lineageFor(lineageConflict) + if err != nil { + return err + } + versionABefore, versionBBefore := d.snapshotVersion(d.customerA), d.snapshotVersion(d.customerB) + + // Customer B's backend now claims the same purchase through the trusted + // server observation surface. A public SDK-key submission deliberately + // cannot freeze or reassign an attached lineage; the secret server key is + // the authority required to open this operator-resolved conflict. + d.step("Conflicting trusted identity evidence arrives naming Customer B") + status, body = d.raw(http.MethodPost, "/v1/billing/server/observations", + encode(trustedObservation9B("obs_bind_b_3000000900000019", "sub_bind_b_3000000900000019", "3000000900000019")), + map[string]string{ + "Authorization": "Bearer " + d.serverKey9B.raw, + billinghttp.CustomerTokenHeader: d.tokenB, + }) + if status != http.StatusAccepted && status != http.StatusOK { + return fmt.Errorf("trusted observation returned %d: %s", status, body) + } + if err := d.drainValidation(6); err != nil { + return err + } + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b000012-0000-4000-8000-000000000012", + NotificationType: "DID_RENEW", SignedAt: d.at(-8 * time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000019", OriginalTransactionID: lineageConflict, + ProductID: appleMonthly9B, PurchaseDate: d.at(-8 * time.Minute), + ExpiresDate: timePointer(d.at(31 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageConflict, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-8 * time.Minute)}, + }); err != nil { + return err + } + d.note("Customer A owns the lineage; Customer B's trusted backend claimed its renewal transaction") + d.query("the conflict is open, the lineage is frozen, and nothing was reassigned", + `SELECT c.conflict_scope, c.status, c.detail->>'diagnosticCode' AS diagnostic_code, + (c.first_customer_id=$2) AS first_is_a, (c.second_customer_id=$3) AS second_is_b, + l.projection_frozen, l.diagnostic_status, (l.billing_customer_id=$2) AS still_attached_to_a + FROM billing_identity_conflicts c JOIN purchase_lineages l ON l.id = c.purchase_lineage_id + WHERE c.project_id=$1 AND c.status='open'`, projectID9B, d.customerA, d.customerB) + + d.step("Neither customer is granted from the frozen lineage") + if err := d.project(d.customerA); err != nil { + return err + } + if err := d.project(d.customerB); err != nil { + return err + } + d.query("no double grant: the disputed lineage appears under exactly one Billing Customer", + `SELECT s.billing_customer_id, count(*) AS sources, count(DISTINCT s.source_state) AS states + FROM entitlement_sources s + WHERE s.project_id=$1 AND s.purchase_lineage_id=$2 GROUP BY 1`, projectID9B, conflictLineage) + d.note("the Entitlement `pro` is unchanged because a permanent one-time source still grants it;") + d.note("freezing the disputed lineage changed no Entitlement state, so no snapshot was minted and") + d.note("the last accepted authoritative state stands, which is the OD-10 requirement") + d.query("last accepted authoritative state is preserved for both customers", + `SELECT p.billing_customer_id, p.snapshot_version FROM customer_entitlement_pointers p + WHERE p.project_id=$1 ORDER BY p.billing_customer_id`, projectID9B) + + d.step("Operator resolution through the approved workflow") + var conflictID string + if err := d.pool.QueryRow(d.ctx, + `SELECT id FROM billing_identity_conflicts WHERE project_id=$1 AND status='open'`, + projectID9B).Scan(&conflictID); err != nil { + return err + } + status, body = d.operator9B(http.MethodPost, + "/v1/projects/"+projectID9B+"/billing/identity-conflicts/"+conflictID+"/resolution", + map[string]any{ + "action": "keep_existing", + "assignedBillingCustomerId": d.customerA, + "reason": "Support ticket 4711: the store account belongs to customer A.", + }) + d.http("POST /v1/projects/{projectId}/billing/identity-conflicts/{conflictId}/resolution", status, body) + if status != http.StatusOK { + return fmt.Errorf("conflict resolution returned %d", status) + } + d.query("the resolution is audited with its reason and the actor who took it", + `SELECT c.status, c.resolution_action, c.resolved_by_actor_id, + c.detail->>'resolutionReason' AS reason, l.projection_frozen, l.diagnostic_status + FROM billing_identity_conflicts c JOIN purchase_lineages l ON l.id = c.purchase_lineage_id + WHERE c.id=$1`, conflictID) + + d.step("Both customers are reprojected") + if err := d.drainProjection(12); err != nil { + return err + } + d.query("pointers after the resolution", + `SELECT p.billing_customer_id, p.snapshot_version, p.updated_at + FROM customer_entitlement_pointers p WHERE p.project_id=$1 ORDER BY p.billing_customer_id`, projectID9B) + d.note("customer A snapshot version %d → %d; customer B %d → %d", + versionABefore, d.snapshotVersion(d.customerA), versionBBefore, d.snapshotVersion(d.customerB)) + return d.drainWebhooks(8) +} + +// --------------------------------------------------------------------------- +// Demonstration 13 — webhook retry +// --------------------------------------------------------------------------- + +func (d *demo) demo13WebhookRetry() error { + d.demonstration(13, "Webhook retry with a stable event id") + + d.step("Make the destination fail its next delivery") + d.destination.failNext(http.StatusServiceUnavailable, 1) + + // The retried delivery is an already-committed entitlement change, re-queued + // through the operator replay surface. + // + // It used to be a REVOKE on the conflict lineage. That stopped producing an + // event once defect D-4 was fixed, and the reason is the fix working: the + // customer holds several granting sources at this point in the scenario, so + // revoking one changes no Entitlement state and Mosaic correctly mints no + // snapshot and emits no event. The event the demonstration used to retry was + // an artefact of the aggregate being recomputed from one lineage. + // + // Replaying a committed delivery keeps every property this demonstration is + // about — a stable event id, a byte-identical body across attempts, a real + // jittered backoff, an append-only attempt history — and reaches them + // through the operator API a person would actually use. + d.step("Re-queue a committed entitlement change through the operator replay surface") + deliveryID := d.lastDeliveryID() + if deliveryID == "" { + return fmt.Errorf("no committed webhook delivery to replay") + } + status, body := d.actor9B(http.MethodPost, + "/v1/projects/"+projectID9B+"/billing/webhook-deliveries/"+deliveryID+"/replay", nil) + d.http("POST .../billing/webhook-deliveries/{deliveryId}/replay", status, truncate(body, 240)) + if status != http.StatusOK && status != http.StatusAccepted { + return fmt.Errorf("replay returned %d", status) + } + + d.step("Attempt delivery: the destination is down") + if err := d.drainWebhooks(4); err != nil { + return err + } + d.query("the failed attempt is recorded and a retry is scheduled", + `SELECT a.attempt_number, a.outcome, a.response_status, COALESCE(a.error_code,'-') AS error_code, + (a.next_attempt_at IS NOT NULL) AS retry_scheduled + FROM webhook_delivery_attempts a WHERE a.project_id=$1 ORDER BY a.attempted_at DESC LIMIT 3`, + projectID9B) + d.query("the delivery is pending, not failed, and its state was never rolled back", + `SELECT dl.status, dl.attempt_count, dl.max_attempts, (dl.next_attempt_at > now()) AS scheduled_ahead + FROM webhook_deliveries dl WHERE dl.project_id=$1 AND dl.status='pending'`, projectID9B) + d.query("the entitlement state that produced the event is unchanged by the delivery failure", + `SELECT p.snapshot_version, s.change_reason + FROM customer_entitlement_pointers p + JOIN customer_entitlement_snapshots s ON s.id = p.current_snapshot_id + WHERE p.billing_customer_id=$1 AND p.environment_id=$2`, d.customerA, environmentID9B) + + d.step("The destination recovers; wait for the scheduled retry") + waited, err := d.waitForDelivery(2 * time.Minute) + if err != nil { + return err + } + d.note("retry became available after %s of real elapsed time; no clock was manipulated", waited.Round(time.Second)) + if err := d.drainWebhooks(6); err != nil { + return err + } + + d.step("Attempt history and byte-identical redelivery") + d.query("complete attempt history for the retried delivery", + `SELECT a.attempt_number, a.outcome, a.response_status, COALESCE(a.error_code,'-') AS error_code + FROM webhook_delivery_attempts a + WHERE a.project_id=$1 AND a.webhook_delivery_id IN ( + SELECT id FROM webhook_deliveries WHERE project_id=$1 AND attempt_count > 1) + ORDER BY a.webhook_delivery_id, a.attempt_number`, projectID9B) + d.compareRetryDeliveries() + return nil +} + +// --------------------------------------------------------------------------- +// Demonstration 14 — replay and rule versions +// --------------------------------------------------------------------------- + +func (d *demo) demo14ReplayAndRuleVersions() error { + d.demonstration(14, "Replay under the active rule, and an unimplemented rule version") + + snapshotsBefore, eventsBefore := d.counts() + checksumBefore := d.currentChecksum(d.customerA) + d.note("before the replay: %d customer snapshots, %d webhook events, current checksum %s", + snapshotsBefore, eventsBefore, checksumBefore) + + d.step("Replay one customer under the active rule version") + status, body := d.actor9B(http.MethodPost, + "/v1/projects/"+projectID9B+"/environments/"+environmentID9B+"/billing/projection-replays", + map[string]any{"billingCustomerId": d.customerA, "projectionRuleVersion": 1, "limit": 50}) + d.http("POST .../billing/projection-replays", status, body) + if status != http.StatusOK { + return fmt.Errorf("replay returned %d", status) + } + + snapshotsAfter, eventsAfter := d.counts() + d.note("after the replay: %d customer snapshots, %d webhook events, current checksum %s", + snapshotsAfter, eventsAfter, d.currentChecksum(d.customerA)) + d.note("identical checksum: %v; no new snapshot: %v; no new webhook: %v", + checksumBefore == d.currentChecksum(d.customerA), + snapshotsBefore == snapshotsAfter, eventsBefore == eventsAfter) + d.query("the replay recorded an attempt even though it wrote no snapshot", + `SELECT outcome, COALESCE(error_code,'-') AS error_code, rule_version, scope_key + FROM projection_attempts WHERE project_id=$1 ORDER BY started_at DESC LIMIT 3`, projectID9B) + + d.step("Request an unimplemented rule version") + status, body = d.actor9B(http.MethodPost, + "/v1/projects/"+projectID9B+"/environments/"+environmentID9B+"/billing/projection-replays", + map[string]any{"billingCustomerId": d.customerA, "projectionRuleVersion": 2}) + d.http("POST .../billing/projection-replays (projectionRuleVersion=2)", status, body) + d.note("shadow projection is deferred (plan OD-11(a)): rule versions are recorded on every snapshot") + d.note("and replay-plus-checksum comparison ships in 9B, while the diff engine waits for a second") + d.note("implemented rule version to diff against. A request for one is refused cleanly, never") + d.note("recomputed under the active semantics.") + d.query("one rule version exists and it is active", + `SELECT version, status, description FROM projection_rule_versions ORDER BY version`) + + d.step("Projection health") + status, body = d.actor9B(http.MethodGet, + "/v1/projects/"+projectID9B+"/environments/"+environmentID9B+"/billing/projection-health", nil) + d.http("GET .../billing/projection-health", status, body) + return nil +} + +// --------------------------------------------------------------------------- +// The one-minute demonstration +// --------------------------------------------------------------------------- + +func (d *demo) stageOneMinute() error { + if err := d.stage9BSetup(); err != nil { + return err + } + d.demonstration(0, "One-minute demonstration") + + d.step("Validated purchase → authoritative Pro Entitlement") + status, body := d.server9B(http.MethodPost, "/v1/billing/identity/customers", + map[string]any{"applicationUserId": applicationUserA9B}) + var customer struct { + Data struct { + BillingCustomerID string `json:"billingCustomerId"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(body), &customer); err != nil { + return err + } + d.customerA = customer.Data.BillingCustomerID + d.http("POST /v1/billing/identity/customers", status, body) + token, err := d.issueToken(d.customerA, "demo-9b-one-minute") + if err != nil { + return err + } + d.tokenA = token + d.bindToken = token + + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b0000f1-0000-4000-8000-0000000000f1", + NotificationType: "SUBSCRIBED", SignedAt: d.at(-time.Hour), + Transaction: transactionVector{ + TransactionID: "3000000900000101", OriginalTransactionID: lineageOneMinuteSub, + ProductID: appleMonthly9B, PurchaseDate: d.at(-time.Hour), + ExpiresDate: timePointer(d.at(30 * 24 * time.Hour)), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageOneMinuteSub, AutoRenewStatus: 1, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-time.Hour)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + d.showCustomerSnapshot() + + d.step("Cancellation keeps access through the period end") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b0000f2-0000-4000-8000-0000000000f2", + NotificationType: "DID_CHANGE_RENEWAL_STATUS", SignedAt: d.at(-30 * time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000102", OriginalTransactionID: lineageOneMinuteSub, + ProductID: appleMonthly9B, TransactionReason: "RENEWAL", PurchaseDate: d.at(-time.Hour), + ExpiresDate: timePointer(d.at(30 * 24 * time.Hour)), SignedDate: d.at(-30 * time.Minute), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageOneMinuteSub, AutoRenewStatus: 0, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-30 * time.Minute)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + d.showSubscriptionState() + d.showCustomerSnapshot() + + d.step("A lifetime purchase joins the same Entitlement") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b0000f3-0000-4000-8000-0000000000f3", + NotificationType: "ONE_TIME_CHARGE", SignedAt: d.at(-20 * time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000103", OriginalTransactionID: lineageOneMinuteLifer, + ProductID: appleLifetime9B, ProductType: "Non-Consumable", PurchaseDate: d.at(-20 * time.Minute), + }, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + + d.step("Expiration removes the subscription source; the lifetime source keeps access active") + if err := d.deliverApple(appleEvent{ + NotificationUUID: "9b0000f4-0000-4000-8000-0000000000f4", + NotificationType: "EXPIRED", SignedAt: d.at(-10 * time.Minute), + Transaction: transactionVector{ + TransactionID: "3000000900000104", OriginalTransactionID: lineageOneMinuteSub, + ProductID: appleMonthly9B, TransactionReason: "RENEWAL", PurchaseDate: d.at(-time.Hour), + ExpiresDate: timePointer(d.at(-10 * time.Minute)), SignedDate: d.at(-10 * time.Minute), + }, + Renewal: &renewalVector{OriginalTransactionID: lineageOneMinuteSub, AutoRenewStatus: 0, + AutoRenewProductID: appleMonthly9B, ProductID: appleMonthly9B, SignedAt: d.at(-10 * time.Minute)}, + }); err != nil { + return err + } + if err := d.project(d.customerA); err != nil { + return err + } + d.showCustomerSnapshot() + d.showSources() + + d.step("The SDK wire returns the same snapshot") + status, body, _ = d.sdkSync(d.tokenA, 0, "", []string{entitlementKey9B}) + d.http("POST /v1/sdk/billing/entitlements", status, body) + + d.step("The signed webhook reports the change") + if err := d.drainWebhooks(8); err != nil { + return err + } + d.showDeliveries() + return nil +} + +// --------------------------------------------------------------------------- +// Drivers +// --------------------------------------------------------------------------- + +// deliverApple posts one synthetic signed Apple notification to the real intake +// endpoint, installs the matching signed transaction on the App Store Server API +// stub, and runs the real validation worker job over it. +func (d *demo) deliverApple(event appleEvent) error { + body, signedTransaction, err := d.chain.buildAppleEvent(event) + if err != nil { + return err + } + d.apple.addTransaction(event.Transaction.TransactionID, signedTransaction) + // A real SDK reports the purchase it just made, carrying the customer's + // token. That submission is what lets the store's own notification — which + // names nobody — reach an identified customer. + if err := d.bindPurchase(d.bindToken, event.Transaction.OriginalTransactionID, + event.Transaction.TransactionID, "a_"); err != nil { + return err + } + status, response := d.raw(http.MethodPost, d.intakePath9B, body, nil) + d.note("intake %s/%s → %d %s", event.NotificationType, orDash(event.Subtype), status, truncate(response, 160)) + if status != http.StatusAccepted && status != http.StatusOK { + return fmt.Errorf("intake for %s returned %d: %s", event.NotificationUUID, status, response) + } + return d.drainValidation(6) +} + +// bindPurchase submits a Customer Access Token-bound observation for one +// transaction, which is how a purchase reaches an identified Billing Customer in +// production. +// +// This replaces the `bridge()` substitution the first Stage 4 run had to +// perform. Nothing is stood in for any more: the observation goes through the +// real public SDK endpoint with the real token, Mosaic records the +// submission-context association evidence itself, and when the notification's +// fact commits the seam reads that evidence back and attaches the lineage. The +// lineage row, both instance rows, the association, the supersession edge, and +// the projection trigger are all written by production code. +// +// One observation per purchase chain is enough. After the first fact the lineage +// carries an accepted association, and a prior association is itself the +// evidence that a renewal does not have to re-prove identity — which is exactly +// what a real SDK does: it reports the purchase once, and the store's +// notifications carry the rest of the lifecycle. +func (d *demo) bindPurchase(token, lineage, transactionID, label string) error { + if token == "" || d.boundLineages[lineage] { + return nil + } + d.boundLineages[lineage] = true + // The submission id is per (transaction, reporter). Two backends reporting + // the same purchase are two submissions, not a duplicate of one, and the + // second has to become its own Raw Billing Input or the claim it carries is + // never validated and never reaches the resolver. + status, body := d.raw(http.MethodPost, "/v1/sdk/billing/observations", + encode(observation9B("obs_bind_"+label+transactionID, "sub_bind_"+label+transactionID, transactionID)), + map[string]string{ + "Authorization": "Bearer " + d.publicKey9B.raw, + billinghttp.CustomerTokenHeader: token, + }) + if status != http.StatusAccepted && status != http.StatusOK { + return fmt.Errorf("token-bound observation for %s returned %d: %s", transactionID, status, body) + } + d.note("SDK observed transaction %s under a Customer Access Token; Mosaic recorded the association", + transactionID) + return d.drainValidation(6) +} + +// projectQueued enqueues a customer-scoped projection through the real trigger +// path and drains it with the real worker job function. This is exactly what a +// deployed worker does. +func (d *demo) projectQueued(customerID string) error { + if err := d.projection.Enqueue(d.ctx, billingprojection.Scope{ + ProjectID: projectID9B, EnvironmentID: environmentID9B, CustomerID: customerID, + }, billingprojection.KindFactCommitted); err != nil { + return err + } + return d.drainProjection(12) +} + +// project drives one customer projection exactly as a deployed worker does: +// enqueue through the real trigger, drain with the real job function. +// +// The direct customer-scoped `Project` call this used to end with was the +// workaround for defect D-4, which is fixed: the queued path now carries a +// customer id and no lineage id, so it recomputes the aggregate from every +// lineage the customer holds. +func (d *demo) project(customerID string) error { + return d.projectQueued(customerID) +} + +func (d *demo) drainProjection(limit int) error { + for index := 0; index < limit; index++ { + processed, err := d.projection.ProcessNextProjection(d.ctx, "demo-worker") + if err != nil { + return err + } + if !processed { + return nil + } + } + return nil +} + +func (d *demo) drainWebhooks(limit int) error { + for index := 0; index < limit; index++ { + processed, err := d.webhooks.ProcessNextDelivery(d.ctx, "demo-worker") + if err != nil { + return err + } + if !processed { + return nil + } + } + return nil +} + +func (d *demo) waitForDelivery(budget time.Duration) (time.Duration, error) { + started := time.Now() + for time.Since(started) < budget { + var ready bool + err := d.pool.QueryRow(d.ctx, + `SELECT COALESCE(bool_or(next_attempt_at <= now()), false) + FROM webhook_deliveries WHERE project_id=$1 AND status='pending'`, projectID9B).Scan(&ready) + if err != nil { + return 0, err + } + if ready { + return time.Since(started), nil + } + time.Sleep(time.Second) + } + return 0, fmt.Errorf("no webhook delivery became available within %s", budget) +} diff --git a/apps/api/cmd/billingdemo/demo9b_helpers.go b/apps/api/cmd/billingdemo/demo9b_helpers.go new file mode 100644 index 00000000..7e298abe --- /dev/null +++ b/apps/api/cmd/billingdemo/demo9b_helpers.go @@ -0,0 +1,462 @@ +//go:build billingdemo + +// This file belongs to the build-tagged demonstration driver and is excluded +// from every ordinary build. See demo9b_stubs.go for why that matters. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" + billingaccesshttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingaccess" + billingrestorehttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingrestore" +) + +// billingWebhookSign is Mosaic's own signing function, used to check the +// published cross-implementation vectors. The destination stub verifies with an +// independent implementation on purpose (demo9b_stubs.go). +func billingWebhookSign(secret string, timestamp int64, eventID string, body []byte) string { + return billingwebhook.Sign(secret, timestamp, eventID, body) +} + +// --------------------------------------------------------------------------- +// HTTP helpers for the Phase 9B tenant +// --------------------------------------------------------------------------- + +// actor9B calls a dashboard-authenticated operator route as the 9B owner. +func (d *demo) actor9B(method, path string, body any) (int, string) { + return d.raw(method, path, encode(body), map[string]string{"X-Demo-Actor": ownerActorID9B}) +} + +// operator9B calls the Phase 9B dashboard operator surface. It goes to the +// second mux built in wire(); see the defect recorded there. +func (d *demo) operator9B(method, path string, body any) (int, string) { + var reader io.Reader + if encoded := encode(body); encoded != "" { + reader = bytes.NewReader([]byte(encoded)) + } + request, err := http.NewRequestWithContext(d.ctx, method, d.operatorServer.URL+path, reader) + if err != nil { + return 0, err.Error() + } + if reader != nil { + request.Header.Set("Content-Type", "application/json") + } + request.Header.Set("X-Demo-Actor", ownerActorID9B) + response, err := d.operatorServer.Client().Do(request) + if err != nil { + return 0, err.Error() + } + defer func() { _ = response.Body.Close() }() + payload, _ := io.ReadAll(response.Body) + return response.StatusCode, strings.TrimSpace(string(payload)) +} + +// server9B calls a trusted-server route with the Project's secret server key. +func (d *demo) server9B(method, path string, body any) (int, string) { + return d.raw(method, path, encode(body), map[string]string{ + "Authorization": "Bearer " + d.serverKey9B.raw, + }) +} + +// sdkRaw calls an SDK route with only the public SDK key, which is all the +// restore surface takes. +func (d *demo) sdkRaw(method, path string, body any) (int, string) { + return d.raw(method, path, encode(body), map[string]string{ + billingrestorehttp.SDKKeyHeader: d.publicKey9B.raw, + }) +} + +// sdkSync posts the ratified Authoritative Entitlement sync request — the exact +// wire form the Flutter, iOS, and Android SDKs send. +func (d *demo) sdkSync(token string, knownVersion int64, entityTag string, keys []string) (int, string, http.Header) { + payload := map[string]any{ + "supportedAuthoritativeEntitlementContracts": []string{"1"}, + "correlationId": "demo-9b-sync", + } + if knownVersion > 0 { + payload["knownSnapshotVersion"] = knownVersion + } + if entityTag != "" { + payload["entityTag"] = entityTag + } + if len(keys) > 0 { + payload["requestedEntitlementKeys"] = keys + } + return d.rawWithHeaders(http.MethodPost, "/v1/sdk/billing/entitlements", encode(map[string]any{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementSyncRequest", + "payload": payload, + }), map[string]string{ + "Authorization": d.bearer(token), + billingaccesshttp.SDKKeyHeader: d.publicKey9B.raw, + }) +} + +func (d *demo) sdkConditionalGet(token, entityTag string) (int, string, http.Header) { + return d.rawWithHeaders(http.MethodGet, "/v1/sdk/billing/entitlements", "", map[string]string{ + "Authorization": d.bearer(token), + billingaccesshttp.SDKKeyHeader: d.publicKey9B.raw, + "If-None-Match": `"` + entityTag + `"`, + }) +} + +func (d *demo) bearer(token string) string { return "Bearer " + token } + +// rawWithHeaders is d.raw with the response headers preserved. The freshness +// window travels in headers as well as in the record, so a demonstration that +// dropped them would not be showing the whole contract. +func (d *demo) rawWithHeaders(method, path, body string, headers map[string]string) (int, string, http.Header) { + var reader io.Reader + if body != "" { + reader = bytes.NewReader([]byte(body)) + } + request, err := http.NewRequestWithContext(d.ctx, method, d.server.URL+path, reader) + if err != nil { + return 0, err.Error(), http.Header{} + } + if body != "" { + request.Header.Set("Content-Type", "application/json") + } + for name, value := range headers { + request.Header.Set(name, value) + } + response, err := d.server.Client().Do(request) + if err != nil { + return 0, err.Error(), http.Header{} + } + defer func() { _ = response.Body.Close() }() + payload, _ := io.ReadAll(response.Body) + return response.StatusCode, strings.TrimSpace(string(payload)), response.Header +} + +// issueToken mints a Customer Access Token through the real trusted API. +func (d *demo) issueToken(customerID, correlationID string) (string, error) { + status, body := d.server9B(http.MethodPost, "/v1/billing/server/customer-tokens", map[string]any{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenIssuanceRequest", + "payload": map[string]any{ + "billingCustomerId": customerID, + "audience": "sdk_sync", + "scopes": []string{"entitlements.read", "entitlements.sync"}, + "requestedTtlSeconds": 3600, + "correlationId": correlationID, + }, + }) + d.http("POST /v1/billing/server/customer-tokens", status, redactToken(body)) + if status != http.StatusCreated { + return "", fmt.Errorf("token issuance returned %d", status) + } + var issued struct { + Data struct { + Payload struct { + Token string `json:"token"` + } `json:"payload"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(body), &issued); err != nil { + return "", err + } + if issued.Data.Payload.Token == "" { + return "", fmt.Errorf("no token in issuance response") + } + return issued.Data.Payload.Token, nil +} + +// observation9B builds the Billing Ingestion Contract v1 client record an SDK +// sends after a purchase or a restore. +func observation9B(observationID, submissionID, transactionID string) map[string]any { + return map[string]any{ + "billingIngestionContractVersion": "1", + "recordType": "clientTransactionObservation", + "payload": map[string]any{ + "observationId": observationID, + "submissionId": submissionID, + "providerId": "apple_app_store", + "storePlatform": "apple_app_store", + "transactionReference": map[string]string{ + "referenceKind": billing.ReferenceAppStoreTransactionID, "value": transactionID, + }, + "observedAt": time.Now().UTC().Format("2006-01-02T15:04:05Z"), + "sourceAuthority": "client_observation", + "context": map[string]string{ + "platform": "ios", "sdkFamily": "mosaic-ios", "sdkVersion": "1.0.0", + }, + }, + } +} + +// trustedObservation9B builds the server-authenticated form used when an +// application's backend, rather than an SDK installation, reports a purchase. +func trustedObservation9B(observationID, submissionID, transactionID string) map[string]any { + return map[string]any{ + "billingIngestionContractVersion": "1", + "recordType": "serverTransactionObservation", + "payload": map[string]any{ + "observationId": observationID, + "submissionId": submissionID, + "providerId": "apple_app_store", + "storePlatform": "apple_app_store", + "transactionReference": map[string]string{ + "referenceKind": billing.ReferenceAppStoreTransactionID, "value": transactionID, + }, + "sourceAuthority": "trusted_server_observation", + "trustBasis": "application_backend", + "receivedAt": time.Now().UTC().Format("2006-01-02T15:04:05Z"), + }, + } +} + +// --------------------------------------------------------------------------- +// Reads used by more than one demonstration +// --------------------------------------------------------------------------- + +func (d *demo) showSubscriptionState() { + d.query("current Subscription Snapshot", + `SELECT ss.projection_version, ss.access_state, ss.lifecycle_state, ss.renewal_intent, + ss.billing_state, ss.uncertainty_reason, ss.period_start_at, ss.period_end_at, + ss.grace_period_end_at, ss.cancellation_effective_at, ss.expiration_effective_at, + ss.current_product_id + FROM subscription_snapshots ss + JOIN subscription_instances si ON si.current_snapshot_id = ss.id + WHERE ss.project_id=$1 ORDER BY ss.created_at DESC LIMIT 4`, projectID9B) +} + +func (d *demo) showCustomerSnapshot() { + d.query("current Customer Entitlement Snapshot and its entries", + `SELECT s.snapshot_version, s.change_reason, e.entitlement_key, e.state, e.end_known, + e.effective_start, e.effective_end, e.source_count, e.uncertainty_reason, e.explanation_code + FROM customer_entitlement_pointers p + JOIN customer_entitlement_snapshots s ON s.id = p.current_snapshot_id + LEFT JOIN customer_entitlement_snapshot_entries e ON e.customer_entitlement_snapshot_id = s.id + WHERE p.billing_customer_id=$1 AND p.environment_id=$2 + ORDER BY e.entitlement_key`, d.customerA, environmentID9B) +} + +func (d *demo) showSources() { + d.query("every Entitlement Source on the current snapshot, with its explanation", + `SELECT s.source_type, s.source_state, s.explanation_code, s.source_start, s.source_end, + s.end_known, s.is_test_source, + (s.subscription_instance_id IS NOT NULL) AS from_subscription, + (s.one_time_purchase_instance_id IS NOT NULL) AS from_one_time + FROM entitlement_sources s + WHERE s.customer_entitlement_snapshot_id = ( + SELECT current_snapshot_id FROM customer_entitlement_pointers + WHERE billing_customer_id=$1 AND environment_id=$2) + ORDER BY s.source_type, s.source_start, s.id`, d.customerA, environmentID9B) +} + +func (d *demo) showTimeline(originalTransactionID string) { + d.query("Subscription Timeline (append-only)", + `SELECT t.entry_type, t.effective_at, t.explanation_code, t.rule_version + FROM subscription_timeline_entries t + JOIN subscription_instances si ON si.id = t.subscription_instance_id + JOIN purchase_lineages l ON l.id = si.purchase_lineage_id + WHERE l.lineage_key_digest=$1 ORDER BY t.effective_at, t.id`, + billing.AppleTransactionKey("production", originalTransactionID)) +} + +func (d *demo) showDeliveries() { + received := d.destination.deliveries() + if len(received) == 0 { + d.note("no webhook has reached the destination stub yet") + return + } + for _, delivery := range received[max(0, len(received)-4):] { + d.note("destination received event %s (answered %d); signature verified against key %d: %v", + delivery.EventID, delivery.Status, delivery.VerifiedKey, delivery.Verified) + } + d.query("delivery outcomes recorded by Mosaic", + `SELECT dl.status, dl.attempt_count, a.outcome, a.response_status + FROM webhook_deliveries dl + LEFT JOIN webhook_delivery_attempts a ON a.webhook_delivery_id = dl.id + WHERE dl.project_id=$1 ORDER BY dl.created_at DESC, a.attempt_number DESC LIMIT 6`, projectID9B) +} + +// compareRetryDeliveries proves the retry carried the same event id and a +// byte-identical body. +func (d *demo) compareRetryDeliveries() { + received := d.destination.deliveries() + byEvent := map[string][]receivedWebhook{} + for _, delivery := range received { + byEvent[delivery.EventID] = append(byEvent[delivery.EventID], delivery) + } + for eventID, attempts := range byEvent { + if len(attempts) < 2 { + continue + } + identical := true + for index := 1; index < len(attempts); index++ { + if !bytes.Equal(attempts[0].Body, attempts[index].Body) { + identical = false + } + } + d.note("event %s was delivered %d times; body byte-identical across attempts: %v", + eventID, len(attempts), identical) + d.note("first attempt answered %d, last answered %d; signature verified each time: %v/%v", + attempts[0].Status, attempts[len(attempts)-1].Status, + attempts[0].Verified, attempts[len(attempts)-1].Verified) + } +} + +func (d *demo) snapshotVersion(customerID string) int64 { + if customerID == "" { + return 0 + } + var version int64 + _ = d.pool.QueryRow(d.ctx, + `SELECT COALESCE(snapshot_version,0) FROM customer_entitlement_pointers + WHERE billing_customer_id=$1 AND environment_id=$2`, customerID, environmentID9B).Scan(&version) + return version +} + +func (d *demo) currentChecksum(customerID string) string { + var checksum string + _ = d.pool.QueryRow(d.ctx, + `SELECT encode(s.checksum,'hex') FROM customer_entitlement_pointers p + JOIN customer_entitlement_snapshots s ON s.id = p.current_snapshot_id + WHERE p.billing_customer_id=$1 AND p.environment_id=$2`, customerID, environmentID9B).Scan(&checksum) + return checksum +} + +func (d *demo) counts() (snapshots int, events int) { + _ = d.pool.QueryRow(d.ctx, + `SELECT (SELECT count(*) FROM customer_entitlement_snapshots WHERE project_id=$1), + (SELECT count(*) FROM webhook_events WHERE project_id=$1)`, projectID9B).Scan(&snapshots, &events) + return snapshots, events +} + +func (d *demo) subscriptionPeriodEnd(originalTransactionID string) string { + var value *time.Time + _ = d.pool.QueryRow(d.ctx, + `SELECT ss.period_end_at FROM subscription_snapshots ss + JOIN subscription_instances si ON si.current_snapshot_id = ss.id + JOIN purchase_lineages l ON l.id = si.purchase_lineage_id + WHERE l.lineage_key_digest=$1`, + billing.AppleTransactionKey("production", originalTransactionID)).Scan(&value) + if value == nil { + return "(none)" + } + return value.UTC().Format(time.RFC3339) +} + +func (d *demo) lineageFor(originalTransactionID string) (string, error) { + var id string + err := d.pool.QueryRow(d.ctx, + `SELECT id FROM purchase_lineages WHERE project_id=$1 AND lineage_key_digest=$2`, + projectID9B, billing.AppleTransactionKey("production", originalTransactionID)).Scan(&id) + return id, err +} + +func (d *demo) latestRawInput() (string, error) { + var id string + err := d.pool.QueryRow(d.ctx, + `SELECT id FROM billing_raw_inputs WHERE project_id=$1 ORDER BY received_at DESC LIMIT 1`, + projectID9B).Scan(&id) + return id, err +} + +// --------------------------------------------------------------------------- +// Transcript helpers +// --------------------------------------------------------------------------- + +func (d *demo) demonstration(number int, title string) { + if number == 0 { + fmt.Printf("\n\n########## ONE-MINUTE DEMONSTRATION — %s ##########\n", title) + } else { + fmt.Printf("\n\n########## DEMONSTRATION %d — %s ##########\n", number, title) + } + d.stepNumber = 0 + d.demoNumber = number +} + +func truncate(value string, limit int) string { + if len(value) <= limit { + return value + } + return value[:limit] + "… (" + fmt.Sprint(len(value)) + " bytes)" +} + +func orDash(value string) string { + if value == "" { + return "-" + } + return value +} + +// redactToken removes the one Customer Access Token value a response ever +// carries. It is a bearer credential and must not reach an evidence document. +func redactToken(body string) string { + return redactMember(body, `"token":"`) +} + +// redactSecret removes the webhook signing secret from a destination response. +func redactSecret(body string) string { + return redactMember(body, `"secret":"`) +} + +func redactMember(body, marker string) string { + index := strings.Index(body, marker) + if index < 0 { + return body + } + rest := body[index+len(marker):] + end := strings.Index(rest, `"`) + if end < 0 { + return body + } + return body[:index+len(marker)] + "" + body[index+len(marker)+end:] +} + +func max(left, right int) int { + if left > right { + return left + } + return right +} + +// probe runs a read that is expected to reveal something, and prints the error +// rather than aborting when it fails. A demonstration that hides a failing +// query is not a demonstration. +func (d *demo) probe(label, sql string, args ...any) { + fmt.Printf(" PROBE: %s\n", label) + rows, err := d.pool.Query(d.ctx, sql, args...) + if err != nil { + fmt.Printf(" FAILED: %v\n", err) + return + } + defer rows.Close() + for rows.Next() { + values, valueErr := rows.Values() + if valueErr != nil { + fmt.Printf(" FAILED: %v\n", valueErr) + return + } + cells := make([]string, len(values)) + for index, value := range values { + cells[index] = render(value) + } + fmt.Printf(" %s\n", strings.Join(cells, " | ")) + } + if err := rows.Err(); err != nil { + fmt.Printf(" FAILED: %v\n", err) + } +} + +// lastDeliveryID names the most recently succeeded webhook delivery, which is +// what demonstration 13 re-queues through the operator replay surface. +func (d *demo) lastDeliveryID() string { + var id string + _ = d.pool.QueryRow(d.ctx, + `SELECT id FROM webhook_deliveries + WHERE project_id=$1 AND status='succeeded' + ORDER BY updated_at DESC LIMIT 1`, projectID9B).Scan(&id) + return id +} diff --git a/apps/api/cmd/billingdemo/demo9b_seed.go b/apps/api/cmd/billingdemo/demo9b_seed.go new file mode 100644 index 00000000..3ad90d93 --- /dev/null +++ b/apps/api/cmd/billingdemo/demo9b_seed.go @@ -0,0 +1,262 @@ +//go:build billingdemo + +// This file belongs to the build-tagged demonstration driver and is excluded +// from every ordinary build. See demo9b_stubs.go for why that matters. +package main + +import ( + "context" + "crypto/sha256" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// The Phase 9B demonstration owns its own tenant so it can be run on its own, +// repeatedly, without disturbing the Phase 9A tenant or being disturbed by it. +const ( + organizationID9B = "org_demo9b" + projectID9B = "proj_demo9b" + environmentID9B = "env_demo9b" + ownerActorID9B = "actor_demo9b_owner" + + iosApplicationID9B = "app_demo9b_ios" + appleBundleID9B = "com.mosaic.demo9b" + + // Provider products. + appleMonthly9B = "com.mosaic.demo9b.pro.monthly" + appleYearly9B = "com.mosaic.demo9b.pro.yearly" + appleLifetime9B = "com.mosaic.demo9b.pro.lifetime" + + // Mosaic products. + productMonthly9B = "prd_demo9b_monthly" + productYearly9B = "prd_demo9b_yearly" + productLifetime9B = "prd_demo9b_lifetime" + + // The single Entitlement every source in this demonstration grants. One key + // is the point: demonstration 5 needs two independent sources granting the + // same Entitlement. + entitlementID9B = "ent_demo9b_pro" + entitlementKey9B = "pro" + + mappingMonthly9B = "ppm_demo9b_monthly" + mappingYearly9B = "ppm_demo9b_yearly" + mappingLifetime9B = "ppm_demo9b_lifetime" + + applicationUserA9B = "user-alpha@demo.mosaic.local" + applicationUserB9B = "user-bravo@demo.mosaic.local" +) + +// seedTenant9B creates the workspace the Phase 9B demonstration needs. +// +// It writes only tables other phases already own: organizations, membership, +// projects, environments, applications, products, entitlements, provider +// product mappings, and API keys. Nothing under billing_*, purchase_lineages, +// subscription_*, customer_entitlement_*, product_entitlement_grant_versions, +// or webhook_* is written here — every one of those rows is produced during the +// run by an HTTP handler, an application service, or a worker job function. +func seedTenant9B(ctx context.Context, pool *pgxpool.Pool) (publicKey, serverKey apiKey, err error) { + now := time.Now().UTC() + if err = resetTenant9B(ctx, pool); err != nil { + return apiKey{}, apiKey{}, err + } + + publicKey, err = newAPIKey("mos_pk_demo9b") + if err != nil { + return apiKey{}, apiKey{}, err + } + serverKey, err = newAPIKey("mos_sk_demo9b") + if err != nil { + return apiKey{}, apiKey{}, err + } + publicDigest := sha256.Sum256([]byte(publicKey.raw)) + serverDigest := sha256.Sum256([]byte(serverKey.raw)) + + statements := []struct { + query string + args []any + }{ + {`INSERT INTO organizations(id,name,created_at,updated_at) VALUES ($1,'Mosaic Demo 9B',$2,$2) + ON CONFLICT (id) DO NOTHING`, []any{organizationID9B, now}}, + {`INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) + VALUES ($1,$2,'owner',$3,$3) ON CONFLICT (organization_id,actor_id) DO NOTHING`, + []any{organizationID9B, ownerActorID9B, now}}, + {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES ($1,$2,'demo9b','Phase 9B Demo','active',$3,$3) ON CONFLICT (id) DO NOTHING`, + []any{projectID9B, organizationID9B, 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{environmentID9B, projectID9B, now}}, + {`INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES ($1,$2,'Demo 9B iOS','ios',$3,$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{iosApplicationID9B, projectID9B, appleBundleID9B, now}}, + + {`INSERT INTO products(id,project_id,key,internal_name,type,status,metadata_source,readiness_ready,created_at,updated_at) + VALUES ($1,$2,'pro-monthly','Pro Monthly','subscription','connected','mock',true,$3,$3) + ON CONFLICT (id) DO NOTHING`, []any{productMonthly9B, projectID9B, now}}, + {`INSERT INTO products(id,project_id,key,internal_name,type,status,metadata_source,readiness_ready,created_at,updated_at) + VALUES ($1,$2,'pro-yearly','Pro Yearly','subscription','connected','mock',true,$3,$3) + ON CONFLICT (id) DO NOTHING`, []any{productYearly9B, projectID9B, now}}, + {`INSERT INTO products(id,project_id,key,internal_name,type,status,metadata_source,readiness_ready,created_at,updated_at) + VALUES ($1,$2,'pro-lifetime','Pro Lifetime','one_time_non_consumable','connected','mock',true,$3,$3) + ON CONFLICT (id) DO NOTHING`, []any{productLifetime9B, projectID9B, now}}, + + {`INSERT INTO entitlements(id,project_id,key,name,description,created_at,updated_at) + VALUES ($1,$2,$3,'Pro','Everything in Pro',$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{entitlementID9B, projectID9B, entitlementKey9B, now}}, + + {`INSERT INTO provider_product_mappings( + id,project_id,product_id,application_id,provider,provider_product_identifier,status, + environment_id,platform,availability,sync_state,created_at,updated_at) + VALUES ($1,$2,$3,$4,'app_store',$5,'active',$6,'ios','available','current',$7,$7)`, + []any{mappingMonthly9B, projectID9B, productMonthly9B, iosApplicationID9B, appleMonthly9B, environmentID9B, now}}, + {`INSERT INTO provider_product_mappings( + id,project_id,product_id,application_id,provider,provider_product_identifier,status, + environment_id,platform,availability,sync_state,created_at,updated_at) + VALUES ($1,$2,$3,$4,'app_store',$5,'active',$6,'ios','available','current',$7,$7)`, + []any{mappingYearly9B, projectID9B, productYearly9B, iosApplicationID9B, appleYearly9B, environmentID9B, now}}, + {`INSERT INTO provider_product_mappings( + id,project_id,product_id,application_id,provider,provider_product_identifier,status, + environment_id,platform,availability,sync_state,created_at,updated_at) + VALUES ($1,$2,$3,$4,'app_store',$5,'active',$6,'ios','available','current',$7,$7)`, + []any{mappingLifetime9B, projectID9B, productLifetime9B, iosApplicationID9B, appleLifetime9B, environmentID9B, now}}, + + {`INSERT INTO api_keys(id,environment_id,kind,prefix,secret_digest,created_by_actor_id,created_at,application_id,application_project_id) + VALUES ($1,$2,'public_sdk',$3,$4,$5,$6,$7,$8)`, + []any{publicKey.id, environmentID9B, publicKey.prefix, publicDigest[:], ownerActorID9B, now, iosApplicationID9B, projectID9B}}, + {`INSERT INTO api_keys(id,environment_id,kind,prefix,secret_digest,created_by_actor_id,created_at) + VALUES ($1,$2,'secret_server',$3,$4,$5,$6)`, + []any{serverKey.id, environmentID9B, serverKey.prefix, serverDigest[:], ownerActorID9B, now}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + return apiKey{}, apiKey{}, fmt.Errorf("seed 9b: %s: %w", statement.query[:48], err) + } + } + return publicKey, serverKey, nil +} + +// resetTenant9B clears a previous run of this demonstration. +// +// The append-only triggers are disabled for the duration of the delete only. +// The demonstration itself never touches them, so every append-only guarantee +// below is exercised with the triggers in place. +func resetTenant9B(ctx context.Context, pool *pgxpool.Pool) error { + disable := []string{ + `ALTER TABLE billing_ledger_entries DISABLE TRIGGER billing_ledger_entries_append_only`, + `ALTER TABLE billing_transaction_facts DISABLE TRIGGER billing_transaction_facts_append_only`, + `ALTER TABLE billing_product_resolutions DISABLE TRIGGER billing_product_resolutions_append_only`, + `ALTER TABLE billing_validation_attempts DISABLE TRIGGER billing_validation_attempts_append_only`, + `ALTER TABLE billing_quarantine_actions DISABLE TRIGGER billing_quarantine_actions_append_only`, + `ALTER TABLE billing_raw_inputs DISABLE TRIGGER billing_raw_inputs_append_only`, + `ALTER TABLE store_server_credential_events DISABLE TRIGGER store_server_credential_events_no_change`, + `ALTER TABLE billing_association_evidence DISABLE TRIGGER billing_association_evidence_append_only`, + `ALTER TABLE subscription_snapshots DISABLE TRIGGER subscription_snapshots_append_only`, + `ALTER TABLE subscription_snapshot_facts DISABLE TRIGGER subscription_snapshot_facts_append_only`, + `ALTER TABLE subscription_timeline_entries DISABLE TRIGGER subscription_timeline_entries_append_only`, + `ALTER TABLE projection_attempts DISABLE TRIGGER projection_attempts_append_only`, + `ALTER TABLE customer_entitlement_snapshots DISABLE TRIGGER customer_entitlement_snapshots_append_only`, + `ALTER TABLE customer_entitlement_snapshot_entries DISABLE TRIGGER customer_entitlement_snapshot_entries_append_only`, + `ALTER TABLE entitlement_sources DISABLE TRIGGER entitlement_sources_append_only`, + `ALTER TABLE webhook_events DISABLE TRIGGER webhook_events_append_only`, + `ALTER TABLE webhook_delivery_attempts DISABLE TRIGGER webhook_delivery_attempts_append_only`, + `ALTER TABLE product_entitlement_grant_versions DISABLE TRIGGER product_entitlement_grant_versions_append_only`, + `ALTER TABLE restore_sync_job_inputs DISABLE TRIGGER restore_sync_job_inputs_append_only`, + `ALTER TABLE customer_access_tokens DISABLE TRIGGER customer_access_tokens_preserve_revocation`, + } + deletes := []struct { + query string + arg string + }{ + {`DELETE FROM webhook_delivery_attempts WHERE project_id=$1`, projectID9B}, + {`DELETE FROM webhook_event_fanouts WHERE project_id=$1`, projectID9B}, + {`DELETE FROM webhook_deliveries WHERE project_id=$1`, projectID9B}, + {`DELETE FROM webhook_events WHERE project_id=$1`, projectID9B}, + {`DELETE FROM webhook_signing_secrets WHERE project_id=$1`, projectID9B}, + {`DELETE FROM webhook_destinations WHERE project_id=$1`, projectID9B}, + + {`DELETE FROM restore_sync_job_inputs WHERE project_id=$1`, projectID9B}, + {`DELETE FROM restore_sync_jobs WHERE project_id=$1`, projectID9B}, + + {`DELETE FROM customer_entitlement_pointers WHERE project_id=$1`, projectID9B}, + {`DELETE FROM customer_entitlement_snapshot_entries WHERE project_id=$1`, projectID9B}, + {`DELETE FROM entitlement_sources WHERE project_id=$1`, projectID9B}, + {`DELETE FROM customer_entitlement_snapshots WHERE project_id=$1`, projectID9B}, + + {`DELETE FROM projection_attempts WHERE project_id=$1`, projectID9B}, + {`DELETE FROM projection_jobs WHERE project_id=$1`, projectID9B}, + {`DELETE FROM projection_checkpoints WHERE project_id=$1`, projectID9B}, + {`DELETE FROM subscription_timeline_entries WHERE project_id=$1`, projectID9B}, + {`UPDATE subscription_instances SET current_snapshot_id=NULL WHERE project_id=$1`, projectID9B}, + {`DELETE FROM subscription_snapshot_facts WHERE snapshot_id IN (SELECT id FROM subscription_snapshots WHERE project_id=$1)`, projectID9B}, + {`DELETE FROM subscription_snapshots WHERE project_id=$1`, projectID9B}, + {`DELETE FROM subscription_instances WHERE project_id=$1`, projectID9B}, + {`DELETE FROM one_time_purchase_instances WHERE project_id=$1`, projectID9B}, + + {`DELETE FROM customer_access_tokens WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_identity_conflicts WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_association_evidence WHERE project_id=$1`, projectID9B}, + {`UPDATE purchase_lineages SET superseded_by_lineage_id=NULL, billing_customer_id=NULL WHERE project_id=$1`, projectID9B}, + {`DELETE FROM purchase_lineages WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_customer_aliases WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_customers WHERE project_id=$1`, projectID9B}, + + {`DELETE FROM billing_ledger_entries WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_replay_jobs WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_reconciliation_runs WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_quarantine_actions WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_quarantine_records WHERE project_id=$1`, projectID9B}, + {`DELETE FROM purchase_chain_digest_links WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_transaction_facts WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_product_resolutions WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_validation_jobs WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_validation_attempts WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_raw_inputs WHERE project_id=$1`, projectID9B}, + {`DELETE FROM store_server_credential_events WHERE project_id=$1`, projectID9B}, + {`DELETE FROM store_server_credential_applications WHERE project_id=$1`, projectID9B}, + {`DELETE FROM store_server_credentials WHERE project_id=$1`, projectID9B}, + {`DELETE FROM billing_project_settings WHERE project_id=$1`, projectID9B}, + + {`DELETE FROM product_entitlement_grant_versions WHERE project_id=$1`, projectID9B}, + {`DELETE FROM product_entitlement_grants WHERE project_id=$1`, projectID9B}, + {`DELETE FROM provider_product_mappings WHERE project_id=$1`, projectID9B}, + {`DELETE FROM entitlements WHERE project_id=$1`, projectID9B}, + {`DELETE FROM api_keys WHERE environment_id=$1`, environmentID9B}, + {`DELETE FROM audit_events WHERE project_id=$1`, projectID9B}, + } + enable := []string{ + `ALTER TABLE billing_ledger_entries ENABLE TRIGGER billing_ledger_entries_append_only`, + `ALTER TABLE billing_transaction_facts ENABLE TRIGGER billing_transaction_facts_append_only`, + `ALTER TABLE billing_product_resolutions ENABLE TRIGGER billing_product_resolutions_append_only`, + `ALTER TABLE billing_validation_attempts ENABLE TRIGGER billing_validation_attempts_append_only`, + `ALTER TABLE billing_quarantine_actions ENABLE TRIGGER billing_quarantine_actions_append_only`, + `ALTER TABLE billing_raw_inputs ENABLE TRIGGER billing_raw_inputs_append_only`, + `ALTER TABLE store_server_credential_events ENABLE TRIGGER store_server_credential_events_no_change`, + `ALTER TABLE billing_association_evidence ENABLE TRIGGER billing_association_evidence_append_only`, + `ALTER TABLE subscription_snapshots ENABLE TRIGGER subscription_snapshots_append_only`, + `ALTER TABLE subscription_snapshot_facts ENABLE TRIGGER subscription_snapshot_facts_append_only`, + `ALTER TABLE subscription_timeline_entries ENABLE TRIGGER subscription_timeline_entries_append_only`, + `ALTER TABLE projection_attempts ENABLE TRIGGER projection_attempts_append_only`, + `ALTER TABLE customer_entitlement_snapshots ENABLE TRIGGER customer_entitlement_snapshots_append_only`, + `ALTER TABLE customer_entitlement_snapshot_entries ENABLE TRIGGER customer_entitlement_snapshot_entries_append_only`, + `ALTER TABLE entitlement_sources ENABLE TRIGGER entitlement_sources_append_only`, + `ALTER TABLE webhook_events ENABLE TRIGGER webhook_events_append_only`, + `ALTER TABLE webhook_delivery_attempts ENABLE TRIGGER webhook_delivery_attempts_append_only`, + `ALTER TABLE product_entitlement_grant_versions ENABLE TRIGGER product_entitlement_grant_versions_append_only`, + `ALTER TABLE restore_sync_job_inputs ENABLE TRIGGER restore_sync_job_inputs_append_only`, + `ALTER TABLE customer_access_tokens ENABLE TRIGGER customer_access_tokens_preserve_revocation`, + } + for _, statement := range disable { + _, _ = pool.Exec(ctx, statement) + } + for _, statement := range deletes { + if _, err := pool.Exec(ctx, statement.query, statement.arg); err != nil { + return fmt.Errorf("reset 9b: %s: %w", statement.query, err) + } + } + for _, statement := range enable { + _, _ = pool.Exec(ctx, statement) + } + return nil +} diff --git a/apps/api/cmd/billingdemo/demo9b_stubs.go b/apps/api/cmd/billingdemo/demo9b_stubs.go new file mode 100644 index 00000000..6748c0a4 --- /dev/null +++ b/apps/api/cmd/billingdemo/demo9b_stubs.go @@ -0,0 +1,338 @@ +//go:build billingdemo + +//go:debug x509usefallbackroots=1 + +// This file belongs to the build-tagged demonstration driver and is excluded +// from every ordinary build. +// +// The `//go:debug x509usefallbackroots=1` directive above is the reason this +// file must never be reachable from a release build. It makes crypto/x509 use +// the fallback root pool this process installs instead of the platform trust +// store, which is how the demonstration can point Mosaic's real webhook +// delivery policy — HTTPS-only, no InsecureSkipVerify, no TLS seam — at a +// loopback destination stub. In a deployed binary the same directive would mean +// Mosaic trusted whatever roots the process happened to install. +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// --------------------------------------------------------------------------- +// Demonstration trust anchor +// --------------------------------------------------------------------------- + +// demoTLS holds a locally generated CA and a leaf certificate for `localhost`. +// +// Mosaic's webhook policy (internal/billingwebhook/ssrf.go) refuses any +// destination that is not https, builds its own http.Transport, and exposes no +// option to relax certificate verification — correctly, because a webhook +// carries entitlement state. Verification is therefore not bypassed here: a +// real chain is minted and installed as the process fallback root, and the real +// policy performs a real TLS handshake and a real certificate verification +// against it. +type demoTLS struct { + certificate tls.Certificate + pool *x509.CertPool +} + +func newDemoTLS() (demoTLS, error) { + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return demoTLS{}, err + } + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Mosaic Demo Webhook Root CA (SYNTHETIC)"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + return demoTLS{}, err + } + caCertificate, err := x509.ParseCertificate(caDER) + if err != nil { + return demoTLS{}, err + } + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return demoTLS{}, err + } + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caCertificate, &leafKey.PublicKey, caKey) + if err != nil { + return demoTLS{}, err + } + + pool := x509.NewCertPool() + pool.AddCert(caCertificate) + // Installed process-wide, honoured because of the //go:debug directive at the + // top of this file. Every other TLS client in the demonstration (the Google + // OAuth stub) pins its own RootCAs explicitly and is unaffected. + x509.SetFallbackRoots(pool) + + return demoTLS{ + certificate: tls.Certificate{Certificate: [][]byte{leafDER}, PrivateKey: leafKey}, + pool: pool, + }, nil +} + +// --------------------------------------------------------------------------- +// Webhook destination stub +// --------------------------------------------------------------------------- + +// receivedWebhook is one delivery as the destination saw it. +type receivedWebhook struct { + Header string + Body []byte + EventID string + Verified bool + VerifiedKey int + Status int + ReceivedAt time.Time +} + +// destinationStub is the application backend a tenant would run. It verifies +// the Mosaic-Signature header exactly as the published integrator rules require +// (packages/test-fixtures/src/webhook-signature-vectors.json): recompute +// HMAC-SHA256 over "v1.{timestamp}.{eventId}.{rawBody}" using the raw bytes as +// received, accept if ANY v1 parameter verifies. +type destinationStub struct { + mutex sync.Mutex + secrets []string + received []receivedWebhook + failWith int + failCount int + + server *httptest.Server + url string +} + +func newDestinationStub(material demoTLS) *destinationStub { + stub := &destinationStub{} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + header := r.Header.Get("Mosaic-Signature") + + stub.mutex.Lock() + secrets := append([]string(nil), stub.secrets...) + failWith := stub.failWith + if failWith != 0 && stub.failCount > 0 { + stub.failCount-- + if stub.failCount == 0 { + stub.failWith = 0 + } + } + stub.mutex.Unlock() + + eventID := eventIDOf(body) + verified, index := verifySignature(header, eventID, body, secrets) + + status := http.StatusNoContent + if failWith != 0 { + status = failWith + } + stub.mutex.Lock() + stub.received = append(stub.received, receivedWebhook{ + Header: header, Body: body, EventID: eventID, + Verified: verified, VerifiedKey: index, Status: status, ReceivedAt: time.Now().UTC(), + }) + stub.mutex.Unlock() + + w.WriteHeader(status) + }) + server := httptest.NewUnstartedServer(handler) + server.TLS = &tls.Config{Certificates: []tls.Certificate{material.certificate}} + server.StartTLS() + stub.server = server + + _, port, _ := net.SplitHostPort(strings.TrimPrefix(server.URL, "https://")) + // A literal loopback address rather than `localhost`: the policy screens + // every address a name resolves to and pins the first, and `localhost` + // resolves to ::1 as well as 127.0.0.1 on this host while httptest listens + // on IPv4 only. The certificate carries 127.0.0.1 as an IP SAN, so the + // handshake is verified against it rather than skipped. + stub.url = "https://127.0.0.1:" + port + "/mosaic/webhooks" + return stub +} + +func (s *destinationStub) addSecret(secret string) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.secrets = append(s.secrets, secret) +} + +// failNext makes the destination answer `status` for the next `count` +// deliveries and then recover on its own. +func (s *destinationStub) failNext(status, count int) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.failWith, s.failCount = status, count +} + +func (s *destinationStub) deliveries() []receivedWebhook { + s.mutex.Lock() + defer s.mutex.Unlock() + return append([]receivedWebhook(nil), s.received...) +} + +func (s *destinationStub) close() { s.server.Close() } + +// eventIDOf reads the event id out of a delivery body. A real receiver needs it +// for deduplication and for signature verification, and it is inside the signed +// payload precisely so a captured signature cannot be moved onto another event. +func eventIDOf(body []byte) string { + var envelope struct { + Payload struct { + EventID string `json:"eventId"` + } `json:"payload"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return "" + } + return envelope.Payload.EventID +} + +// verifySignature is an independent implementation of the published rules. It +// deliberately does not call billingwebhook.Sign: a verifier that reuses the +// producer's own function proves only that the function agrees with itself. +func verifySignature(header, eventID string, body []byte, secrets []string) (bool, int) { + timestamp := "" + candidates := []string{} + for _, part := range strings.Split(header, ",") { + name, value, found := strings.Cut(strings.TrimSpace(part), "=") + if !found { + continue + } + switch name { + case "t": + timestamp = value + case "v1": + candidates = append(candidates, strings.ToLower(value)) + } + } + if timestamp == "" || len(candidates) == 0 { + return false, -1 + } + seconds, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return false, -1 + } + // The replay window is checked before any signature comparison, exactly as + // the integrator rules require. + if delta := time.Since(time.Unix(seconds, 0)); delta > 300*time.Second || delta < -300*time.Second { + return false, -1 + } + signed := "v1." + timestamp + "." + eventID + "." + string(body) + for index, secret := range secrets { + expected := hmacHex(secret, signed) + for _, candidate := range candidates { + if constantTimeEqual(expected, candidate) { + return true, index + } + } + } + return false, -1 +} + +func hmacHex(secret, message string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(message)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func constantTimeEqual(left, right string) bool { + if len(left) != len(right) { + return false + } + var difference byte + for index := 0; index < len(left); index++ { + difference |= left[index] ^ right[index] + } + return difference == 0 +} + +// --------------------------------------------------------------------------- +// Shared reference vectors +// --------------------------------------------------------------------------- + +// webhookVectorFile is the cross-implementation signature vector set every SDK +// and every integrator is expected to agree with. +const webhookVectorFile = "../../packages/test-fixtures/src/webhook-signature-vectors.json" + +type signatureVector struct { + ID string `json:"id"` + Secret string `json:"secret"` + Timestamp int64 `json:"timestamp"` + EventID string `json:"eventId"` + RawBody string `json:"rawBody"` + Signature string `json:"signature"` + Header string `json:"header"` + Notes string `json:"notes"` +} + +func loadSignatureVectors(path string) ([]signatureVector, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var file struct { + Vectors []signatureVector `json:"vectors"` + } + if err := json.Unmarshal(raw, &file); err != nil { + return nil, err + } + if len(file.Vectors) == 0 { + return nil, fmt.Errorf("no signature vectors in %s", path) + } + return file.Vectors, nil +} + +// digestPreview renders the first bytes of a digest for the transcript. +func digestPreview(value []byte) string { + encoded := hex.EncodeToString(value) + if len(encoded) <= 24 { + return encoded + } + return encoded[:24] + "…" +} + +func sha256Hex(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} diff --git a/apps/api/cmd/billingdemo/demo9b_vectors.go b/apps/api/cmd/billingdemo/demo9b_vectors.go new file mode 100644 index 00000000..5c988511 --- /dev/null +++ b/apps/api/cmd/billingdemo/demo9b_vectors.go @@ -0,0 +1,177 @@ +//go:build billingdemo + +// This file belongs to the build-tagged demonstration driver and is excluded +// from every ordinary build. See demo9b_stubs.go for why that matters. +package main + +import ( + "encoding/json" + "time" +) + +// The Apple material produced here is SYNTHETIC, signed by the per-run chain in +// vectors.go and verified by Mosaic's real verifier against a root it was told +// to trust. Every field name and every unit is Apple's documented one, so +// everything downstream of the signature check — parsing, normalization, fact +// classification, ordering, projection — runs exactly as it would on a real +// payload. + +// transactionVector is the controllable subset of Apple's +// JWSTransactionDecodedPayload. It is a struct rather than a map so a +// demonstration step cannot silently misspell a field Mosaic reads. +type transactionVector struct { + TransactionID string + OriginalTransactionID string + ProductID string + ProductType string + TransactionReason string + PurchaseDate time.Time + ExpiresDate *time.Time + RevocationDate *time.Time + RevocationReason *int + IsUpgraded bool + SignedDate time.Time + OwnershipType string +} + +func (v transactionVector) payload() map[string]any { + productType := v.ProductType + if productType == "" { + productType = "Auto-Renewable Subscription" + } + reason := v.TransactionReason + if reason == "" { + reason = "PURCHASE" + } + ownership := v.OwnershipType + if ownership == "" { + ownership = "PURCHASED" + } + signed := v.SignedDate + if signed.IsZero() { + signed = v.PurchaseDate + } + payload := map[string]any{ + "transactionId": v.TransactionID, + "originalTransactionId": v.OriginalTransactionID, + "webOrderLineItemId": "1000000" + v.TransactionID[len(v.TransactionID)-6:], + "bundleId": appleBundleID9B, + "productId": v.ProductID, + "subscriptionGroupIdentifier": "21456789", + "purchaseDate": v.PurchaseDate.UnixMilli(), + "originalPurchaseDate": v.PurchaseDate.UnixMilli(), + "quantity": 1, + "type": productType, + "transactionReason": reason, + "inAppOwnershipType": ownership, + "signedDate": signed.UnixMilli(), + "environment": "Production", + "storefront": "USA", + } + if v.ExpiresDate != nil { + payload["expiresDate"] = v.ExpiresDate.UnixMilli() + } + if v.RevocationDate != nil { + payload["revocationDate"] = v.RevocationDate.UnixMilli() + } + if v.RevocationReason != nil { + payload["revocationReason"] = *v.RevocationReason + } + if v.IsUpgraded { + payload["isUpgraded"] = true + } + return payload +} + +// renewalVector is the controllable subset of Apple's +// JWSRenewalInfoDecodedPayload. It carries the three fields Phase 9B's +// fact-shape pass added: auto-renew intent, billing-retry state, and the grace +// period's end. +type renewalVector struct { + OriginalTransactionID string + AutoRenewStatus int + AutoRenewProductID string + ProductID string + IsInBillingRetry bool + GracePeriodExpiresAt *time.Time + SignedAt time.Time +} + +func (v renewalVector) payload() map[string]any { + payload := map[string]any{ + "originalTransactionId": v.OriginalTransactionID, + "autoRenewStatus": v.AutoRenewStatus, + "autoRenewProductId": v.AutoRenewProductID, + "productId": v.ProductID, + "signedDate": v.SignedAt.UnixMilli(), + "environment": "Production", + } + if v.IsInBillingRetry { + payload["isInBillingRetryPeriod"] = true + } + if v.GracePeriodExpiresAt != nil { + payload["gracePeriodExpiresDate"] = v.GracePeriodExpiresAt.UnixMilli() + } + return payload +} + +// appleEvent is one complete App Store Server Notification V2 plus the signed +// transaction the App Store Server API will return when Mosaic re-reads it. +// +// Mosaic never trusts the transaction embedded in a notification: validation +// re-queries the store. Both are produced here so the two agree, exactly as +// they would in production. +type appleEvent struct { + NotificationUUID string + NotificationType string + Subtype string + Transaction transactionVector + Renewal *renewalVector + // SignedAt is the notification's own signedDate. It is the provider event + // time Mosaic recovers for ordering. + SignedAt time.Time +} + +// build returns the notification body to POST to the intake endpoint and the +// signed transaction to install on the App Store Server API stub. +func (c demoChain) buildAppleEvent(event appleEvent) (body string, signedTransaction string, err error) { + signedTransaction, err = c.signJWS(event.Transaction.payload()) + if err != nil { + return "", "", err + } + data := map[string]any{ + "appAppleId": 1234567890, + "bundleId": appleBundleID9B, + "bundleVersion": "1", + "environment": "Production", + "signedTransactionInfo": signedTransaction, + "status": 1, + } + if event.Renewal != nil { + signedRenewal, renewalErr := c.signJWS(event.Renewal.payload()) + if renewalErr != nil { + return "", "", renewalErr + } + data["signedRenewalInfo"] = signedRenewal + } + envelope := map[string]any{ + "notificationType": event.NotificationType, + "notificationUUID": event.NotificationUUID, + "version": "2.0", + "signedDate": event.SignedAt.UnixMilli(), + "data": data, + } + if event.Subtype != "" { + envelope["subtype"] = event.Subtype + } + signedPayload, err := c.signJWS(envelope) + if err != nil { + return "", "", err + } + encoded, err := json.Marshal(map[string]string{"signedPayload": signedPayload}) + return string(encoded), signedTransaction, err +} + +func timePointer(value time.Time) *time.Time { return &value } + +func intPointer(value int) *int { return &value } diff --git a/apps/api/cmd/billingdemo/main.go b/apps/api/cmd/billingdemo/main.go index 526f70fd..77322acf 100644 --- a/apps/api/cmd/billingdemo/main.go +++ b/apps/api/cmd/billingdemo/main.go @@ -44,6 +44,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "flag" "fmt" "io" "net/http" @@ -56,10 +57,28 @@ import ( "github.com/rs/zerolog" "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/billingdiagnostics" + "github.com/Mujhtech/mosaic/apps/api/internal/billinggrant" + "github.com/Mujhtech/mosaic/apps/api/internal/billingoperator" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" + "github.com/Mujhtech/mosaic/apps/api/internal/billingrestore" + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstorejws" "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstoreserver" "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingaccesspostgres" + "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/billinggrantpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingkeys" + "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" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingrestorepostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingseam" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingwebhookpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver" "github.com/Mujhtech/mosaic/apps/api/internal/platform/ratelimit" @@ -78,6 +97,10 @@ type demo struct { pool *pgxpool.Pool service *billing.Service server *httptest.Server + // operatorServer carries the Phase 9B operator surface. Since defect D-3 + // was fixed it is the same server as `server`; the field is kept so the + // operator HTTP helpers keep naming the surface they exercise. + operatorServer *httptest.Server apple *appleStub play *playStub @@ -94,14 +117,54 @@ type demo struct { stepNumber int started time.Time + + // Phase 9B services. They are the same constructions cmd/api and cmd/worker + // perform; only the two substitutions named in wire() differ. + projection *billingprojection.Service + identity *billingcustomer.Service + access *billingaccess.Service + grants *billinggrant.Service + webhooks *billingwebhook.Service + restores *billingrestore.Service + diagnostics *billingdiagnostics.Service + operator *billingoperator.Service + + tlsMaterial demoTLS + destination *destinationStub + + // Phase 9B tenant credentials and run state. + publicKey9B apiKey + serverKey9B apiKey + intakePath9B string + credentialID9B string + customerA string + customerB string + tokenA string + tokenB string + // bindToken is the Customer Access Token the simulated SDK presents when it + // reports a purchase. It is what attaches a lineage to an identified + // customer through production wiring; the first Stage 4 run had to stand in + // for this with a bridge() substitution (defect D-1). + bindToken string + // boundLineages remembers which purchase chains the SDK has already + // reported, so a renewal does not re-report a purchase a real SDK observed + // once. + boundLineages map[string]bool + destinationID string + demoNumber int + oneMinute bool + scenarioBaseline time.Time } func run() error { + phase := flag.String("phase", "9b", "which demonstration to run: 9a, 9b, all, or oneminute") + flag.Parse() + databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) if databaseURL == "" { return errors.New("DATABASE_URL is required") } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() pool, err := pgxpool.New(ctx, databaseURL) @@ -110,13 +173,38 @@ func run() error { } defer pool.Close() - d := &demo{ctx: ctx, pool: pool, started: time.Now()} + d := &demo{ctx: ctx, pool: pool, started: time.Now(), boundLineages: map[string]bool{}} if err := d.wire(); err != nil { return err } defer d.server.Close() + defer d.destination.close() + + stages := []func() error(nil) + switch *phase { + case "9a": + stages = d.stages9A() + case "9b": + stages = d.stages9B() + case "all": + stages = append(d.stages9A(), d.stages9B()...) + case "oneminute": + d.oneMinute = true + stages = []func() error{d.stageOneMinute} + default: + return fmt.Errorf("unknown -phase %q", *phase) + } + for _, stage := range stages { + if err := stage(); err != nil { + return err + } + } + fmt.Printf("\n=== demonstration complete in %s ===\n", time.Since(d.started).Round(time.Millisecond)) + return nil +} - for _, stage := range []func() error{ +func (d *demo) stages9A() []func() error { + return []func() error{ d.stageSetup, d.stageApple, d.stageGoogle, @@ -125,13 +213,7 @@ func run() error { d.stageReconciliation, d.stageReplay, d.stageNoAccessState, - } { - if err := stage(); err != nil { - return err - } } - fmt.Printf("\n=== demonstration complete in %s ===\n", time.Since(d.started).Round(time.Millisecond)) - return nil } // wire builds the real composition root with three deliberate substitutions, @@ -187,9 +269,42 @@ func (d *demo) wire() error { return err } + // Phase 9B composition, identical to cmd/api's except that the webhook + // policy is constructed with the self-hosted allowlist so a loopback + // destination is permitted. HTTPS, certificate verification, redirect + // refusal, resolve-and-pin, and the reserved-address screen are all + // unchanged. + if d.tlsMaterial, err = newDemoTLS(); err != nil { + return err + } + d.destination = newDestinationStub(d.tlsMaterial) + + projectionRepository := billingprojectionpostgres.New(d.pool) + d.projection = billingprojection.NewService(projectionRepository) + keys := billingkeys.New(billingpostgres.New(d.pool)) + d.identity = billingcustomer.NewService(billingcustomerpostgres.New(d.pool), keys.Identity(), d.projection) + d.access = billingaccess.NewService( + billingaccesspostgres.New(d.pool), + billingaccesspostgres.NewKeyAuthenticator(billingpostgres.New(d.pool)), + billingaccess.WithIssuer("mosaic-billing-demo")) + d.grants = billinggrant.NewService(billinggrantpostgres.New(d.pool)) + d.webhooks = billingwebhook.NewService(billingwebhookpostgres.New(d.pool), cipher, + billingwebhook.NewPolicy(billingwebhook.WithSelfHostedAllowlist(true))) + d.restores = billingrestore.NewService(billingrestorepostgres.New(d.pool), keys.Restore()) + d.diagnostics = billingdiagnostics.NewService(billingdiagnosticspostgres.New(d.pool), + billingdiagnostics.WithReplay(d.projection, projectionRepository)) + d.operator = billingoperator.NewService(billingoperatorpostgres.New(d.pool), + billingaccesspostgres.New(d.pool), d.identity) + + // The ingestion service is constructed last because the Phase 9A→9B seam + // makes it depend on the identity and access services, exactly as cmd/api + // and cmd/worker now wire it. This is what the driver's bridge() + // substitution used to stand in for (defect D-1). + seam := billingseam.New(d.identity, d.access) d.service = billing.NewService(billingpostgres.New(d.pool), cipher, verifier, billing.WithProviders(appleClient, googleClient), - billing.WithNotificationBaseURL(demoNotificationOrigin)) + billing.WithNotificationBaseURL(demoNotificationOrigin), + billing.WithSeam(seam, seam)) logger := zerolog.New(io.Discard) // SUBSTITUTION 3: the dashboard principal resolver returns a fixed actor @@ -209,12 +324,26 @@ func (d *demo) wire() error { }, logger, httpserver.Dependencies{ PrincipalResolver: resolver, Billing: d.service, - BillingIPLimiter: ratelimit.New(600, 600, 1024), - BillingKeyLimiter: ratelimit.New(600, 600, 1024), - APILimiter: ratelimit.New(600, 600, 1024), - ExportLimiter: ratelimit.New(600, 600, 1024), + BillingAccess: d.access, + BillingCustomer: d.identity, + BillingGrant: d.grants, + // Defect D-3 is fixed: BillingOperator now mounts beside Billing in the + // standard composition, exactly as cmd/api wires it. The demo's second + // operator mux is gone with it. + BillingOperator: d.operator, + BillingRestore: d.restores, + BillingWebhook: d.webhooks, + BillingDiagnostics: d.diagnostics, + BillingIPLimiter: ratelimit.New(6000, 6000, 4096), + BillingKeyLimiter: ratelimit.New(6000, 6000, 4096), + EntitlementSyncLimiter: ratelimit.New(6000, 6000, 4096), + APILimiter: ratelimit.New(6000, 6000, 4096), + ExportLimiter: ratelimit.New(6000, 6000, 4096), }) d.server = httptest.NewServer(handler) + // The operator surface is served by the same router as everything else. + // The field is kept so the operator HTTP helpers need no change. + d.operatorServer = d.server return nil } diff --git a/apps/api/cmd/billingdemo/vectors.go b/apps/api/cmd/billingdemo/vectors.go index 33f7791b..aa9dbcf6 100644 --- a/apps/api/cmd/billingdemo/vectors.go +++ b/apps/api/cmd/billingdemo/vectors.go @@ -41,6 +41,16 @@ import ( // requires it on the intermediate, so the synthetic chain carries it. var appleWWDROID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 2, 1} +// demoChainBackdate is how far back the synthetic chain is valid. +// +// appstorejws verifies the certificate chain *as of the payload's signedDate*, +// which is correct: a signature is only meaningful against a certificate that +// was valid when it was made. The Phase 9B demonstrations replay a subscription +// history whose provider events are months old, so the chain that signs them +// has to have been valid then. A real Apple chain is; this one is minted per +// run, so its validity window is widened to match the histories it signs. +const demoChainBackdate = 3 * 365 * 24 * time.Hour + type demoChain struct { root *x509.Certificate leafKey *ecdsa.PrivateKey @@ -67,7 +77,7 @@ func newDemoChain() (demoChain, error) { rootTemplate := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Mosaic Demo Root CA (SYNTHETIC — not Apple)"}, - NotBefore: time.Now().Add(-24 * time.Hour), + NotBefore: time.Now().Add(-demoChainBackdate), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, @@ -85,7 +95,7 @@ func newDemoChain() (demoChain, error) { intermediateTemplate := &x509.Certificate{ SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "Mosaic Demo Intermediate CA (SYNTHETIC)"}, - NotBefore: time.Now().Add(-24 * time.Hour), + NotBefore: time.Now().Add(-demoChainBackdate), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, @@ -104,7 +114,7 @@ func newDemoChain() (demoChain, error) { leafTemplate := &x509.Certificate{ SerialNumber: big.NewInt(3), Subject: pkix.Name{CommonName: "Mosaic Demo Leaf (SYNTHETIC)"}, - NotBefore: time.Now().Add(-24 * time.Hour), + NotBefore: time.Now().Add(-demoChainBackdate), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, } diff --git a/apps/api/cmd/worker/main.go b/apps/api/cmd/worker/main.go index 9d50d54b..5e433e23 100644 --- a/apps/api/cmd/worker/main.go +++ b/apps/api/cmd/worker/main.go @@ -22,12 +22,25 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/analytics" "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/billingprojection" + "github.com/Mujhtech/mosaic/apps/api/internal/billingrestore" + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" "github.com/Mujhtech/mosaic/apps/api/internal/experiment" "github.com/Mujhtech/mosaic/apps/api/internal/platform/analyticspostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstorejws" "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstoreserver" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingaccesspostgres" + "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/billingpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingprojectionpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingrestorepostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingseam" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingwebhookpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/buildinfo" "github.com/Mujhtech/mosaic/apps/api/internal/platform/cloudworkspacepostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/config" @@ -145,6 +158,11 @@ func run() (runErr error) { var billingService *billing.Service var billingRepository *billingpostgres.Repository + var projectionService *billingprojection.Service + var restoreService *billingrestore.Service + var restoreRepository *billingrestorepostgres.Repository + var webhookService *billingwebhook.Service + var webhookRepository *billingwebhookpostgres.Repository if cfg.Billing.Enabled { billingCipher, err := providercredential.NewAESGCMCipher(cfg.Providers.CredentialKeyring, rand.Reader) if err != nil { @@ -175,9 +193,30 @@ func run() (runErr error) { return fmt.Errorf("configure Google Play client: %w", err) } billingRepository = billingpostgres.New(pool) + projectionService = billingprojection.NewService(billingprojectionpostgres.New(pool)) + // 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 + // constructed here for that reason alone — the worker serves no HTTP and + // exposes neither. + billingKeys := billingkeys.New(billingRepository) + customerService := billingcustomer.NewService( + billingcustomerpostgres.New(pool), billingKeys.Identity(), projectionService) + accessService := billingaccess.NewService( + billingaccesspostgres.New(pool), + billingaccesspostgres.NewKeyAuthenticator(billingRepository)) + seam := billingseam.New(customerService, accessService) billingService = billing.NewService(billingRepository, billingCipher, verifier, billing.WithProviders(appleClient, googleClient), - billing.WithRetention(cfg.Billing.RawRetention())) + billing.WithRetention(cfg.Billing.RawRetention()), + billing.WithSeam(seam, seam)) + restoreRepository = billingrestorepostgres.New(pool) + restoreService = billingrestore.NewService(restoreRepository, + billingkeys.New(billingRepository).Restore()) + webhookRepository = billingwebhookpostgres.New(pool) + webhookService = billingwebhook.NewService(webhookRepository, billingCipher, + billingwebhook.NewPolicy(billingwebhook.WithSelfHostedAllowlist( + cfg.Billing.WebhookAllowPrivateDestinations))) } workerID, err := os.Hostname() @@ -212,6 +251,18 @@ func run() (runErr error) { if err := billingRepository.RegisterQueueMetrics(); err != nil { return fmt.Errorf("register billing queue metrics: %w", err) } + // Per-table row counts for the Phase 9B schema. Plan §15 decided + // snapshot retention with no drill baseline to extrapolate from, so the + // trend has to start being recorded before it is needed. + if err := billingdiagnosticspostgres.New(pool).RegisterRowCountMetrics(); err != nil { + return fmt.Errorf("register billing table row metrics: %w", err) + } + if err := restoreRepository.RegisterQueueMetrics(); err != nil { + return fmt.Errorf("register billing restore queue metrics: %w", err) + } + if err := webhookRepository.RegisterQueueMetrics(); err != nil { + return fmt.Errorf("register billing webhook queue metrics: %w", err) + } } families := make([]jobFamily, 0, 8) @@ -221,8 +272,23 @@ func run() (runErr error) { if billingService != nil { // Validation runs first in the round-robin because a store notification // waiting on validation is the latency an operator actually sees. + // Projection runs immediately after it: a validated fact that has not + // been projected has not yet changed anyone's access, so the two + // latencies are one user-visible number. families = append(families, jobFamily{"billing_validation", billingService.ProcessNextValidation}, + 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 + // later in the round robin and every restore would observe the + // previous poll's state and reschedule itself once more than it + // needed to. + jobFamily{"billing_restore_sync", restoreService.ProcessNextRestoreSync}, + // Delivery runs strictly outside the projection transaction. A + // destination that is down produces retries and eventually an + // exhausted delivery; it never rolls back an entitlement change and + // never blocks a projection. + jobFamily{"billing_webhook_delivery", webhookService.ProcessNextDelivery}, jobFamily{"billing_rtdn", billingService.ProcessNextRTDN}, jobFamily{"billing_reconciliation", billingService.ProcessNextReconciliation}, jobFamily{"billing_replay", billingService.ProcessNextReplay}, diff --git a/apps/api/internal/billing/digest.go b/apps/api/internal/billing/digest.go index 937c25aa..8e8021e8 100644 --- a/apps/api/internal/billing/digest.go +++ b/apps/api/internal/billing/digest.go @@ -80,6 +80,50 @@ func TokenDigest(token string) []byte { return sum[:] } +// Alias types for correlator digests. They are duplicated from the billing +// identity module's vocabulary because the values are a persisted digest domain +// rather than a Go constant: changing either copy without the other silently +// stops two records of the same person from matching. +const ( + AliasAppleAppAccountToken = "apple_app_account_token" + AliasGoogleObfuscatedID = "google_obfuscated_account_id" +) + +// Association evidence types this module produces. They are the same duplicated +// vocabulary as the alias types above and exist for the same reason: the value +// is persisted, so it is not free to differ between the two modules. +const ( + EvidenceAppAccountToken = "app_account_token" + EvidenceObfuscatedAccount = "obfuscated_external_account_id" +) + +// AliasDigest is the one-way representation of a customer correlator, and the +// only form of one this package ever produces. +// +// It lives here rather than only in the identity module because the validator is +// where a provider correlator is first seen — Apple's `appAccountToken` on the +// verified transaction, Google's `obfuscatedExternalAccountId` on the +// authoritative purchase — and the raw value must not travel any further than +// the function that hashes it. Nothing downstream of validation receives one: +// not a Transaction Fact, not a log line, not a metric attribute, not an audit +// event. Phase 9A's fact-shape exclusion is unchanged; the digest's home is the +// 9B association-evidence table. +// +// The domain separation matters more than usual. An application user id and an +// Apple app-account token are both opaque strings chosen by someone else; +// without the domain prefix and the alias type, a value that happened to be +// identical across two alias types would collapse into one active resolution and +// silently join two people. +func AliasDigest(aliasType, value string) []byte { + hasher := sha256.New() + hasher.Write([]byte("mosaic-billing-alias-v1")) + hasher.Write([]byte{0}) + hasher.Write([]byte(aliasType)) + hasher.Write([]byte{0}) + hasher.Write([]byte(value)) + return hasher.Sum(nil) +} + // ContentDigest canonicalizes a JSON body before hashing so that two deliveries // differing only in key order or whitespace compare equal. A body that is not // JSON is hashed as received. @@ -103,6 +147,14 @@ func ContentDigest(body []byte) []byte { // the same mapping history recomputes the same digest and the unique constraint // absorbs the write, while a genuinely different outcome produces a different // digest and is recorded as a new fact rather than overwriting the old one. +// +// Digest v2 (validator version 2) appends the fact-shape fields of Phase 9B +// §8: grace end, billing retry, scheduled renewal product, upgrade marker, +// revocation reason, refund type, ownership type, subscription group, and the +// recovered provider event time. Every one is a provider statement, so a +// change in any of them is a genuinely different fact. Facts recorded under +// validator 1 keep their v1 digests; the validator version inside the digest +// separates the two populations structurally. func FactDigest(fact TransactionFact) []byte { fields := []string{ fact.EnvironmentID, @@ -131,10 +183,29 @@ func FactDigest(fact TransactionFact) []byte { int64Field(fact.ResolvedMappingVersion), strconv.Itoa(fact.ValidatorVersion), strconv.Itoa(fact.FactVersion), + // v2 fact-shape fields. Any revalidation now runs under validator 2, so + // its digest differs from the stored v1 digest by the version field + // alone; the appended fields never collide with the v1 population. + timeField(fact.GracePeriodExpiresAt), + boolField(fact.BillingRetryActive), + fact.AutoRenewProductIdentifier, + boolField(fact.IsUpgraded), + intField(fact.RevocationReason), + fact.RefundType, + fact.InAppOwnershipType, + fact.SubscriptionGroupIdentifier, + timeField(fact.ProviderEventOccurredAt), } return digestOf("mosaic-billing-fact-v1", fields...) } +func intField(value *int) string { + if value == nil { + return "" + } + return strconv.Itoa(*value) +} + func timeField(value *time.Time) string { if value == nil || value.IsZero() { return "" diff --git a/apps/api/internal/billing/model.go b/apps/api/internal/billing/model.go index d7eb0f5e..3b400b05 100644 --- a/apps/api/internal/billing/model.go +++ b/apps/api/internal/billing/model.go @@ -7,7 +7,22 @@ import "time" // validator 2") and so a fact records which normalization produced it. It is // incremented whenever normalization changes in a way that could produce a // different fact from the same input. -const ValidatorVersion = 1 +// +// Version 2 is the Phase 9B fact-shape pass: provider fields that version 1 +// parsed but never persisted (grace end, billing retry, scheduled renewal +// product, upgrade marker, revocation reason and refund type, ownership type, +// subscription group) plus a recovered provider event time for Google facts. +// All of them participate in FactDigest v2. +const ValidatorVersion = 2 + +// Refund types recorded on a refund fact. Apple does not state refund scope in +// the transaction payload, so Apple facts leave the field empty; Google voided +// purchases state it explicitly. +const ( + RefundTypeFull = "full" + RefundTypeQuantityPartial = "quantity_partial" + RefundTypeProrated = "prorated" +) // Providers. const ( @@ -191,6 +206,19 @@ const ( QuarantineReplayConflict = "replay_conflict" QuarantineProviderPermanentlyFailed = "provider_permanently_failed" QuarantineValidationExhausted = "validation_exhausted" + // QuarantineMissingProviderTimestamp marks an input whose provider payload + // carried no usable event or transaction timestamp. Recording a fact dated + // with worker wall-clock is forbidden: occurred_at participates in + // FactDigest, so a wall-clock value defeats replay idempotency (9A B7). + QuarantineMissingProviderTimestamp = "missing_provider_timestamp" + // QuarantineVoidProductUnresolved marks a Google voided purchase whose + // Product could not be attributed — a multi-line-item order, or an + // orders.get that failed permanently. It is deliberately distinct from + // `malformed_reference` (review finding I-4): the refund *was* recorded, so + // the operator action is to attribute the Product and re-resolve, not to + // investigate a broken input. Access for the purchase reads `unknown` + // meanwhile, never `owned`. + QuarantineVoidProductUnresolved = "void_product_unresolved" ) // Quarantine statuses. There is no status meaning "operator declared this @@ -231,6 +259,8 @@ const ( ClassAppleInAppPurchaseKey = "appleInAppPurchaseKey" ClassGoogleServiceAccountKey = "googleServiceAccountKey" ClassBillingRawPayload = "billingRawPayload" + // ClassWebhookSigningSecret is the Phase 9B addition (ADR-0024). + ClassWebhookSigningSecret = "webhookSigningSecret" ) // Actor is the authenticated dashboard principal. @@ -367,20 +397,36 @@ type TransactionFact struct { RevokedAt *time.Time `json:"revokedAt,omitempty"` RefundedAt *time.Time `json:"refundedAt,omitempty"` RenewalExpected *bool `json:"renewalExpected,omitempty"` - IsTestTransaction bool `json:"isTestTransaction"` - ProviderProductIdentifier string `json:"providerProductIdentifier"` - ProviderBasePlanIdentifier string `json:"providerBasePlanIdentifier,omitempty"` - ProviderOfferIdentifier string `json:"providerOfferIdentifier,omitempty"` - ResolutionState string `json:"resolutionState"` - MosaicProductID string `json:"mosaicProductId,omitempty"` - ProviderProductMappingID string `json:"providerProductMappingId,omitempty"` - ResolvedMappingVersion *int64 `json:"resolvedMappingVersion,omitempty"` - ValidatorVersion int `json:"validatorVersion"` - FactVersion int `json:"factVersion"` - SourceRawInputID string `json:"sourceRawInputId"` - ValidationAttemptID string `json:"validationAttemptId"` - FactDigest []byte `json:"-"` - RecordedAt time.Time `json:"recordedAt"` + // Fact-shape v2 fields (Phase 9B §8). All are provider statements, never + // interpretations, and all participate in FactDigest. + GracePeriodExpiresAt *time.Time `json:"gracePeriodExpiresAt,omitempty"` + BillingRetryActive *bool `json:"billingRetryActive,omitempty"` + AutoRenewProductIdentifier string `json:"autoRenewProductIdentifier,omitempty"` + IsUpgraded *bool `json:"isUpgraded,omitempty"` + RevocationReason *int `json:"revocationReason,omitempty"` + RefundType string `json:"refundType,omitempty"` + InAppOwnershipType string `json:"inAppOwnershipType,omitempty"` + SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier,omitempty"` + // ProviderEventOccurredAt is the provider-stated event time of the input + // that produced this fact. It exists because every fact in a Google + // lineage shares occurred_at = startTime, which would make ordering tie on + // a constant; the event time recovered from the RTDN (or the raw input's + // provider_occurred_at) breaks that tie with a provider statement. + ProviderEventOccurredAt *time.Time `json:"providerEventOccurredAt,omitempty"` + IsTestTransaction bool `json:"isTestTransaction"` + ProviderProductIdentifier string `json:"providerProductIdentifier"` + ProviderBasePlanIdentifier string `json:"providerBasePlanIdentifier,omitempty"` + ProviderOfferIdentifier string `json:"providerOfferIdentifier,omitempty"` + ResolutionState string `json:"resolutionState"` + MosaicProductID string `json:"mosaicProductId,omitempty"` + ProviderProductMappingID string `json:"providerProductMappingId,omitempty"` + ResolvedMappingVersion *int64 `json:"resolvedMappingVersion,omitempty"` + ValidatorVersion int `json:"validatorVersion"` + FactVersion int `json:"factVersion"` + SourceRawInputID string `json:"sourceRawInputId"` + ValidationAttemptID string `json:"validationAttemptId"` + FactDigest []byte `json:"-"` + RecordedAt time.Time `json:"recordedAt"` } // QuarantineRecord is one input that cannot safely proceed. diff --git a/apps/api/internal/billing/repository.go b/apps/api/internal/billing/repository.go index 2bf8c11b..ae9e5612 100644 --- a/apps/api/internal/billing/repository.go +++ b/apps/api/internal/billing/repository.go @@ -96,11 +96,44 @@ type AttemptOutcome struct { Attempt ValidationAttempt Resolution *ResolutionRecord Fact *TransactionFact - Ledger []LedgerEntry - Quarantine *QuarantineWrite + // Supersession is the once-per-lineage purchase_superseded fact recorded + // when a Google linkedPurchaseToken is observed. It is built only from + // lineage-constant fields, so its digest is stable across re-observations + // and the fact-identity constraint absorbs every write after the first. + Supersession *TransactionFact + Ledger []LedgerEntry + Quarantine *QuarantineWrite // NextAvailableAt schedules a retry; zero completes or fails the job. NextAvailableAt time.Time JobStatus string + // Correlators are the customer correlators the provider's authoritative + // response carried, already hashed. They are on the outcome rather than on + // the fact because Phase 9A's fact-shape exclusion stands: no correlator, + // raw or digested, is a Transaction Fact column. Their home is the 9B + // association-evidence table, and this is how they get there. + Correlators []AssociationCorrelator + // ReferenceDigests are every transaction-reference digest under which a + // submitted observation could have recorded submission-context evidence for + // this transaction. There is more than one because a client observation + // cannot state a Store Environment — a device can be made to say anything — + // so it is recorded under `unclassified` while the notification for the same + // purchase is recorded under the environment the store confirmed. + ReferenceDigests [][]byte +} + +// AssociationCorrelator is one hashed customer correlator observed on a +// provider's authoritative response. +// +// It never carries the raw value. The value is hashed inside the validator, at +// the point it is parsed, so that nothing downstream — the repository, the +// binder, a log line, a span attribute — is ever in a position to leak one. +type AssociationCorrelator struct { + // EvidenceType is the association-evidence vocabulary entry. + EvidenceType string + // AliasType is the alias domain the digest was taken under, which is what + // lets it be matched against an alias a backend already attached. + AliasType string + Digest []byte } // ResolutionRecord is the persisted Resolution Snapshot. @@ -211,6 +244,11 @@ 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) // 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/seam.go b/apps/api/internal/billing/seam.go new file mode 100644 index 00000000..428000b0 --- /dev/null +++ b/apps/api/internal/billing/seam.go @@ -0,0 +1,120 @@ +package billing + +import ( + "context" + "time" +) + +// This file is the Phase 9A → Phase 9B seam, expressed as two ports. +// +// Phase 9A ends with a validated Transaction Fact in an append-only ledger. +// Phase 9B begins with a Purchase Lineage that a Billing Customer owns. Nothing +// used to join those two halves: `LocateLineage`, `ResolveLineageCustomer`, +// `RecordSupersession`, and `EvidenceForReference` all existed and all had zero +// production callers, so in a deployed system a purchase produced no lineage, no +// instance, no association, no projection, and no answer on any entitlement +// surface. That was defect D-1. +// +// The seam is two steps, in two different places, for a reason: +// +// - The *structural* half — the lineage row and the projection instance it +// owns — is written inside the same transaction that records the fact, in +// the repository. It is a deterministic function of the fact's own chain +// digest, it decides nothing, and it must be exactly as durable as the fact, +// because the projection trigger is written in that transaction too. +// +// - The *identity* half — which customer owns the lineage — is a decision, so +// it belongs to the identity application service and runs after the commit +// through the port below. It is idempotent: locating the lineage re-reads +// the row the transaction created, and an association that already names the +// same customer is a no-op. +// +// The ports live here rather than in the identity module because the direction +// of dependency has to be this way round: the identity module already imports +// this one, so this one may not import it back. + +// LineageBinder decides which Billing Customer owns the Purchase Lineage a +// newly committed fact belongs to. +// +// Implemented by an adapter over the billing identity application service. When +// no binder is wired the validator records facts exactly as Phase 9A did and +// says so once at startup, so a deployment that has not finished wiring 9B +// degrades to 9A rather than failing. +type LineageBinder interface { + BindFact(ctx context.Context, binding FactBinding) error +} + +// FactBinding is everything the identity module needs to attach one lineage, +// and nothing more. In particular it carries no raw correlator and no provider +// token: the digests were taken in the validator. +type FactBinding struct { + ProjectID string + EnvironmentID string + Provider string + // FactChainDigest is the fact's own chain digest. It differs from the + // lineage key when the provider has handed the purchase chain a new token, + // and the difference is what a lineage-level supersession edge is derived + // from. + FactChainDigest []byte + // LineageKeyDigest is the *root* of the provider purchase chain, already + // resolved by walking supersession edges backwards. It is the fact's own + // digest domain, which is the one every fact-to-lineage join compares. + LineageKeyDigest []byte + // RawInputID is the input whose validation produced the fact. It is recorded + // on the evidence so a decision can be traced back to what triggered it. + RawInputID string + // ReferenceDigests are every transaction-reference digest that could name + // this transaction, used to find submission-context evidence recorded when a + // device or a backend submitted an observation for it. There is more than + // one because a client observation cannot state a Store Environment and is + // therefore recorded under `unclassified`, while a notification is recorded + // under the environment the store confirmed. + ReferenceDigests [][]byte + // Correlators are the hashed provider correlators from the authoritative + // response. + Correlators []AssociationCorrelator + // AcquiredAt dates the purchase for the one-time instance row. + AcquiredAt time.Time +} + +// SubmissionBinder records the association a submitted observation carries. +// +// An SDK or an application backend that submits an observation while holding a +// Customer Access Token is stating "this transaction belongs to the customer +// this token names". That statement is the only thing in a deployed system that +// can attach a *first* purchase to an identified customer: a store notification +// arrives out of band and names nobody, and the observation contract carries no +// customer member. The token is the assertion, and it is trustworthy because +// only the application's own backend can mint one. +// +// The evidence is keyed on the transaction reference rather than on a lineage, +// because at submission time no lineage exists yet — the purchase has not been +// validated. `EvidenceForReference` is how the seam finds it later. +type SubmissionBinder interface { + // BindSubmission authenticates the Customer Access Token and records the + // evidence. An empty token is not an error: most observations carry none. + // It returns the customer the token named, or "" when there was no token. + BindSubmission(ctx context.Context, token string, submission SubmissionBinding) (string, error) +} + +// SubmissionBinding names one submitted observation. +type SubmissionBinding struct { + ProjectID string + EnvironmentID string + RawInputID string + // TransactionReferenceDigest is the reference the observation named. It is + // the join key the seam reads back. + TransactionReferenceDigest []byte + // SecretServerKey reports whether the submission itself was authenticated + // by the application's secret server key rather than by the public SDK key. + // + // It decides how much authority the recorded evidence carries, and the + // distinction is not cosmetic: a secret server key proves the application's + // own backend is speaking, while the public key ships inside every install + // and proves only that the caller holds a Customer Access Token. Recording + // both at the same authority let anyone able to present a token take an + // established purchase away from the customer that owns it, or freeze it in + // an identity conflict. + SecretServerKey bool + ObservedAt time.Time +} diff --git a/apps/api/internal/billing/service.go b/apps/api/internal/billing/service.go index 287bec6f..85f6608d 100644 --- a/apps/api/internal/billing/service.go +++ b/apps/api/internal/billing/service.go @@ -53,6 +53,13 @@ type Service struct { // is used only to render the endpoint URL returned on create and rotate. notificationBaseURL string + // lineages attaches a committed fact's Purchase Lineage to a Billing + // Customer, and submissions record the association a token-bound + // observation carries. Both are the Phase 9A→9B seam (see seam.go) and both + // are optional: without them this service behaves exactly as Phase 9A did. + lineages LineageBinder + submissions SubmissionBinder + intakeAccepted metric.Int64Counter intakeRejected metric.Int64Counter signatureFailure metric.Int64Counter @@ -109,6 +116,19 @@ func WithRetention(retention time.Duration) ServiceOption { } } +// WithSeam wires the Phase 9B seam. Either binder may be nil. +// +// They are one option rather than two because wiring one without the other is +// always a mistake: submission evidence nothing reads is dead weight, and a +// lineage binder with no submission evidence can only ever reach the +// purchase-anchored fallback, which silently turns every identified customer's +// first purchase into an unidentified one. +func WithSeam(lineages LineageBinder, submissions SubmissionBinder) ServiceOption { + return func(s *Service) { + s.lineages, s.submissions = lineages, submissions + } +} + func WithNotificationBaseURL(base string) ServiceOption { return func(s *Service) { s.notificationBaseURL = strings.TrimRight(strings.TrimSpace(base), "/") } } @@ -403,6 +423,49 @@ func (s *Service) SubmitClientObservation(ctx context.Context, rawKey string, ob return s.submitObservation(ctx, scope, observation, SourceClientObservation, AuthorityClient, AuthUnauthenticated, correlationID) } +// CustomerToken carries an optional Customer Access Token presented alongside an +// observation. +// +// It is the submission-context half of the Phase 9A→9B seam. A store +// notification arrives out of band and names nobody, and the observation +// contract has no customer member, so a caller holding a token is the only thing +// in a deployed system that can say which customer a *first* purchase belongs +// to. The token is trustworthy for that because only the application's own +// backend can mint one. +// +// It is a separate argument rather than a field on Observation because it is not +// part of the contract record: it is a credential, and credentials do not travel +// in bodies that get sealed and replayed. +type CustomerToken string + +// SubmitClientObservationAs is SubmitClientObservation with a Customer Access +// Token attached. An empty token behaves exactly like the plain form. +func (s *Service) SubmitClientObservationAs(ctx context.Context, rawKey string, token CustomerToken, + observation Observation, correlationID string) (SubmissionResult, error) { + + scope, err := s.repository.AuthenticateSDKKey(ctx, rawKey) + if err != nil { + return SubmissionResult{}, ErrUnauthenticated + } + observation.StoreEnvironment = StoreUnclassified + observation.PurchaseToken = "" + return s.submitObservationAs(ctx, scope, token, observation, + SourceClientObservation, AuthorityClient, AuthUnauthenticated, correlationID) +} + +// SubmitServerObservationAs is SubmitServerObservation with a Customer Access +// Token attached. +func (s *Service) SubmitServerObservationAs(ctx context.Context, rawKey string, token CustomerToken, + observation Observation, correlationID string) (SubmissionResult, error) { + + scope, err := s.repository.AuthenticateServerKey(ctx, rawKey) + if err != nil { + return SubmissionResult{}, ErrUnauthenticated + } + return s.submitObservationAs(ctx, scope, token, observation, + SourceTrustedServerObservation, AuthorityTrustedServer, AuthVerifiedTransport, correlationID) +} + // SubmitServerObservation records a trusted app-backend report. It may carry a // full Google purchase token, which is encrypted on receipt and still subjected // to complete provider validation: a trusted caller is more accountable, not @@ -416,6 +479,10 @@ func (s *Service) SubmitServerObservation(ctx context.Context, rawKey string, ob } func (s *Service) submitObservation(ctx context.Context, scope ObservationScope, observation Observation, source, authority, authentication, correlationID string) (SubmissionResult, error) { + return s.submitObservationAs(ctx, scope, "", observation, source, authority, authentication, correlationID) +} + +func (s *Service) submitObservationAs(ctx context.Context, scope ObservationScope, token CustomerToken, observation Observation, source, authority, authentication, correlationID string) (SubmissionResult, error) { ctx, span := s.tracer.Start(ctx, "billing.intake.observation") defer span.End() now := s.now() @@ -491,6 +558,34 @@ func (s *Service) submitObservation(ctx context.Context, scope ObservationScope, }, nil } s.observeIntake(ctx, provider, result) + + // Record the association the submission carries, before the switch below + // returns. A duplicate submission still records it: the second device of the + // same customer submitting the same purchase is the ordinary restore shape, + // and the evidence table is append-only history rather than a set. + if s.submissions != nil && token != "" && result.RawInputID != "" && !result.Conflicted { + if _, err := s.submissions.BindSubmission(ctx, string(token), SubmissionBinding{ + ProjectID: scope.ProjectID, EnvironmentID: scope.EnvironmentID, + RawInputID: result.RawInputID, + TransactionReferenceDigest: referenceDigest, + // The credential that authenticated this submission, not the one + // that minted the token. A token-bound observation arriving on the + // public SDK key records weaker evidence than the same claim made + // by the application's own backend over its secret key. + SecretServerKey: authority == AuthorityTrustedServer, + ObservedAt: now, + }); err != nil { + // The observation itself is recorded and valid. Failing the + // submission over the association would turn a transient identity + // failure into a dropped purchase, and the seam retries the decision + // from the fact side anyway. + zerolog.Ctx(ctx).Error(). + Str("project_id", scope.ProjectID). + Str("raw_input_id", result.RawInputID). + Msg("observation submission evidence could not be recorded") + } + } + switch { case result.Conflicted: // The same submission id arrived carrying different content. Accepting diff --git a/apps/api/internal/billing/service_worker.go b/apps/api/internal/billing/service_worker.go index c1028dec..6136e63a 100644 --- a/apps/api/internal/billing/service_worker.go +++ b/apps/api/internal/billing/service_worker.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "strconv" "strings" "time" @@ -60,9 +61,57 @@ 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) + 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, + }) +} + // runValidation performs one attempt and assembles everything it produced. It // never returns an error: a failure is an outcome that must be recorded, not a // condition that discards the work. @@ -250,8 +299,51 @@ func (s *Service) validateApple(ctx context.Context, job ValidationJob, input Ra SourceRawInputID: input.ID, ValidationAttemptID: attemptID, } + if !applyAppleTransaction(&fact, transaction, renewal) { + // 9A correction (B7): worker wall-clock must never stand in for + // occurred_at — it participates in FactDigest, so a wall-clock value + // makes every replay of the same input a "new" fact and defeats replay + // idempotency. An Apple payload with neither purchaseDate nor + // signedDate quarantines instead. + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "no_provider_timestamp"), QuarantineMissingProviderTimestamp, "error") + } + + outcome := s.resolveAndBuild(ctx, job, input, fact, platform, attemptID, attemptNumber, started, storeEnvironment) + // The correlator is read from the App Store Server API's own verified + // transaction rather than from the notification body, because that response + // is the authority and the notification is only the trigger. It is hashed + // here and the raw value goes no further. + // Every reference an observation for this transaction could have been + // submitted under. A client observation cannot classify the Store + // Environment, so it lands under `unclassified`; a restore names the + // original transaction rather than the renewal. + outcome.ReferenceDigests = appendDigest(outcome.ReferenceDigests, input.TransactionReferenceDigest) + for _, reference := range []string{transaction.TransactionID, transaction.OriginalTransactionID} { + if reference == "" { + continue + } + for _, environment := range []string{storeEnvironment, StoreUnclassified} { + outcome.ReferenceDigests = appendDigest(outcome.ReferenceDigests, + AppleTransactionKey(environment, reference)) + } + } + if token := strings.TrimSpace(transaction.AppAccountToken); token != "" { + outcome.Correlators = append(outcome.Correlators, AssociationCorrelator{ + EvidenceType: EvidenceAppAccountToken, + AliasType: AliasAppleAppAccountToken, + Digest: AliasDigest(AliasAppleAppAccountToken, token), + }) + } + return outcome +} + +// applyAppleTransaction populates the transaction-derived fields of an Apple +// fact. It reports false when the payload carries no provider timestamp at +// all, in which case no fact may be recorded (9A correction B7). +func applyAppleTransaction(fact *TransactionFact, transaction appstorejws.TransactionPayload, renewal *appstorejws.RenewalPayload) bool { if transaction.OriginalTransactionID != "" { - fact.PurchaseChainDigest = AppleTransactionKey(storeEnvironment, transaction.OriginalTransactionID) + fact.PurchaseChainDigest = AppleTransactionKey(fact.StoreEnvironment, transaction.OriginalTransactionID) } if when, ok := appstorejws.Millis(transaction.PurchaseDate); ok { fact.OccurredAt = when @@ -261,24 +353,41 @@ func (s *Service) validateApple(ctx context.Context, job ValidationJob, input Ra fact.PeriodEndAt = &when } if when, ok := appstorejws.Millis(transaction.RevocationDate); ok { + // 9A correction: both of Apple's revocation reasons are refunds — 0 is + // "refunded for another reason", 1 is "refunded due to an app issue" — + // so a revocation always carries refunded_at, not only reason 1. fact.RevokedAt = &when - if transaction.RevocationReason != nil && *transaction.RevocationReason == 1 { - fact.RefundedAt = &when - } + fact.RefundedAt = &when + } + // Fact-shape v2: persist what was already parsed but dropped (quality B10). + fact.RevocationReason = transaction.RevocationReason + fact.InAppOwnershipType = transaction.InAppOwnershipType + fact.SubscriptionGroupIdentifier = transaction.SubscriptionGroupIdentifier + if transaction.IsUpgraded { + upgraded := true + fact.IsUpgraded = &upgraded } if renewal != nil { expected := renewal.AutoRenewStatus == 1 fact.RenewalExpected = &expected + fact.AutoRenewProductIdentifier = renewal.AutoRenewProductID + if renewal.IsInBillingRetry { + retrying := true + fact.BillingRetryActive = &retrying + } + if when, ok := appstorejws.Millis(renewal.GracePeriodExpiresAt); ok { + fact.GracePeriodExpiresAt = &when + } + } + if when, ok := appstorejws.Millis(transaction.SignedDate); ok { + fact.ProviderEventOccurredAt = &when } if fact.OccurredAt.IsZero() { if when, ok := appstorejws.Millis(transaction.SignedDate); ok { fact.OccurredAt = when - } else { - fact.OccurredAt = started } } - - return s.resolveAndBuild(ctx, job, input, fact, platform, attemptID, attemptNumber, started, storeEnvironment) + return !fact.OccurredAt.IsZero() } // appleTransactionType maps Apple's product type onto the two types Phase 9A @@ -483,11 +592,13 @@ func (s *Service) validateGoogle(ctx context.Context, job ValidationJob, input R Permanent(CategoryInvalid, "raw_body_unavailable"), QuarantineMalformedReference, "warning") } - packageName, purchaseToken, productID, orderID, subscription, ok := decodeGoogleWork(body) + work, ok := decodeGoogleWork(body) if !ok { return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, Permanent(CategoryInvalid, "malformed_google_input"), QuarantineMalformedReference, "error") } + packageName, purchaseToken, productID, orderID := work.packageName, work.purchaseToken, work.productID, work.orderID + subscription := work.subscription if packageName == "" { // Only an RTDN carries a packageName; an observation does not. packageName = scopedPackageName @@ -541,9 +652,24 @@ func (s *Service) validateGoogle(ctx context.Context, job ValidationJob, input R FactVersion: 1, SourceRawInputID: input.ID, ValidationAttemptID: attemptID, - OccurredAt: started, + // OccurredAt is deliberately not defaulted: only a provider-stated + // time may date a fact (9A correction B7), and a branch that cannot + // supply one quarantines below. + } + // Fact-shape v2: recover the provider event time so ordering inside a + // Google lineage does not tie on the constant startTime. The RTDN's + // eventTimeMillis is the provider's own statement; the raw input's + // provider_occurred_at (Pub/Sub publish time) is the fallback. + if !work.eventTime.IsZero() { + when := work.eventTime + fact.ProviderEventOccurredAt = &when + } else if input.ProviderOccurredAt != nil { + when := input.ProviderOccurredAt.UTC() + fact.ProviderEventOccurredAt = &when } + linkedPurchaseToken := "" + obfuscatedAccountID := "" if subscription { purchase, err := s.google.GetSubscription(ctx, account, packageName, purchaseToken) s.providerRequests.Add(ctx, 1, metric.WithAttributes( @@ -557,35 +683,60 @@ func (s *Service) validateGoogle(ctx context.Context, job ValidationJob, input R return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, Permanent(CategoryInvalid, "subscription_has_no_line_items"), QuarantineMalformedReference, "error") } - item := purchase.LineItems[0] - fact.TransactionType = TypeAutoRenewableSubscription - fact.ProviderProductIdentifier = item.ProductID - fact.ProviderTransactionID = purchase.LatestOrderID - fact.FactKind = googleSubscriptionKind(purchase.SubscriptionState) - fact.IsTestTransaction = purchase.TestPurchase != nil - if item.OfferDetails != nil { - fact.ProviderBasePlanIdentifier = item.OfferDetails.BasePlanID - fact.ProviderOfferIdentifier = item.OfferDetails.OfferID + applyGoogleSubscription(&fact, purchase) + if purchase.ExternalAccountIdentifiers != nil { + obfuscatedAccountID = purchase.ExternalAccountIdentifiers.ObfuscatedExternalAccountID } - if item.AutoRenewingPlan != nil { - expected := item.AutoRenewingPlan.AutoRenewEnabled - fact.RenewalExpected = &expected - } - if when, ok := parseRFC3339(purchase.StartTime); ok { - fact.PeriodStartAt = &when - fact.OccurredAt = when - } - if when, ok := parseRFC3339(item.ExpiryTime); ok { - fact.PeriodEndAt = &when - } - if purchase.LinkedPurchaseToken != "" { - // The link is recorded, not acted on: acting on it would mean - // revoking access, and no access state exists to revoke. - fact.SupersedesChainDigest = TokenDigest(purchase.LinkedPurchaseToken) - fact.FactKind = KindPurchaseSuperseded + if !applyGoogleVoid(&fact, work, input.ProviderOccurredAt) { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "void_event_time_unavailable"), QuarantineMissingProviderTimestamp, "error") } + linkedPurchaseToken = purchase.LinkedPurchaseToken } else { + // A voided-purchase notification carries no SKU; recover it from the + // order so the refund fact still resolves to a Product. + if productID == "" && orderID != "" { + order, orderErr := s.google.GetOrder(ctx, account, packageName, orderID) + s.providerRequests.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", ProviderGooglePlay), + attribute.String("endpoint", "order_get"), + attribute.Bool("failed", orderErr != nil))) + if orderErr != nil { + // Review finding I-4: a *permanently* failing orders.get on a + // void used to burn attempts and record nothing, so the refund + // never became a fact and the purchase kept granting forever. + // A transient failure still retries — the order may come back — + // but once the failure is permanent (or retries are spent) the + // void is recorded with an unresolved Product instead of being + // dropped. + classification := Classify(orderErr, s.now()) + if !work.voided || (classification.Retryable && + !classification.ExhaustedFor(attemptNumber, job.MaxAttempts)) { + return s.classifiedFailure(job, input, attemptID, attemptNumber, started, orderErr) + } + return s.voidWithoutProduct(job, input, fact, work, attemptID, attemptNumber, started, + orderID, "void_order_lookup_permanently_failed") + } + if len(order.LineItems) == 1 { + productID = order.LineItems[0].ProductID + } else if work.voided { + // Review finding I-4: a multi-line-item order cannot be + // attributed to one SKU from the order alone. Quarantining the + // whole input left the refund unrecorded and the purchase + // entitled, so the void is recorded product-unresolved instead + // and gets its own quarantine reason — an operator looking at + // `product_identifier_unavailable` had no way to tell this case + // (revenue already refunded, access still to be corrected) from + // a plainly malformed reference. + return s.voidWithoutProduct(job, input, fact, work, attemptID, attemptNumber, started, + orderID, "void_order_line_items_ambiguous") + } + } if productID == "" { + if work.voided { + return s.voidWithoutProduct(job, input, fact, work, attemptID, attemptNumber, started, + orderID, "void_product_identifier_unavailable") + } return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, Permanent(CategoryInvalid, "product_identifier_unavailable"), QuarantineMalformedReference, "error") } @@ -597,23 +748,23 @@ func (s *Service) validateGoogle(ctx context.Context, job ValidationJob, input R if err != nil { return s.classifiedFailure(job, input, attemptID, attemptNumber, started, err) } - if purchase.PurchaseState != 0 { + if purchase.PurchaseState != 0 && !work.voided { // Only PURCHASED is a completed purchase. PENDING and CANCELLED are // recorded as inputs but produce no fact, because a fact asserts that // the store confirmed a completed transaction. return s.recordedNoFactAttempt(job, input, attemptID, attemptNumber, started, "google_purchase_not_completed") } - fact.TransactionType = TypeNonConsumable - fact.FactKind = KindOneTimePurchase - fact.ProviderProductIdentifier = purchase.ProductID - fact.ProviderTransactionID = purchase.OrderID - if purchase.PurchaseType != nil && *purchase.PurchaseType == 0 { - fact.IsTestTransaction = true - } - if millis, err := strconv.ParseInt(purchase.PurchaseTimeMillis, 10, 64); err == nil && millis > 0 { - when := time.UnixMilli(millis).UTC() - fact.OccurredAt = when - fact.PeriodStartAt = &when + // 9A correction (B2): a voided one-time purchase re-queries as + // purchaseState != 0, and recording no fact left refunded + // non-consumables entitled forever. The voided-purchase notification is + // the provider's refund statement — its 30-day lookback is why the void + // must become a fact on receipt — so it produces a refund fact even + // though the re-queried state alone says only "not purchased". + applyGoogleOneTime(&fact, purchase) + obfuscatedAccountID = purchase.ObfuscatedExternalAccountID + if !applyGoogleVoid(&fact, work, input.ProviderOccurredAt) { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "void_event_time_unavailable"), QuarantineMissingProviderTimestamp, "error") } } @@ -627,28 +778,337 @@ func (s *Service) validateGoogle(ctx context.Context, job ValidationJob, input R return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, Permanent(CategoryInvalid, "store_environment_mismatch"), QuarantineStoreEnvironmentMismatch, "error") } - return s.resolveAndBuild(ctx, job, input, fact, platform, attemptID, attemptNumber, started, fact.StoreEnvironment) + if fact.OccurredAt.IsZero() { + // 9A correction (B7): occurred_at participates in FactDigest, so worker + // wall-clock would make every replay a different fact. No provider + // timestamp means no fact. + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "no_provider_timestamp"), QuarantineMissingProviderTimestamp, "error") + } + outcome := s.resolveAndBuild(ctx, job, input, fact, platform, attemptID, attemptNumber, started, fact.StoreEnvironment) + // Same treatment as Apple's appAccountToken: read from the authoritative + // purchase resource, hashed here, raw value goes no further. + outcome.ReferenceDigests = appendDigest(outcome.ReferenceDigests, input.TransactionReferenceDigest) + outcome.ReferenceDigests = appendDigest(outcome.ReferenceDigests, fact.PurchaseChainDigest) + if account := strings.TrimSpace(obfuscatedAccountID); account != "" { + outcome.Correlators = append(outcome.Correlators, AssociationCorrelator{ + EvidenceType: EvidenceObfuscatedAccount, + AliasType: AliasGoogleObfuscatedID, + Digest: AliasDigest(AliasGoogleObfuscatedID, account), + }) + } + if linkedPurchaseToken != "" && outcome.Fact != nil { + // 9A correction (B1): the linked purchase token is a persistent attribute + // of the successor subscription, present on every re-query for its whole + // life. The state-derived fact kind is kept — overwriting it hid every + // later expiration, cancellation, and grace fact behind + // purchase_superseded — and the supersession edge is recorded as its own + // fact built only from lineage-constant fields, so its digest is stable + // and the unique fact constraint absorbs every observation after the + // first. The link is thereby "emitted once when newly observed" as a + // structural property rather than a lookup. + supersession, err := s.supersessionFactFrom(*outcome.Fact) + if err != nil { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "supersession_fact_unavailable"), + QuarantineMalformedReference, "error") + } + outcome.Supersession = supersession + } + return outcome +} + +// applyGoogleSubscription populates the subscription-specific fields of a +// Google fact from the authoritative subscriptionsv2 resource. It is a pure +// assembly step, split out so the fact-kind and supersession behaviour is +// testable without provider plumbing. +func applyGoogleSubscription(fact *TransactionFact, purchase googleplay.SubscriptionPurchase) { + item := purchase.LineItems[0] + fact.TransactionType = TypeAutoRenewableSubscription + fact.ProviderProductIdentifier = item.ProductID + fact.ProviderTransactionID = purchase.LatestOrderID + fact.FactKind = googleSubscriptionKind(purchase.SubscriptionState) + fact.IsTestTransaction = purchase.TestPurchase != nil + if purchase.SubscriptionState == "SUBSCRIPTION_STATE_ON_HOLD" { + retrying := true + fact.BillingRetryActive = &retrying + } + if item.OfferDetails != nil { + fact.ProviderBasePlanIdentifier = item.OfferDetails.BasePlanID + fact.ProviderOfferIdentifier = item.OfferDetails.OfferID + } + if item.AutoRenewingPlan != nil { + expected := item.AutoRenewingPlan.AutoRenewEnabled + fact.RenewalExpected = &expected + } + if when, ok := parseRFC3339(purchase.StartTime); ok { + fact.PeriodStartAt = &when + fact.OccurredAt = when + } + if when, ok := parseRFC3339(item.ExpiryTime); ok { + fact.PeriodEndAt = &when + if purchase.SubscriptionState == "SUBSCRIPTION_STATE_IN_GRACE_PERIOD" { + // Play does not publish a separate grace-end field: while a + // subscription is in grace it keeps `expiryTime` extended to the + // end of the grace window, so that value *is* the provider-stated + // grace end. Without it the grace fact carried no bound, the + // projection warned on every pass, reported the lineage plainly + // active, and a Project's grants_in_grace opt-out had nothing to + // act on. + graceEnd := when + fact.GracePeriodExpiresAt = &graceEnd + } + } + if purchase.LinkedPurchaseToken != "" { + // The link is recorded, not acted on: acting on it would mean revoking + // access, and no access state exists to revoke. The state-derived + // fact_kind above is deliberately not overwritten (9A defect B1). + fact.SupersedesChainDigest = TokenDigest(purchase.LinkedPurchaseToken) + } +} + +// applyGoogleOneTime populates the one-time-purchase fields of a Google fact +// from the authoritative purchases.products resource. +func applyGoogleOneTime(fact *TransactionFact, purchase googleplay.ProductPurchase) { + fact.TransactionType = TypeNonConsumable + fact.FactKind = KindOneTimePurchase + fact.ProviderProductIdentifier = purchase.ProductID + fact.ProviderTransactionID = purchase.OrderID + if purchase.PurchaseType != nil && *purchase.PurchaseType == 0 { + fact.IsTestTransaction = true + } + if millis, err := strconv.ParseInt(purchase.PurchaseTimeMillis, 10, 64); err == nil && millis > 0 { + when := time.UnixMilli(millis).UTC() + fact.OccurredAt = when + fact.PeriodStartAt = &when + } +} + +// applyGoogleVoid rewrites a fact as the refund the voided-purchase +// notification asserts (9A correction B2). A Google void both refunds and +// revokes ownership, so both effective timestamps are set from the provider's +// own event time. It reports false when the input is voided but no provider +// timestamp exists to date the refund — a fact must never be dated with worker +// wall-clock. +func applyGoogleVoid(fact *TransactionFact, work googleWork, providerOccurredAt *time.Time) bool { + if !work.voided { + return true + } + when := work.eventTime + if when.IsZero() && providerOccurredAt != nil { + when = providerOccurredAt.UTC() + } + if when.IsZero() { + return false + } + fact.FactKind = KindRefund + fact.OccurredAt = when + fact.RefundedAt = &when + fact.RevokedAt = &when + switch work.refundType { + case 1: + fact.RefundType = RefundTypeFull + case 2: + fact.RefundType = RefundTypeQuantityPartial + } + return true +} + +// ProviderProductVoidUnresolved stands in for the SKU of a voided Google +// purchase Mosaic could not attribute to one product. Play product identifiers +// cannot contain a colon, so the sentinel can never collide with a real one, and +// it exists only because `provider_product_identifier` is NOT NULL and non-blank +// on every fact. +const ProviderProductVoidUnresolved = "unresolved:voided_purchase" + +// voidWithoutProduct records a Google void whose Product could not be resolved +// (review finding I-4). +// +// Two provider shapes reach here: an order with several line items, which +// cannot be attributed to one SKU, and an orders.get that fails permanently. +// Both previously produced no fact at all — the first quarantined the input as +// a malformed reference, the second exhausted its attempts — so a refunded +// purchase went on granting its Entitlement indefinitely. Money left the +// merchant and access did not. +// +// The refund is therefore recorded as a fact with `resolution_state = +// unresolved`, which drives the lineage to `unknown` rather than leaving it +// `owned`: Mosaic states that this purchase is no longer good without claiming +// to know which Product it was. The input is quarantined alongside it under its +// own reason code, so the operator queue distinguishes "refund recorded, +// product needs attribution" from a plainly malformed reference. +func (s *Service) voidWithoutProduct(job ValidationJob, input RawInput, fact TransactionFact, work googleWork, + attemptID string, attemptNumber int, started time.Time, orderID, diagnostic string) AttemptOutcome { + + fact.TransactionType = TypeNonConsumable + fact.ProviderProductIdentifier = ProviderProductVoidUnresolved + fact.ResolutionState = StateUnresolved + fact.MosaicProductID = "" + fact.ProviderProductMappingID = "" + fact.ResolvedMappingVersion = nil + if orderID != "" { + fact.ProviderTransactionID = orderID + } else { + fact.ProviderTransactionID = "token:" + hexOf(fact.PurchaseChainDigest) + } + if !applyGoogleVoid(&fact, work, input.ProviderOccurredAt) || fact.OccurredAt.IsZero() { + // 9A correction B7 still governs: no provider timestamp, no fact. The + // void is reported under the timestamp reason rather than this one, + // because the operator's next action is different. + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "void_event_time_unavailable"), + QuarantineMissingProviderTimestamp, "error") + } + if !storeEnvironmentMatchesMode(fact.StoreEnvironment, input.EnvironmentMode) { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "store_environment_mismatch"), + QuarantineStoreEnvironmentMismatch, "error") + } + + factID, err := s.newID("btf") + if err != nil { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "fact_identifier_unavailable"), + QuarantineMalformedReference, "error") + } + fact.ID = factID + fact.RecordedAt = s.now() + fact.FactDigest = FactDigest(fact) + + completed := s.now() + outcome := AttemptOutcome{ + Attempt: ValidationAttempt{ + ID: attemptID, ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, + RawInputID: input.ID, CredentialID: input.CredentialID, + AttemptNumber: attemptNumber, ValidatorVersion: ValidatorVersion, + StartedAt: started, CompletedAt: completed, + // Quarantined rather than validated: a fact was produced, but the + // input still needs operator attention, which is the same shape + // resolveAndBuild uses for an unresolvable Product. + Outcome: OutcomeQuarantined, Retryable: false, + FailureCategory: CategoryResolution, + DiagnosticCode: diagnostic, + StoreEnvironment: fact.StoreEnvironment, + LatencyMs: int(completed.Sub(started).Milliseconds()), + CorrelationID: input.CorrelationID, + }, + Fact: &fact, + Quarantine: &QuarantineWrite{ + RawInputID: input.ID, ApplicationID: fact.ApplicationID, Provider: fact.Provider, + ReasonCode: QuarantineVoidProductUnresolved, Severity: "error", + Scopes: []string{"provider_product_mapping"}, + DiagnosticCode: diagnostic, OccurredAt: completed, + }, + // The work is done: re-running it would re-query the same order and + // reach the same answer, and the fact is already recorded. + JobStatus: "completed", + } + outcome.Ledger = s.ledgerFor(input, outcome) + return outcome +} + +// supersessionFactFrom derives the once-per-lineage purchase_superseded fact +// from a validated successor fact. Every field that changes across the +// successor's life (order id, period end, revocation, renewal intent) is +// cleared or replaced with a lineage-constant value, so re-observing the same +// link always recomputes the same FactDigest. +func (s *Service) supersessionFactFrom(main TransactionFact) (*TransactionFact, error) { + fact := main + id, err := s.newID("btf") + if err != nil { + // Previously this returned nil and the link was silently never emitted. + // A supersession edge that is dropped without a trace is a lineage that + // never learns its own chain root, so the failure is reported. + return nil, fmt.Errorf("generate supersession fact identifier: %w", err) + } + fact.ID = id + fact.FactKind = KindPurchaseSuperseded + fact.ProviderTransactionID = "token:" + hexOf(fact.PurchaseChainDigest) + fact.PeriodEndAt = nil + fact.RevokedAt = nil + fact.RefundedAt = nil + fact.RenewalExpected = nil + fact.GracePeriodExpiresAt = nil + fact.BillingRetryActive = nil + fact.AutoRenewProductIdentifier = "" + fact.IsUpgraded = nil + fact.RevocationReason = nil + fact.RefundType = "" + fact.ProviderEventOccurredAt = nil + + // The edge is a statement about two purchase tokens and nothing else, so + // every field that can move underneath it is cleared before the digest is + // taken. Product identity in particular is not lineage-constant: an offer + // expires, a mapping is edited, an unresolved fact is later resolved — and + // each of those recomputed a different digest for the same link, minting a + // duplicate purchase_superseded fact every time. + fact.ProviderProductIdentifier = "superseded" + fact.ProviderBasePlanIdentifier = "" + fact.ProviderOfferIdentifier = "" + fact.ResolutionState = StateUnresolved + fact.MosaicProductID = "" + fact.ProviderProductMappingID = "" + fact.ResolvedMappingVersion = nil + // A void rewrites OccurredAt to the refund instant, so the subscription's + // own start is used where it exists: that value is constant for the chain. + if fact.PeriodStartAt != nil { + fact.OccurredAt = fact.PeriodStartAt.UTC() + } + fact.RecordedAt = s.now() + fact.FactDigest = FactDigest(fact) + return &fact, nil +} + +// googleWork is the decoded intent of one Google raw body: which purchase to +// re-query, and — for a voided purchase notification — the void semantics the +// re-queried state alone cannot express. +type googleWork struct { + packageName string + purchaseToken string + productID string + orderID string + subscription bool + // voided marks a voidedPurchaseNotification. Google's own state on + // re-query says only "not purchased"; the notification is the evidence + // that the reason is a refund, and its 30-day lookback means the void + // must be persisted on receipt. + voided bool + // refundType is Google's voided refundType: 1 full, 2 quantity-based + // partial. Zero when absent. + refundType int + // eventTime is the RTDN eventTimeMillis, the provider-stated instant the + // event occurred. Zero when the body carried none. + eventTime time.Time } // decodeGoogleWork reads whichever shape the raw body holds: a decoded RTDN or // a Mosaic-built observation record. -func decodeGoogleWork(body []byte) (packageName, purchaseToken, productID, orderID string, subscription bool, ok bool) { +func decodeGoogleWork(body []byte) (googleWork, bool) { var notification googleplay.DeveloperNotification if err := json.Unmarshal(body, ¬ification); err == nil && notification.PackageName != "" { - packageName = notification.PackageName + work := googleWork{packageName: notification.PackageName} + if millis, err := strconv.ParseInt(notification.EventTimeMillis, 10, 64); err == nil && millis > 0 { + work.eventTime = time.UnixMilli(millis).UTC() + } switch { case notification.SubscriptionNotification != nil: - return packageName, notification.SubscriptionNotification.PurchaseToken, - notification.SubscriptionNotification.SubscriptionID, "", true, true + work.purchaseToken = notification.SubscriptionNotification.PurchaseToken + work.productID = notification.SubscriptionNotification.SubscriptionID + work.subscription = true + return work, true case notification.OneTimeProductNotification != nil: - return packageName, notification.OneTimeProductNotification.PurchaseToken, - notification.OneTimeProductNotification.SKU, "", false, true + work.purchaseToken = notification.OneTimeProductNotification.PurchaseToken + work.productID = notification.OneTimeProductNotification.SKU + return work, true case notification.VoidedPurchaseNotification != nil: - return packageName, notification.VoidedPurchaseNotification.PurchaseToken, "", - notification.VoidedPurchaseNotification.OrderID, - notification.VoidedPurchaseNotification.ProductType == 1, true + work.purchaseToken = notification.VoidedPurchaseNotification.PurchaseToken + work.orderID = notification.VoidedPurchaseNotification.OrderID + work.subscription = notification.VoidedPurchaseNotification.ProductType == 1 + work.voided = true + work.refundType = notification.VoidedPurchaseNotification.RefundType + return work, true case notification.TestNotification != nil: - return packageName, "", "", "", false, true + return work, true } } var observation struct { @@ -658,9 +1118,13 @@ func decodeGoogleWork(body []byte) (packageName, purchaseToken, productID, order PurchaseToken string `json:"purchaseToken"` } if err := json.Unmarshal(body, &observation); err != nil { - return "", "", "", "", false, false + return googleWork{}, false } - return "", observation.PurchaseToken, "", observation.OrderReference, true, true + return googleWork{ + purchaseToken: observation.PurchaseToken, + orderID: observation.OrderReference, + subscription: true, + }, true } func googleSubscriptionKind(state string) string { @@ -1066,3 +1530,17 @@ func zero(value []byte) { } var _ = errors.Is + +// appendDigest adds one digest to a set, ignoring empties and duplicates. The +// set is small and unordered, so a linear scan is the whole implementation. +func appendDigest(digests [][]byte, digest []byte) [][]byte { + if len(digest) == 0 { + return digests + } + for _, existing := range digests { + if string(existing) == string(digest) { + return digests + } + } + return append(digests, digest) +} diff --git a/apps/api/internal/billing/service_worker_test.go b/apps/api/internal/billing/service_worker_test.go new file mode 100644 index 00000000..02223443 --- /dev/null +++ b/apps/api/internal/billing/service_worker_test.go @@ -0,0 +1,276 @@ +package billing + +import ( + "crypto/rand" + "encoding/json" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstorejws" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" +) + +// These tests pin the 9A corrections classified in the Phase 9B plan (§1.2, +// OD-13): each one is a defect that live-sandbox validation would have caught, +// and each could silently return through a refactor of the Google or Apple +// fact-assembly path. + +func googlePurchase(t *testing.T, raw string) googleplay.SubscriptionPurchase { + t.Helper() + var purchase googleplay.SubscriptionPurchase + if err := json.Unmarshal([]byte(raw), &purchase); err != nil { + t.Fatalf("decode fixture purchase: %v", err) + } + return purchase +} + +func testService(now time.Time) *Service { + return &Service{now: func() time.Time { return now }, random: rand.Reader} +} + +// 9A defect B1: a Google linkedPurchaseToken is a persistent attribute of the +// successor subscription. Overwriting fact_kind with purchase_superseded hid +// every later state fact (expiration, cancellation, grace) behind the link, +// which meant indefinite entitlement after any Google plan change. +func TestGoogleSubscriptionKeepsStateKindWhenLinkedTokenPresent(t *testing.T) { + purchase := googlePurchase(t, `{ + "startTime": "2026-01-01T00:00:00Z", + "subscriptionState": "SUBSCRIPTION_STATE_CANCELED", + "latestOrderId": "GPA.100-1", + "linkedPurchaseToken": "old-token", + "lineItems": [{"productId": "pro.monthly", "expiryTime": "2026-02-01T00:00:00Z"}] + }`) + fact := TransactionFact{PurchaseChainDigest: TokenDigest("new-token")} + applyGoogleSubscription(&fact, purchase) + + if fact.FactKind != KindCancellationScheduled { + t.Fatalf("fact kind %q, want the state-derived %q", fact.FactKind, KindCancellationScheduled) + } + if len(fact.SupersedesChainDigest) == 0 { + t.Fatal("supersedes_chain_digest was not recorded for the linked purchase token") + } +} + +// 9A defect B2: a voided (refunded) Google one-time purchase re-queries as +// purchaseState != 0 and recorded no fact, leaving refunded non-consumables +// entitled forever. The voided-purchase notification must become a refund fact +// with both refund and revocation effective times. +func TestVoidedGoogleOneTimePurchaseProducesRefundFact(t *testing.T) { + body := []byte(`{ + "version": "1.0", + "packageName": "com.fixture.app", + "eventTimeMillis": "1767225600000", + "voidedPurchaseNotification": { + "purchaseToken": "token-1", "orderId": "GPA.200-1", "productType": 2, "refundType": 1 + } + }`) + work, ok := decodeGoogleWork(body) + if !ok || !work.voided || work.subscription { + t.Fatalf("voided one-time decode: ok=%v work=%+v", ok, work) + } + if work.refundType != 1 || work.eventTime.IsZero() { + t.Fatalf("void semantics lost: %+v", work) + } + + fact := TransactionFact{} + applyGoogleOneTime(&fact, googleplay.ProductPurchase{ + PurchaseTimeMillis: "1764547200000", PurchaseState: 1, + OrderID: "GPA.200-1", ProductID: "lifetime.pro", + }) + if !applyGoogleVoid(&fact, work, nil) { + t.Fatal("void with provider event time must not be rejected") + } + if fact.FactKind != KindRefund { + t.Fatalf("fact kind %q, want %q", fact.FactKind, KindRefund) + } + if fact.RefundedAt == nil || fact.RevokedAt == nil { + t.Fatal("refund fact must carry refunded_at and revoked_at") + } + if !fact.RefundedAt.Equal(work.eventTime) || !fact.OccurredAt.Equal(work.eventTime) { + t.Fatalf("refund must be dated with the provider event time, got %v", fact.RefundedAt) + } +} + +// A voided input with no provider timestamp anywhere must be rejected rather +// than dated with worker wall-clock (which would poison FactDigest — B7). +func TestVoidWithoutProviderTimestampIsRejected(t *testing.T) { + fact := TransactionFact{} + if applyGoogleVoid(&fact, googleWork{voided: true}, nil) { + t.Fatal("a void without any provider timestamp must not produce a fact") + } +} + +// 9A defect B7: worker wall-clock must never date a fact. An Apple payload +// with no provider timestamp yields no fact, and two validations of the same +// payload at different wall-clock instants produce identical digests. +func TestAppleFactRejectsMissingProviderTimestamp(t *testing.T) { + fact := TransactionFact{StoreEnvironment: StoreProduction} + if applyAppleTransaction(&fact, appstorejws.TransactionPayload{TransactionID: "100"}, nil) { + t.Fatal("a payload with no provider timestamp must not produce a fact") + } + + // SignedDate alone is an acceptable provider timestamp. + fact = TransactionFact{StoreEnvironment: StoreProduction} + if !applyAppleTransaction(&fact, appstorejws.TransactionPayload{TransactionID: "100", SignedDate: 1767225600000}, nil) { + t.Fatal("signedDate is a provider timestamp and must be accepted") + } + if !fact.OccurredAt.Equal(time.UnixMilli(1767225600000).UTC()) { + t.Fatalf("occurred_at %v, want the provider signedDate", fact.OccurredAt) + } +} + +// Digest stability across validations at different wall-clock times is the +// property B7 protects: replay of the same input must be a structural no-op. +func TestFactDigestStableAcrossWallClock(t *testing.T) { + build := func() TransactionFact { + fact := TransactionFact{ + EnvironmentID: "env_1", ApplicationID: "app_1", Provider: ProviderAppStore, + StoreEnvironment: StoreProduction, ProviderTransactionID: "100", + TransactionType: TypeAutoRenewableSubscription, ValidatorVersion: ValidatorVersion, FactVersion: 1, + } + applyAppleTransaction(&fact, appstorejws.TransactionPayload{ + TransactionID: "100", OriginalTransactionID: "90", + PurchaseDate: 1767225600000, ExpiresDate: 1769904000000, + }, nil) + fact.FactKind = KindRenewal + return fact + } + one, two := build(), build() + one.RecordedAt = at("2026-01-01T00:00:00Z") + two.RecordedAt = at("2026-06-01T12:34:56Z") + if string(FactDigest(one)) != string(FactDigest(two)) { + t.Fatal("FactDigest depends on wall-clock state; replay idempotency is broken") + } +} + +// 9A correction: both Apple revocation reasons are refunds. Reason 0 +// ("refunded for another reason") previously recorded revoked_at with no +// refunded_at, so the refund scope was invisible to any consumer. +func TestAppleRevocationAlwaysCarriesRefund(t *testing.T) { + reason := 0 + fact := TransactionFact{StoreEnvironment: StoreProduction} + applyAppleTransaction(&fact, appstorejws.TransactionPayload{ + TransactionID: "100", PurchaseDate: 1767225600000, + RevocationDate: 1768000000000, RevocationReason: &reason, + }, nil) + if fact.RevokedAt == nil || fact.RefundedAt == nil { + t.Fatalf("revocation with reason 0 must set both revoked_at and refunded_at, got %+v", fact) + } + if !fact.RefundedAt.Equal(time.UnixMilli(1768000000000).UTC()) { + t.Fatalf("refunded_at %v, want the provider revocationDate", fact.RefundedAt) + } +} + +// The supersession edge itself is a separate fact whose digest must be stable +// across re-observations of the same lineage: renewals change the order id and +// expiry, and if those leaked into the supersession fact every renewal would +// append a duplicate purchase_superseded fact. +func TestSupersessionFactDigestStableAcrossRenewals(t *testing.T) { + first := googlePurchase(t, `{ + "startTime": "2026-01-01T00:00:00Z", + "subscriptionState": "SUBSCRIPTION_STATE_ACTIVE", + "latestOrderId": "GPA.100-1", + "linkedPurchaseToken": "old-token", + "lineItems": [{"productId": "pro.monthly", "expiryTime": "2026-02-01T00:00:00Z", + "autoRenewingPlan": {"autoRenewEnabled": true}}] + }`) + second := googlePurchase(t, `{ + "startTime": "2026-01-01T00:00:00Z", + "subscriptionState": "SUBSCRIPTION_STATE_CANCELED", + "latestOrderId": "GPA.100-3", + "linkedPurchaseToken": "old-token", + "lineItems": [{"productId": "pro.monthly", "expiryTime": "2026-04-01T00:00:00Z", + "autoRenewingPlan": {"autoRenewEnabled": false}}] + }`) + + base := TransactionFact{ + EnvironmentID: "env_1", ApplicationID: "app_1", Provider: ProviderGooglePlay, + StoreEnvironment: StoreProduction, PurchaseChainDigest: TokenDigest("new-token"), + ValidatorVersion: ValidatorVersion, FactVersion: 1, + } + factOne, factTwo := base, base + applyGoogleSubscription(&factOne, first) + applyGoogleSubscription(&factTwo, second) + + serviceOne := testService(at("2026-01-01T01:00:00Z")) + serviceTwo := testService(at("2026-03-15T09:30:00Z")) + supersessionOne, errOne := serviceOne.supersessionFactFrom(factOne) + supersessionTwo, errTwo := serviceTwo.supersessionFactFrom(factTwo) + if errOne != nil || errTwo != nil { + t.Fatalf("supersession fact was not built: %v / %v", errOne, errTwo) + } + if supersessionOne.FactKind != KindPurchaseSuperseded { + t.Fatalf("supersession fact kind %q", supersessionOne.FactKind) + } + if string(supersessionOne.FactDigest) != string(supersessionTwo.FactDigest) { + t.Fatal("supersession fact digest changed across renewals; the link would be recorded repeatedly") + } +} + +// Review finding I-4: a Google void whose Product cannot be attributed used to +// produce no Transaction Fact at all — a multi-line-item order quarantined the +// input as a malformed reference, and a permanently failing orders.get burned +// its attempts — so the refunded purchase kept granting its Entitlement +// indefinitely. Money left the merchant and access did not. +// +// This pins the shape of the recovery: a refund fact is recorded with an +// unresolved Product (which the projection reads as `unknown`, never `owned`), +// dated with the provider's own event time, and the input is quarantined under +// its own reason code so the operator queue can tell "refund recorded, product +// attribution owed" from a broken input. +func TestVoidWithoutResolvableProductStillRecordsTheRefund(t *testing.T) { + now := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + service := testService(now) + eventTime := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + + input := RawInput{ + ID: "bri_1", ProjectID: "prj_1", EnvironmentID: "env_1", + EnvironmentMode: "production", Provider: ProviderGooglePlay, + CorrelationID: "corr_1", + } + fact := TransactionFact{ + ProjectID: "prj_1", EnvironmentID: "env_1", EnvironmentMode: "production", + ApplicationID: "app_1", Provider: ProviderGooglePlay, + StoreEnvironment: StoreProduction, PurchaseChainDigest: TokenDigest("token-1"), + ValidatorVersion: ValidatorVersion, FactVersion: 1, + SourceRawInputID: "bri_1", ValidationAttemptID: "bva_1", + } + work := googleWork{voided: true, refundType: 1, eventTime: eventTime} + + outcome := service.voidWithoutProduct(ValidationJob{}, input, fact, work, + "bva_1", 1, now, "GPA.900-1", "void_order_line_items_ambiguous") + + if outcome.Fact == nil { + t.Fatal("an unattributable void produced no fact; the refunded purchase would keep granting") + } + if outcome.Fact.FactKind != KindRefund { + t.Fatalf("fact kind %q, want %q", outcome.Fact.FactKind, KindRefund) + } + if outcome.Fact.ResolutionState != StateUnresolved || outcome.Fact.MosaicProductID != "" { + t.Fatalf("fact must be product-unresolved, got state %q product %q", + outcome.Fact.ResolutionState, outcome.Fact.MosaicProductID) + } + if !outcome.Fact.OccurredAt.Equal(eventTime) || outcome.Fact.RefundedAt == nil { + t.Fatalf("refund must be dated with the provider event time, got %v", outcome.Fact.OccurredAt) + } + if len(outcome.Fact.FactDigest) == 0 { + t.Fatal("fact digest was not computed; duplicate delivery would not deduplicate") + } + if outcome.Quarantine == nil || outcome.Quarantine.ReasonCode != QuarantineVoidProductUnresolved { + t.Fatalf("quarantine reason %+v, want %q", outcome.Quarantine, QuarantineVoidProductUnresolved) + } + if outcome.JobStatus != "completed" { + t.Fatalf("job status %q, want completed: re-running reaches the same answer", outcome.JobStatus) + } + + // No provider timestamp anywhere still yields no fact (9A correction B7); + // the recovery must not become a wall-clock backdoor. + undated := service.voidWithoutProduct(ValidationJob{}, input, fact, + googleWork{voided: true}, "bva_2", 1, now, "GPA.900-1", "void_order_line_items_ambiguous") + if undated.Fact != nil { + t.Fatal("a void with no provider timestamp produced a fact") + } + if undated.Quarantine == nil || undated.Quarantine.ReasonCode != QuarantineMissingProviderTimestamp { + t.Fatalf("undated void quarantine %+v, want missing_provider_timestamp", undated.Quarantine) + } +} diff --git a/apps/api/internal/billingaccess/errors.go b/apps/api/internal/billingaccess/errors.go new file mode 100644 index 00000000..614fa59a --- /dev/null +++ b/apps/api/internal/billingaccess/errors.go @@ -0,0 +1,25 @@ +package billingaccess + +import "errors" + +// Stable domain errors. Handlers map these in one place; nothing compares an +// error message string. +var ( + // ErrBillingDisabled maps to `unavailable` on every entitlement surface, + // never to `inactive`. A Project that turned billing off has not told + // Mosaic that its customers lost access — it has told Mosaic to stop + // answering, and those are different answers. + ErrBillingDisabled = errors.New("billing is not enabled for this Project") + // ErrUnauthenticated covers a missing, malformed, expired, revoked, or + // wrong-audience credential. It is deliberately one error: distinguishing + // them on the wire would tell an attacker which half of a guess was right. + ErrUnauthenticated = errors.New("the request could not be authenticated") + ErrForbidden = errors.New("the credential does not cover this resource") + ErrNotFound = errors.New("the requested resource was not found") + ErrInvalid = errors.New("the request is not valid") + ErrConflict = errors.New("the resource is in a conflicting state") + ErrUnavailable = errors.New("billing storage is unavailable") + // ErrDestinationRefused is an SSRF-policy refusal. It is returned to the + // operator configuring the destination, never to the destination. + ErrDestinationRefused = errors.New("the destination address is not allowed") +) diff --git a/apps/api/internal/billingaccess/model.go b/apps/api/internal/billingaccess/model.go new file mode 100644 index 00000000..746e458a --- /dev/null +++ b/apps/api/internal/billingaccess/model.go @@ -0,0 +1,216 @@ +// Package billingaccess owns Mosaic's authoritative access surfaces: Customer +// Access Tokens, the SDK entitlement sync endpoint, the trusted-server +// entitlement APIs, restore and sync jobs, and projection diagnostics. +// +// Everything in this package reads committed projections. Nothing here +// projects, derives access, or interprets a provider payload — that is +// billingprojection's job, and keeping the read side unable to derive is what +// makes "the API and the SDK see the same state" structural rather than +// aspirational. +package billingaccess + +import "time" + +// ContractVersion is the Authoritative Entitlement Contract version this build +// speaks. It appears on every record this package serializes. +const ContractVersion = "1" + +// TokenContractVersion is the Customer Access Token Contract version. +const TokenContractVersion = "1" + +// --------------------------------------------------------------------------- +// Customer Access Tokens +// --------------------------------------------------------------------------- + +// TokenPrefix is the contract's fixed prefix. It is not a namespace to parse: +// it exists so a leaked credential is recognisable in a secret scanner. +const TokenPrefix = "mcat_" + +// TokenRandomBytes is 256 bits, which base64url-encodes to the contract's +// exact 43 characters. +const TokenRandomBytes = 32 + +// Token audiences. `server_check` is declared by the contract and deliberately +// not issued in Phase 9B: declaring it now means adding the audience later +// costs no contract version, and refusing to mint it now means no token exists +// for a surface that has not been built. +const ( + AudienceSDKSync = "sdk_sync" + AudienceServerCheck = "server_check" +) + +// Token scopes. +const ( + ScopeEntitlementsRead = "entitlements.read" + ScopeEntitlementsSync = "entitlements.sync" + ScopeRestoreRequest = "restore.request" +) + +// Token lifetimes. The default is one hour and the ceiling is one day; the +// schema enforces the ceiling independently, so a service bug cannot mint a +// long-lived credential. +const ( + DefaultTokenTTL = time.Hour + MaxTokenTTL = 24 * time.Hour + MinTokenTTL = time.Minute +) + +// Token statuses. +const ( + TokenActive = "active" + TokenExpired = "expired" + TokenRevoked = "revoked" +) + +// Revocation reasons, closed per the contract. +const ( + RevokedCustomerSignedOut = "customer_signed_out" + RevokedIdentityChanged = "identity_changed" + RevokedOperator = "operator_revoked" + RevokedCustomerDeleted = "customer_deleted" + RevokedKeyRotated = "key_rotated" + RevokedSuspectedCompromise = "suspected_compromise" + RevokedSuperseded = "superseded_by_new_token" +) + +// Token is the server-side metadata about one opaque credential. The credential +// itself is never a field here: it exists exactly once, in the issuance result, +// and Mosaic keeps only its digest. +type Token struct { + ID string + ProjectID string + EnvironmentID string + CustomerID string + Audience string + Scopes []string + IssuedByAPIKeyID string + IssuedAt time.Time + ExpiresAt time.Time + RevokedAt *time.Time + RevocationReason string + LastUsedAt *time.Time +} + +// Status reports the token's contract status at an instant. Revocation wins +// over expiry: a token revoked before it expired is revoked, and reporting it +// as merely expired would hide an operator action. +func (t Token) Status(now time.Time) string { + switch { + case t.RevokedAt != nil: + return TokenRevoked + case !now.Before(t.ExpiresAt): + return TokenExpired + default: + return TokenActive + } +} + +// HasScope reports whether the token carries a scope. +func (t Token) HasScope(scope string) bool { + for _, held := range t.Scopes { + if held == scope { + return true + } + } + return false +} + +// IssuanceRequest is what a trusted server asks for. It carries no Project or +// Environment: tenant scope comes from the authenticated secret server key, so +// a careless caller cannot mint a token into a tenant it does not own. +type IssuanceRequest struct { + CustomerID string + Audience string + Scopes []string + RequestedTTLSecond int + CorrelationID string +} + +// IssuedToken is the only value that ever carries the credential. +type IssuedToken struct { + // Value is returned to the caller exactly once and is never logged, + // never persisted, and never returned again. + Value string + Metadata Token +} + +// KeyScope is the tenant an API key authenticated into. It is a local copy of +// the ingestion package's scope so this package does not depend on the +// ingestion service to authenticate a read. +type KeyScope struct { + APIKeyID string + OrganizationID string + ProjectID string + EnvironmentID string + EnvironmentMode string + ApplicationID string +} + +// Actor is the operator behind a trusted-server or dashboard call. +type Actor struct{ ID string } + +// --------------------------------------------------------------------------- +// Freshness policy (OD-5) +// --------------------------------------------------------------------------- + +// Freshness defaults. `refreshAfter` asks a reader to refresh; `validUntil` +// ends authoritative validity; `staleGraceSeconds` is the bounded window past +// validUntil in which a reader may keep serving previously active Entitlements +// while clearly marking them stale. +// +// The contract caps the combined horizon — (validUntil - issuedAt) plus the +// grace window — at thirty days. That ceiling is enforced in Go as well as +// documented, because it is the difference between a bounded offline grace and +// an entitlement that never expires on a device that never reconnects. +const ( + DefaultRefreshAfter = time.Hour + DefaultValidFor = 7 * 24 * time.Hour + DefaultStaleGraceWindow = 24 * time.Hour + MaxCombinedHorizon = 30 * 24 * time.Hour +) + +// Freshness is the per-Environment freshness policy applied to a served +// snapshot. +type Freshness struct { + RefreshAfter time.Duration + ValidFor time.Duration + StaleGrace time.Duration +} + +// DefaultFreshness is the shipped policy. +func DefaultFreshness() Freshness { + return Freshness{ + RefreshAfter: DefaultRefreshAfter, + ValidFor: DefaultValidFor, + StaleGrace: DefaultStaleGraceWindow, + } +} + +// Bounded clamps a policy into the contract's admissible range. It is applied +// on every serialization rather than only at configuration time, so a value +// that reached storage before a bound existed still cannot be served. +func (f Freshness) Bounded() Freshness { + if f.RefreshAfter <= 0 { + f.RefreshAfter = DefaultRefreshAfter + } + if f.ValidFor <= 0 { + f.ValidFor = DefaultValidFor + } + if f.StaleGrace < 0 { + f.StaleGrace = 0 + } + if f.RefreshAfter > f.ValidFor { + f.RefreshAfter = f.ValidFor + } + if f.ValidFor+f.StaleGrace > MaxCombinedHorizon { + // The validity window is preserved and the grace window absorbs the + // overflow: shortening validity would expire a snapshot a reader is + // entitled to treat as authoritative, whereas shortening grace only + // removes a degraded-mode allowance. + f.StaleGrace = MaxCombinedHorizon - f.ValidFor + if f.StaleGrace < 0 { + f.ValidFor, f.StaleGrace = MaxCombinedHorizon, 0 + } + } + return f +} diff --git a/apps/api/internal/billingaccess/repository.go b/apps/api/internal/billingaccess/repository.go new file mode 100644 index 00000000..49cd9350 --- /dev/null +++ b/apps/api/internal/billingaccess/repository.go @@ -0,0 +1,67 @@ +package billingaccess + +import ( + "context" + "time" +) + +// KeyAuthenticator authenticates an API key into a tenant. It is a port rather +// than a dependency on the ingestion service, because a read surface must not +// be able to reach the write path just to find out who is calling. +type KeyAuthenticator interface { + // AuthenticateServerKey resolves a secret server key. This is the second + // consumer of that authentication, after trusted-server observations. + AuthenticateServerKey(ctx context.Context, raw string) (KeyScope, error) + // AuthenticateSDKKey resolves a public SDK key. A public key proves which + // Environment and Application are calling and nothing else: it can never, + // by itself, select a customer. + AuthenticateSDKKey(ctx context.Context, raw string) (KeyScope, error) +} + +// Repository is the persistence port for the access surfaces. +// +// Every read here is a read of committed state. There is no method that +// projects, derives, or repairs: an access surface that could derive would +// eventually derive something different from the projection, and the two +// answers would both be authoritative. +type Repository interface { + BillingEnabled(ctx context.Context, projectID string) (bool, error) + + // --- Customer Access Tokens --------------------------------------------- + + // CreateToken stores a token's digest and scope columns, and writes the + // issuance audit event in the same transaction. The token value itself is + // never passed here: the caller hands over its digest. + CreateToken(ctx context.Context, token Token, digest []byte, actorReference string) (Token, error) + // TokenByDigest resolves a presented token. It returns ErrUnauthenticated + // for an unknown digest so a caller cannot distinguish "no such token" from + // "wrong token". + TokenByDigest(ctx context.Context, digest []byte) (Token, error) + // TouchToken records last use. It is best-effort by design: a failure to + // record a diagnostic must never fail an entitlement read. + TouchToken(ctx context.Context, tokenID string, at time.Time) error + RevokeToken(ctx context.Context, scope KeyScope, tokenID, reason, actorReference string, at time.Time) (Token, error) + ListTokens(ctx context.Context, scope KeyScope, customerID string, limit int) ([]Token, error) + + // --- Committed projections ---------------------------------------------- + + // CurrentSnapshot reads the customer's current committed snapshot in one + // Environment, with its entries and sources. It returns ErrNotFound when the + // customer has never been projected there — which is a different answer from + // "has nothing", and the caller must keep it different. + CurrentSnapshot(ctx context.Context, projectID, environmentID, customerID string) (SnapshotView, error) + // ProjectionStatusFor reports projection health for one customer, including + // how many validated facts are waiting. + ProjectionStatusFor(ctx context.Context, projectID, environmentID, customerID string) (ProjectionStatus, error) + + Customer(ctx context.Context, projectID, customerID string) (CustomerView, error) + // CreateOrGetCustomerForApplicationUser is the trusted identify path, + // delegated to the identity service and exposed here so the access API has + // one repository. + Subscriptions(ctx context.Context, projectID, environmentID, customerID string, limit int, cursor string) ([]SubscriptionView, string, error) + Subscription(ctx context.Context, projectID, instanceID string) (SubscriptionView, error) + Timeline(ctx context.Context, projectID, instanceID string, limit int, cursor string) ([]TimelineEntry, string, error) + + // RecordAudit writes an audit event for a sensitive read or mutation. + RecordAudit(ctx context.Context, projectID, environmentID, actorReference, action, resourceType, resourceID string, metadata map[string]string, at time.Time) error +} diff --git a/apps/api/internal/billingaccess/service.go b/apps/api/internal/billingaccess/service.go new file mode 100644 index 00000000..4e3856cc --- /dev/null +++ b/apps/api/internal/billingaccess/service.go @@ -0,0 +1,453 @@ +package billingaccess + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "strings" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" +) + +// Service is the access application service. It owns every authorization +// decision on the read surfaces, the token lifecycle, and the freshness policy +// applied to a served snapshot. Handlers are thin wrappers over it. +type Service struct { + repository Repository + keys KeyAuthenticator + now func() time.Time + random io.Reader + tracer trace.Tracer + issuer string + freshness Freshness + + tokensIssued metric.Int64Counter + tokenFailures metric.Int64Counter + syncResults metric.Int64Counter + syncLatency metric.Float64Histogram +} + +type Option func(*Service) + +func WithClock(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +func WithRandom(random io.Reader) Option { + return func(s *Service) { + if random != nil { + s.random = random + } + } +} + +// WithIssuer names the Mosaic installation on issued token metadata. It is an +// operator diagnostic for self-hosted deployments and is never used to decide +// anything. +func WithIssuer(issuer string) Option { + return func(s *Service) { + if issuer = strings.TrimSpace(issuer); issuer != "" { + s.issuer = issuer + } + } +} + +func WithFreshness(freshness Freshness) Option { + return func(s *Service) { s.freshness = freshness.Bounded() } +} + +func NewService(repository Repository, keys KeyAuthenticator, options ...Option) *Service { + meter := otel.Meter("mosaic/billingaccess") + service := &Service{ + repository: repository, + keys: keys, + now: func() time.Time { return time.Now().UTC() }, + random: rand.Reader, + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/billingaccess"), + issuer: "mosaic", + freshness: DefaultFreshness(), + } + service.tokensIssued, _ = meter.Int64Counter("mosaic.billing.token.issued") + service.tokenFailures, _ = meter.Int64Counter("mosaic.billing.token.rejected") + service.syncResults, _ = meter.Int64Counter("mosaic.billing.sync.results") + service.syncLatency, _ = meter.Float64Histogram("mosaic.billing.sync.latency", + metric.WithUnit("ms")) + for _, option := range options { + option(service) + } + return service +} + +// --------------------------------------------------------------------------- +// Customer Access Tokens +// --------------------------------------------------------------------------- + +// IssueToken mints a Customer Access Token for a customer the calling backend +// has already authenticated. +// +// The tenant comes entirely from the authenticated secret server key. The +// request carries no Project and no Environment, so a compromised or careless +// caller cannot mint a token into a tenant it does not own — the scope is read +// from the key, never from the body. +// +// The returned value is the only time the credential exists outside the +// caller's process. It is never logged, never stored, and never returned again. +func (s *Service) IssueToken(ctx context.Context, rawKey string, request IssuanceRequest) (IssuedToken, error) { + ctx, span := s.tracer.Start(ctx, "billing.token.issue") + defer span.End() + + scope, err := s.keys.AuthenticateServerKey(ctx, rawKey) + if err != nil { + s.tokenFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("stage", "issue_auth"))) + return IssuedToken{}, ErrUnauthenticated + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return IssuedToken{}, err + } + + // `server_check` is declared by the contract and deliberately not issued in + // Phase 9B: no server-facing audience exists yet, and minting a credential + // for a surface that does not exist is a credential nobody can revoke on + // purpose. + if request.Audience != AudienceSDKSync { + return IssuedToken{}, ErrInvalid + } + scopes, ok := normalizeScopes(request.Scopes) + if !ok { + return IssuedToken{}, ErrInvalid + } + + customer, err := s.repository.Customer(ctx, scope.ProjectID, request.CustomerID) + if err != nil { + // A customer in another Project is reported as absent rather than + // forbidden: a caller must not be able to probe for the existence of + // another tenant's customers. + return IssuedToken{}, ErrNotFound + } + + value, digest, err := s.newTokenValue() + if err != nil { + return IssuedToken{}, err + } + id, err := s.newID("cat") + if err != nil { + return IssuedToken{}, err + } + + issuedAt := s.now() + token := Token{ + ID: id, + ProjectID: scope.ProjectID, + EnvironmentID: scope.EnvironmentID, + CustomerID: customer.ID, + Audience: request.Audience, + Scopes: scopes, + IssuedByAPIKeyID: scope.APIKeyID, + IssuedAt: issuedAt, + ExpiresAt: issuedAt.Add(clampTTL(request.RequestedTTLSecond)), + } + stored, err := s.repository.CreateToken(ctx, token, digest, scope.APIKeyID) + if err != nil { + return IssuedToken{}, err + } + + s.tokensIssued.Add(ctx, 1) + span.SetAttributes( + attribute.String("mosaic.billing.token.id", stored.ID), + attribute.String("mosaic.billing.token.audience", stored.Audience)) + // The token id is a public handle and safe to log. The value is not, and no + // branch of this method can reach a logger with it. + zerolog.Ctx(ctx).Info(). + Str("billing_token_id", stored.ID). + Str("project_id", stored.ProjectID). + Str("environment_id", stored.EnvironmentID). + Str("billing_customer_id", stored.CustomerID). + Msg("customer access token issued") + + return IssuedToken{Value: value, Metadata: stored}, nil +} + +// RevokeToken invalidates a token immediately. Revocation is one row update, +// which is the practical advantage of an opaque credential over a signed one: +// there is nothing to wait out. +func (s *Service) RevokeToken(ctx context.Context, rawKey, tokenID, reason string) (Token, error) { + ctx, span := s.tracer.Start(ctx, "billing.token.revoke") + defer span.End() + + scope, err := s.keys.AuthenticateServerKey(ctx, rawKey) + if err != nil { + return Token{}, ErrUnauthenticated + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return Token{}, err + } + if !validRevocationReason(reason) { + return Token{}, ErrInvalid + } + return s.repository.RevokeToken(ctx, scope, tokenID, reason, scope.APIKeyID, s.now()) +} + +// ListTokens reports token metadata for one customer. It never returns a token +// value, because Mosaic does not have one to return. +func (s *Service) ListTokens(ctx context.Context, rawKey, customerID string, limit int) ([]Token, error) { + scope, err := s.keys.AuthenticateServerKey(ctx, rawKey) + if err != nil { + return nil, ErrUnauthenticated + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return nil, err + } + return s.repository.ListTokens(ctx, scope, customerID, boundedLimit(limit)) +} + +// AuthenticatedToken is a validated presentation of a Customer Access Token. +type AuthenticatedToken struct { + Token Token + // SDKKey is the tenant the accompanying public SDK key resolved to. It must + // agree with the token's own scope. + SDKKey KeyScope +} + +// AuthenticateCustomerTokenForTenant validates a presented token against a +// tenant the caller has already authenticated by some other credential. +// +// It exists for the observation intake surfaces, where the accompanying +// credential is an API key that the ingestion module has already resolved to a +// Project and Environment — a public SDK key on the client endpoint, a secret +// server key on the trusted one. Routing those through +// AuthenticateCustomerToken would mean re-authenticating a key that is already +// authenticated, and would refuse the trusted endpoint outright, because a +// secret server key is not an SDK key. +// +// Every other check is the same one and for the same reason: expiry, +// revocation, and audience are re-validated on every presentation because the +// token is opaque and has no cached claim to go stale, and a token whose scope +// disagrees with the caller's is refused rather than answered, because that is +// either a misconfiguration or an attempt to write across the isolation +// boundary. +func (s *Service) AuthenticateCustomerTokenForTenant(ctx context.Context, rawToken, projectID, environmentID string) (Token, error) { + if !validTokenShape(rawToken) { + s.tokenFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("stage", "shape"))) + return Token{}, ErrUnauthenticated + } + sum := sha256.Sum256([]byte(rawToken)) + token, err := s.repository.TokenByDigest(ctx, sum[:]) + if err != nil { + s.tokenFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("stage", "lookup"))) + return Token{}, ErrUnauthenticated + } + now := s.now() + switch { + case token.Status(now) != TokenActive: + s.tokenFailures.Add(ctx, 1, metric.WithAttributes( + attribute.String("stage", "status"), attribute.String("status", token.Status(now)))) + return Token{}, ErrUnauthenticated + case token.Audience != AudienceSDKSync: + s.tokenFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("stage", "audience"))) + return Token{}, ErrUnauthenticated + case token.ProjectID != projectID || token.EnvironmentID != environmentID: + s.tokenFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("stage", "tenant_mismatch"))) + zerolog.Ctx(ctx).Warn(). + Str("billing_token_id", token.ID). + Str("token_environment_id", token.EnvironmentID). + Str("key_environment_id", environmentID). + Msg("customer access token presented with a key from another Environment") + return Token{}, ErrForbidden + } + return token, nil +} + +// AuthenticateCustomerToken validates a presented token against the public SDK +// key that accompanies it. +// +// Both credentials are required and both are checked. The token decides *which* +// customer is being read — a public SDK key can never select one — and the SDK +// key decides which Environment is asking. If they disagree, the request is +// refused: a token minted for one Environment presented alongside another +// Environment's key is either a misconfiguration or an attempt to read across +// the isolation boundary, and neither deserves an answer. +// +// Expiry, revocation, and audience are re-checked on every request rather than +// at issuance only. That is the entire reason the token is opaque: there is no +// cached claim to go stale. +func (s *Service) AuthenticateCustomerToken(ctx context.Context, rawToken, rawSDKKey string) (AuthenticatedToken, error) { + sdkScope, err := s.keys.AuthenticateSDKKey(ctx, rawSDKKey) + if err != nil { + s.tokenFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("stage", "sdk_key"))) + return AuthenticatedToken{}, ErrUnauthenticated + } + if !validTokenShape(rawToken) { + s.tokenFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("stage", "shape"))) + return AuthenticatedToken{}, ErrUnauthenticated + } + sum := sha256.Sum256([]byte(rawToken)) + token, err := s.repository.TokenByDigest(ctx, sum[:]) + if err != nil { + s.tokenFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("stage", "lookup"))) + return AuthenticatedToken{}, ErrUnauthenticated + } + + now := s.now() + switch { + case token.Status(now) != TokenActive: + s.tokenFailures.Add(ctx, 1, metric.WithAttributes( + attribute.String("stage", "status"), attribute.String("status", token.Status(now)))) + return AuthenticatedToken{}, ErrUnauthenticated + case token.Audience != AudienceSDKSync: + s.tokenFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("stage", "audience"))) + return AuthenticatedToken{}, ErrUnauthenticated + case token.ProjectID != sdkScope.ProjectID || token.EnvironmentID != sdkScope.EnvironmentID: + // Cross-tenant presentation. Counted separately because a rise in this + // number is a security signal, not a client bug. + s.tokenFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("stage", "tenant_mismatch"))) + zerolog.Ctx(ctx).Warn(). + Str("billing_token_id", token.ID). + Str("token_environment_id", token.EnvironmentID). + Str("key_environment_id", sdkScope.EnvironmentID). + Msg("customer access token presented with a key from another Environment") + return AuthenticatedToken{}, ErrForbidden + } + + // Best-effort: a diagnostic write must never fail an entitlement read. + if err := s.repository.TouchToken(ctx, token.ID, now); err != nil { + zerolog.Ctx(ctx).Debug().Str("billing_token_id", token.ID).Msg("token last-use not recorded") + } + return AuthenticatedToken{Token: token, SDKKey: sdkScope}, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// newTokenValue produces the opaque credential and its digest. The value has no +// internal structure: nothing may be inferred from it, and Mosaic keeps only +// the digest, so a database compromise yields no usable credential. +func (s *Service) newTokenValue() (string, []byte, error) { + buffer := make([]byte, TokenRandomBytes) + if _, err := io.ReadFull(s.random, buffer); err != nil { + return "", nil, fmt.Errorf("generate customer access token: %w", err) + } + value := TokenPrefix + base64.RawURLEncoding.EncodeToString(buffer) + digest := sha256.Sum256([]byte(value)) + return value, digest[:], nil +} + +func (s *Service) newID(prefix string) (string, error) { + buffer := make([]byte, 16) + if _, err := io.ReadFull(s.random, buffer); err != nil { + return "", fmt.Errorf("generate billing access identifier: %w", err) + } + return prefix + "_" + base64.RawURLEncoding.EncodeToString(buffer), nil +} + +// validTokenShape rejects a malformed presentation before it reaches the +// database. It is a cheap filter, not a security boundary: the digest lookup is +// the boundary. +func validTokenShape(value string) bool { + if len(value) != len(TokenPrefix)+43 || !strings.HasPrefix(value, TokenPrefix) { + return false + } + for _, char := range value[len(TokenPrefix):] { + switch { + case char >= 'A' && char <= 'Z', char >= 'a' && char <= 'z', + char >= '0' && char <= '9', char == '-', char == '_': + default: + return false + } + } + return true +} + +// clampTTL applies the contract's ceiling. A caller may shorten a token's life +// and can never lengthen it past the maximum; the schema enforces the same +// bound independently. +func clampTTL(requestedSeconds int) time.Duration { + if requestedSeconds <= 0 { + return DefaultTokenTTL + } + requested := time.Duration(requestedSeconds) * time.Second + if requested < MinTokenTTL { + return MinTokenTTL + } + if requested > MaxTokenTTL { + return MaxTokenTTL + } + return requested +} + +// normalizeScopes validates and canonicalizes the requested scopes. An empty +// request gets the least a token can carry. +func normalizeScopes(requested []string) ([]string, bool) { + if len(requested) == 0 { + return []string{ScopeEntitlementsRead}, true + } + if len(requested) > 3 { + return nil, false + } + seen := map[string]bool{} + result := make([]string, 0, len(requested)) + for _, scope := range requested { + switch scope { + case ScopeEntitlementsRead, ScopeEntitlementsSync, ScopeRestoreRequest: + default: + return nil, false + } + if seen[scope] { + return nil, false + } + seen[scope] = true + result = append(result, scope) + } + return result, true +} + +func validRevocationReason(reason string) bool { + switch reason { + case RevokedCustomerSignedOut, RevokedIdentityChanged, RevokedOperator, + RevokedCustomerDeleted, RevokedKeyRotated, RevokedSuspectedCompromise, + RevokedSuperseded: + return true + default: + return false + } +} + +func boundedLimit(limit int) int { + if limit <= 0 { + return 50 + } + if limit > 200 { + return 200 + } + return limit +} + +// requireEnabled fails closed, matching the ingestion and projection paths: an +// unreadable setting is treated as disabled, so a transient database error +// cannot quietly re-enable a Project that asked Mosaic to hold no billing state. +func (s *Service) requireEnabled(ctx context.Context, projectID string) error { + enabled, err := s.repository.BillingEnabled(ctx, projectID) + if err != nil { + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("billing_error_kind", fmt.Sprintf("%T", err)). + Msg("billing enablement could not be read; treating the Project as disabled") + return ErrBillingDisabled + } + if !enabled { + return ErrBillingDisabled + } + return nil +} diff --git a/apps/api/internal/billingaccess/service_test.go b/apps/api/internal/billingaccess/service_test.go new file mode 100644 index 00000000..904f0a0a --- /dev/null +++ b/apps/api/internal/billingaccess/service_test.go @@ -0,0 +1,473 @@ +package billingaccess + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "strings" + "testing" + "time" +) + +// These tests cover the properties whose failure is a security or correctness +// incident rather than a bug report: a token that reaches another tenant, a +// credential that outlives its revocation, a cached snapshot confirmed without +// proof it is still current, and billing being disabled reported as loss of +// access. + +type fakeRepository struct { + enabled map[string]bool + tokens map[string]Token + digests map[string]string + customers map[string]CustomerView + snapshots map[string]SnapshotView + touched int +} + +func newFakeRepository() *fakeRepository { + return &fakeRepository{ + enabled: map[string]bool{"proj_1": true, "proj_2": true}, + tokens: map[string]Token{}, + digests: map[string]string{}, + customers: map[string]CustomerView{"bcu_1": {ID: "bcu_1", ProjectID: "proj_1"}}, + snapshots: map[string]SnapshotView{}, + } +} + +func (f *fakeRepository) BillingEnabled(_ context.Context, projectID string) (bool, error) { + return f.enabled[projectID], nil +} + +func (f *fakeRepository) CreateToken(_ context.Context, token Token, digest []byte, _ string) (Token, error) { + f.tokens[token.ID] = token + f.digests[string(digest)] = token.ID + return token, nil +} + +func (f *fakeRepository) TokenByDigest(_ context.Context, digest []byte) (Token, error) { + id, ok := f.digests[string(digest)] + if !ok { + return Token{}, ErrUnauthenticated + } + return f.tokens[id], nil +} + +func (f *fakeRepository) TouchToken(_ context.Context, _ string, _ time.Time) error { + f.touched++ + return nil +} + +func (f *fakeRepository) RevokeToken(_ context.Context, scope KeyScope, tokenID, reason, _ string, at time.Time) (Token, error) { + token, ok := f.tokens[tokenID] + if !ok || token.ProjectID != scope.ProjectID || token.EnvironmentID != scope.EnvironmentID { + return Token{}, ErrNotFound + } + revoked := at + token.RevokedAt, token.RevocationReason = &revoked, reason + f.tokens[tokenID] = token + return token, nil +} + +func (f *fakeRepository) ListTokens(_ context.Context, _ KeyScope, _ string, _ int) ([]Token, error) { + return nil, nil +} + +func (f *fakeRepository) CurrentSnapshot(_ context.Context, _, environmentID, customerID string) (SnapshotView, error) { + view, ok := f.snapshots[environmentID+"/"+customerID] + if !ok { + return SnapshotView{}, ErrNotFound + } + return view, nil +} + +func (f *fakeRepository) ProjectionStatusFor(_ context.Context, _, _, _ string) (ProjectionStatus, error) { + return ProjectionStatus{State: ProjectionCurrent, LastProjectedAt: instant("2026-07-28T11:59:58Z")}, nil +} + +func (f *fakeRepository) Customer(_ context.Context, projectID, customerID string) (CustomerView, error) { + view, ok := f.customers[customerID] + if !ok || view.ProjectID != projectID { + return CustomerView{}, ErrNotFound + } + return view, nil +} + +func (f *fakeRepository) Subscriptions(context.Context, string, string, string, int, string) ([]SubscriptionView, string, error) { + return nil, "", nil +} + +func (f *fakeRepository) Subscription(context.Context, string, string) (SubscriptionView, error) { + return SubscriptionView{}, ErrNotFound +} + +func (f *fakeRepository) Timeline(context.Context, string, string, int, string) ([]TimelineEntry, string, error) { + return nil, "", nil +} + +func (f *fakeRepository) RecordAudit(context.Context, string, string, string, string, string, string, map[string]string, time.Time) error { + return nil +} + +type fakeKeys struct { + server map[string]KeyScope + sdk map[string]KeyScope +} + +func (f fakeKeys) AuthenticateServerKey(_ context.Context, raw string) (KeyScope, error) { + scope, ok := f.server[raw] + if !ok { + return KeyScope{}, ErrUnauthenticated + } + return scope, nil +} + +func (f fakeKeys) AuthenticateSDKKey(_ context.Context, raw string) (KeyScope, error) { + scope, ok := f.sdk[raw] + if !ok { + return KeyScope{}, ErrUnauthenticated + } + return scope, nil +} + +// countingReader produces deterministic bytes so a test can assert on token +// shape without asserting on a specific secret. +type countingReader struct{ n byte } + +func (c *countingReader) Read(buffer []byte) (int, error) { + for index := range buffer { + c.n++ + buffer[index] = c.n + } + return len(buffer), nil +} + +func testService(t *testing.T, repository *fakeRepository, now time.Time) *Service { + t.Helper() + keys := fakeKeys{ + server: map[string]KeyScope{ + "sk.one": {APIKeyID: "key_1", ProjectID: "proj_1", EnvironmentID: "env_1"}, + "sk.two": {APIKeyID: "key_2", ProjectID: "proj_2", EnvironmentID: "env_2"}, + }, + sdk: map[string]KeyScope{ + "pk.one": {APIKeyID: "key_3", ProjectID: "proj_1", EnvironmentID: "env_1", ApplicationID: "app_1"}, + "pk.two": {APIKeyID: "key_4", ProjectID: "proj_1", EnvironmentID: "env_2", ApplicationID: "app_2"}, + }, + } + return NewService(repository, keys, + WithClock(func() time.Time { return now }), + WithRandom(&countingReader{})) +} + +func issue(t *testing.T, service *Service, ttlSeconds int) IssuedToken { + t.Helper() + issued, err := service.IssueToken(context.Background(), "sk.one", IssuanceRequest{ + CustomerID: "bcu_1", Audience: AudienceSDKSync, + Scopes: []string{ScopeEntitlementsRead}, RequestedTTLSecond: ttlSeconds, + CorrelationID: "corr-1", + }) + if err != nil { + t.Fatalf("issue token: %v", err) + } + return issued +} + +// The token is opaque and bounded: prefixed, fixed length, and stored only as a +// digest. A token Mosaic could reproduce would be a token a database compromise +// hands to an attacker. +func TestIssuedTokenIsOpaqueAndStoredOnlyAsADigest(t *testing.T) { + repository := newFakeRepository() + service := testService(t, repository, instant("2026-07-28T12:00:00Z")) + issued := issue(t, service, 0) + + if !strings.HasPrefix(issued.Value, TokenPrefix) || len(issued.Value) != len(TokenPrefix)+43 { + t.Fatalf("token value %q does not match the contract shape", issued.Value) + } + sum := sha256.Sum256([]byte(issued.Value)) + if _, ok := repository.digests[string(sum[:])]; !ok { + t.Fatal("the token digest was not stored") + } + for _, stored := range repository.tokens { + encoded, _ := json.Marshal(stored) + if bytes.Contains(encoded, []byte(issued.Value)) { + t.Fatal("the token value was persisted alongside its digest") + } + } + if !issued.Metadata.ExpiresAt.After(issued.Metadata.IssuedAt) { + t.Fatal("the issued token does not expire") + } +} + +// A caller may shorten a token's life and can never lengthen it past the +// contract maximum. +func TestTokenLifetimeIsClamped(t *testing.T) { + repository := newFakeRepository() + now := instant("2026-07-28T12:00:00Z") + service := testService(t, repository, now) + + if got := issue(t, service, 0).Metadata.ExpiresAt.Sub(now); got != DefaultTokenTTL { + t.Fatalf("default lifetime %v, want %v", got, DefaultTokenTTL) + } + if got := issue(t, service, 300).Metadata.ExpiresAt.Sub(now); got != 5*time.Minute { + t.Fatalf("shortened lifetime %v, want 5m", got) + } + if got := issue(t, service, 999999).Metadata.ExpiresAt.Sub(now); got != MaxTokenTTL { + t.Fatalf("lifetime %v exceeds the contract maximum %v", got, MaxTokenTTL) + } +} + +// A token minted for one Environment must be refused when presented alongside +// another Environment's SDK key. Without this check the token, not the key, +// would be the only thing standing between a sandbox client and production +// entitlement state. +func TestTokenCannotCrossEnvironment(t *testing.T) { + repository := newFakeRepository() + service := testService(t, repository, instant("2026-07-28T12:00:00Z")) + issued := issue(t, service, 0) + + if _, err := service.AuthenticateCustomerToken(context.Background(), issued.Value, "pk.one"); err != nil { + t.Fatalf("a matching key pair was refused: %v", err) + } + if _, err := service.AuthenticateCustomerToken(context.Background(), issued.Value, "pk.two"); err != ErrForbidden { + t.Fatalf("a token presented with another Environment's key returned %v, want forbidden", err) + } + if _, err := service.AuthenticateCustomerToken(context.Background(), issued.Value, "pk.unknown"); err != ErrUnauthenticated { + t.Fatalf("an unknown SDK key returned %v, want unauthenticated", err) + } +} + +// Revocation takes effect on the next presentation, and expiry is evaluated on +// every request rather than trusted from issuance. +func TestRevokedAndExpiredTokensAreRefused(t *testing.T) { + repository := newFakeRepository() + now := instant("2026-07-28T12:00:00Z") + service := testService(t, repository, now) + issued := issue(t, service, 0) + + if _, err := service.RevokeToken(context.Background(), "sk.one", issued.Metadata.ID, RevokedCustomerSignedOut); err != nil { + t.Fatalf("revoke: %v", err) + } + if _, err := service.AuthenticateCustomerToken(context.Background(), issued.Value, "pk.one"); err != ErrUnauthenticated { + t.Fatalf("a revoked token returned %v, want unauthenticated", err) + } + + fresh := newFakeRepository() + freshService := testService(t, fresh, now) + freshToken := issue(t, freshService, 0) + later := NewService(fresh, fakeKeys{ + server: map[string]KeyScope{"sk.one": {ProjectID: "proj_1", EnvironmentID: "env_1"}}, + sdk: map[string]KeyScope{"pk.one": {ProjectID: "proj_1", EnvironmentID: "env_1", ApplicationID: "app_1"}}, + }, WithClock(func() time.Time { return now.Add(2 * time.Hour) })) + if _, err := later.AuthenticateCustomerToken(context.Background(), freshToken.Value, "pk.one"); err != ErrUnauthenticated { + t.Fatalf("an expired token returned %v, want unauthenticated", err) + } + + // A token from another Project cannot be revoked through this key. + other := issue(t, service, 0) + if _, err := service.RevokeToken(context.Background(), "sk.two", other.Metadata.ID, RevokedOperator); err != ErrNotFound { + t.Fatalf("cross-tenant revocation returned %v, want not found", err) + } +} + +func seedSnapshot(repository *fakeRepository) { + view := sampleView() + repository.snapshots["env_1/bcu_1"] = view +} + +// A conditional sync is answered `unchanged` only when the version matches. The +// entity tag alone is an opaque equality token with no ordering, so confirming +// on it without the version would confirm a cache whose monotonicity nobody +// checked. +func TestConditionalSyncRequiresVersionMatchAndSlidesFreshness(t *testing.T) { + repository := newFakeRepository() + seedSnapshot(repository) + now := instant("2026-07-28T12:00:00Z") + service := testService(t, repository, now) + issued := issue(t, service, 0) + authenticated, err := service.AuthenticateCustomerToken(context.Background(), issued.Value, "pk.one") + if err != nil { + t.Fatal(err) + } + + full, err := service.Sync(context.Background(), authenticated, SyncRequest{CorrelationID: "corr-1"}) + if err != nil { + t.Fatal(err) + } + if full.Unchanged { + t.Fatal("a first sync was answered as unchanged") + } + + conditional, err := service.Sync(context.Background(), authenticated, SyncRequest{ + KnownSnapshotVersion: 4, EntityTag: full.EntityTag, CorrelationID: "corr-1", + }) + if err != nil { + t.Fatal(err) + } + if !conditional.Unchanged { + t.Fatal("a matching version and tag were not answered as unchanged") + } + // The confirmed snapshot's window is refreshed, so a device that keeps + // confirming the same version never expires while it is in contact. + if !conditional.ValidUntil.After(now) || !conditional.RefreshAfter.After(now) { + t.Fatal("a 304 did not slide the freshness window") + } + + stale, err := service.Sync(context.Background(), authenticated, SyncRequest{ + KnownSnapshotVersion: 3, EntityTag: full.EntityTag, CorrelationID: "corr-1", + }) + if err != nil { + t.Fatal(err) + } + if stale.Unchanged { + t.Fatal("an older known version was answered as unchanged") + } +} + +// The token decides which customer is read. A body hint naming a different one +// is refused rather than ignored: silently ignoring it would let a client +// believe it had read a customer it had not. +func TestSyncRefusesACustomerHintThatDisagreesWithTheToken(t *testing.T) { + repository := newFakeRepository() + seedSnapshot(repository) + service := testService(t, repository, instant("2026-07-28T12:00:00Z")) + issued := issue(t, service, 0) + authenticated, _ := service.AuthenticateCustomerToken(context.Background(), issued.Value, "pk.one") + + if _, err := service.Sync(context.Background(), authenticated, SyncRequest{ + CustomerIDHint: "bcu_other", CorrelationID: "corr-1", + }); err != ErrForbidden { + t.Fatalf("a mismatched customer hint returned %v, want forbidden", err) + } +} + +// Billing being disabled is a statement about Mosaic, not about the customer. +// Every key must come back `unavailable` with a reason, never `inactive`. +func TestBillingDisabledReportsUnavailableNotInactive(t *testing.T) { + repository := newFakeRepository() + seedSnapshot(repository) + service := testService(t, repository, instant("2026-07-28T12:00:00Z")) + repository.enabled["proj_1"] = false + + payload, err := service.Check(context.Background(), "sk.one", "env_1", CheckRequest{ + CustomerID: "bcu_1", EntitlementKeys: []string{"pro"}, CorrelationID: "corr-1", + }) + if err != nil { + t.Fatalf("a disabled Project produced an error instead of a contract answer: %v", err) + } + + var envelope struct { + RecordType string `json:"recordType"` + Payload struct { + Results []struct { + EntitlementKey string `json:"entitlementKey"` + State string `json:"state"` + PrimaryExplanation struct { + Code string `json:"code"` + } `json:"primaryExplanation"` + Uncertainty struct { + Reason string `json:"reason"` + Since string `json:"since"` + } `json:"uncertainty"` + } `json:"results"` + } `json:"payload"` + } + if err := json.Unmarshal(payload, &envelope); err != nil { + t.Fatal(err) + } + if envelope.RecordType != "entitlementCheckResult" || len(envelope.Payload.Results) != 1 { + t.Fatalf("unexpected record: %s", payload) + } + result := envelope.Payload.Results[0] + if result.State != "unavailable" { + t.Fatalf("billing disabled reported state %q, want unavailable", result.State) + } + if result.PrimaryExplanation.Code != "billing_disabled" { + t.Fatalf("explanation %q, want billing_disabled", result.PrimaryExplanation.Code) + } + if result.Uncertainty.Reason == "none" || result.Uncertainty.Since == "" { + t.Fatal("an unavailable result carried no definite uncertainty") + } +} + +// A customer with no committed projection in an Environment is `unknown`, not +// `inactive`, and the sync surface answers with a readable snapshot rather than +// an error the SDK would have to interpret. +func TestNeverProjectedCustomerSyncsWithoutClaimingLossOfAccess(t *testing.T) { + repository := newFakeRepository() + service := testService(t, repository, instant("2026-07-28T12:00:00Z")) + issued := issue(t, service, 0) + authenticated, _ := service.AuthenticateCustomerToken(context.Background(), issued.Value, "pk.one") + + result, err := service.Sync(context.Background(), authenticated, SyncRequest{CorrelationID: "corr-1"}) + if err != nil { + t.Fatalf("a never-projected customer failed to sync: %v", err) + } + var envelope struct { + RecordType string `json:"recordType"` + Payload struct { + SnapshotID string `json:"snapshotId"` + SnapshotVersion int64 `json:"snapshotVersion"` + Entries []any `json:"entries"` + ProjectionStatus struct { + State string `json:"state"` + } `json:"projectionStatus"` + } `json:"payload"` + } + if err := json.Unmarshal(result.Payload, &envelope); err != nil { + t.Fatal(err) + } + if envelope.RecordType != "customerEntitlementSnapshot" { + t.Fatalf("record type %q", envelope.RecordType) + } + if len(envelope.Payload.Entries) != 0 { + t.Fatal("a never-projected customer was given Entitlement entries") + } + if envelope.Payload.ProjectionStatus.State != ProjectionPending { + t.Fatalf("projection status %q, want pending", envelope.Payload.ProjectionStatus.State) + } + // Version 0 is the "nothing has ever been committed" sentinel. Numbering the + // placeholder 1 would collide with the first genuine projection, which is + // also 1. + if envelope.Payload.SnapshotVersion != 0 { + t.Fatalf("the placeholder snapshot claimed version %d, want 0", + envelope.Payload.SnapshotVersion) + } + if envelope.Payload.SnapshotID != "pending.bcu_1" { + t.Fatalf("placeholder snapshot id %q", envelope.Payload.SnapshotID) + } + + // Cross-step: once the customer is projected for the first time, the real + // snapshot must be strictly newer than the placeholder the device cached. + // A device comparing versions only advances when this holds. + repository.snapshots["env_1/bcu_1"] = SnapshotView{ + SnapshotID: "ces_first", ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1", + SnapshotVersion: 1, RuleVersion: 1, + ComputedAt: instant("2026-07-28T11:59:59Z"), + AsOf: instant("2026-07-28T11:59:59Z"), + ChangeReason: "initial_projection", + Projection: ProjectionStatus{State: ProjectionCurrent, LastProjectedAt: instant("2026-07-28T11:59:59Z")}, + } + projected, err := service.Sync(context.Background(), authenticated, SyncRequest{ + KnownSnapshotVersion: envelope.Payload.SnapshotVersion, + EntityTag: result.EntityTag, + CorrelationID: "corr-1", + }) + if err != nil { + t.Fatalf("the first real projection failed to sync: %v", err) + } + if projected.Unchanged { + t.Fatal("the first real snapshot was answered as unchanged against the placeholder") + } + var after struct { + Payload struct { + SnapshotVersion int64 `json:"snapshotVersion"` + } `json:"payload"` + } + if err := json.Unmarshal(projected.Payload, &after); err != nil { + t.Fatal(err) + } + if after.Payload.SnapshotVersion <= envelope.Payload.SnapshotVersion { + t.Fatalf("the first real snapshot is version %d, not strictly newer than the placeholder's %d", + after.Payload.SnapshotVersion, envelope.Payload.SnapshotVersion) + } +} diff --git a/apps/api/internal/billingaccess/snapshot.go b/apps/api/internal/billingaccess/snapshot.go new file mode 100644 index 00000000..97c80d16 --- /dev/null +++ b/apps/api/internal/billingaccess/snapshot.go @@ -0,0 +1,165 @@ +package billingaccess + +import "time" + +// This file holds the read model: what the repository returns when it reads a +// committed projection. It is deliberately separate from the wire records in +// wire.go, because a database row and a contract record are two different +// things and letting one be the other is how a schema change becomes a +// breaking API change. + +// Projection status states, matching the contract's closed vocabulary. +const ( + ProjectionCurrent = "current" + ProjectionPending = "pending" + ProjectionStale = "stale" + ProjectionDegraded = "degraded" + ProjectionFailed = "failed" +) + +// StaleAfter is how long a customer's last projection may be older than the +// newest fact awaiting projection before the status is reported `stale` rather +// than `pending`. It is a reporting threshold only: a stale projection still +// serves its last committed snapshot, because the alternative is telling a +// paying customer they have no access while Mosaic catches up. +const StaleAfter = 15 * time.Minute + +// ProjectionStatus is the health of the projection behind a served record. +type ProjectionStatus struct { + State string + LastProjectedAt time.Time + PendingFactCount int + DiagnosticCode string +} + +// SnapshotView is one committed Customer Entitlement Snapshot as read. +type SnapshotView struct { + SnapshotID string + ProjectID string + EnvironmentID string + CustomerID string + SnapshotVersion int64 + PreviousSnapshotVersion int64 + RuleVersion int + ComputedAt time.Time + AsOf time.Time + Checksum []byte + ChangeReason string + Entries []SnapshotEntry + Sources []SnapshotSource + Projection ProjectionStatus +} + +// SnapshotEntry is one Entitlement's committed state. +type SnapshotEntry struct { + EntitlementID string + EntitlementKey string + State string + EffectiveStart *time.Time + EffectiveEnd *time.Time + EndKnown bool + SourceCount int + UncertaintyReason string + IsTestSource bool + ExplanationCode string + // SourceIDs are the contributing sources, resolved against Sources. + SourceIDs []string +} + +// SnapshotSource is one reason the customer holds, or may hold, an Entitlement. +type SnapshotSource struct { + // RowID is the per-generation row identity. It is not the contract's + // sourceId, which must be stable across snapshots. + RowID string + EntitlementID string + PurchaseLineageID string + ProductID string + GrantVersionID string + SubscriptionInstanceID string + OneTimePurchaseInstanceID string + SourceSnapshotID string + StorePlatform string + SourceType string + SourceState string + SourceStart *time.Time + SourceEnd *time.Time + EndKnown bool + UncertaintyReason string + IsTestSource bool + ExplanationCode string +} + +// SubscriptionView is one projected Subscription Instance as read. +type SubscriptionView struct { + SnapshotID string + SubscriptionInstanceID string + PurchaseLineageID string + CustomerID string + ProjectID string + EnvironmentID string + ProjectionVersion int64 + RuleVersion int + ComputedAt time.Time + AsOf time.Time + StorePlatform string + ProductID string + PriorProductID string + AccessState string + LifecycleState string + RenewalIntent string + BillingState string + UncertaintyReason string + PeriodStart *time.Time + PeriodEnd *time.Time + GracePeriodEnd *time.Time + BillingRetryStart *time.Time + PauseEffectiveAt *time.Time + PauseResumeAt *time.Time + CancellationEffectiveAt *time.Time + ExpirationEffectiveAt *time.Time + RevocationEffectiveAt *time.Time + RefundEffectiveAt *time.Time + SupersededByInstanceID string + IsTestSource bool + SourceFactCount int + Checksum []byte + ChangeReason string + ExplanationCode string +} + +// TimelineEntry is one append-only explanation of a subscription transition. +type TimelineEntry struct { + ID string + EntryType string + EffectiveAt time.Time + ObservedAt time.Time + SubscriptionInstanceID string + OneTimeInstanceID string + ProductID string + PriorProductID string + ExplanationCode string + Detail map[string]string +} + +// CustomerView is a Billing Customer as the trusted-server API reports it. It +// deliberately carries no alias values: aliases are digests, and the digest is +// never a read-side field. +type CustomerView struct { + ID string + ProjectID string + Status string + DiagnosticsStatus string + CurrentProjectionVersion int64 + LastProjectedAt *time.Time + Identified bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// CheckRequest is the multi-key access question a trusted server asks. +type CheckRequest struct { + CustomerID string + EntitlementKeys []string + ExpectedSnapshotVersion int64 + CorrelationID string +} diff --git a/apps/api/internal/billingaccess/sync.go b/apps/api/internal/billingaccess/sync.go new file mode 100644 index 00000000..6f1e7999 --- /dev/null +++ b/apps/api/internal/billingaccess/sync.go @@ -0,0 +1,336 @@ +package billingaccess + +import ( + "context" + "errors" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// SyncRequest is the negotiated body of an entitlement sync. +type SyncRequest struct { + // CustomerIDHint is a hint only. The server derives the customer from the + // token and verifies this value against it; a mismatch is refused. A caller + // can never select a customer by asserting an identifier. + CustomerIDHint string + KnownSnapshotVersion int64 + EntityTag string + RequestedKeys []string + CorrelationID string +} + +// SyncResult is what the sync endpoint produced. +type SyncResult struct { + // Unchanged is true when the caller's cached snapshot is still current. The + // payload is then the snapshotUnchanged record and the transport answers + // 304 with the freshness headers. + Unchanged bool + // Payload is the record body, already in the contract's canonical + // serialization. + Payload []byte + // EntityTag is the strong validator for this representation. + EntityTag string + // RefreshAfter and ValidUntil accompany a 304, so a confirmed-current + // snapshot does not expire merely because it was confirmed instead of + // resent. + RefreshAfter time.Time + ValidUntil time.Time + StaleGrace time.Duration +} + +// Sync answers the SDK entitlement sync. +// +// Three properties are load-bearing: +// +// The customer comes from the token. A body hint is verified against it and +// never used to select. This is the whole reason the public SDK key alone is +// not sufficient authentication for this surface. +// +// Billing disabled is `unavailable`, never `inactive`. A Project that turned +// billing off has not told Mosaic its customers lost access. +// +// 304 slides freshness. A confirmed-current snapshot gets a fresh +// refreshAfter and validUntil, so a device that keeps confirming the same +// version never falls out of validity while it is demonstrably in contact +// with the server. +func (s *Service) Sync(ctx context.Context, authenticated AuthenticatedToken, request SyncRequest) (SyncResult, error) { + ctx, span := s.tracer.Start(ctx, "billing.entitlement.sync") + defer span.End() + started := s.now() + defer func() { + s.syncLatency.Record(ctx, float64(s.now().Sub(started).Milliseconds())) + }() + + token := authenticated.Token + if request.CustomerIDHint != "" && request.CustomerIDHint != token.CustomerID { + s.syncResults.Add(ctx, 1, metric.WithAttributes(attribute.String("result", "customer_mismatch"))) + return SyncResult{}, ErrForbidden + } + if !token.HasScope(ScopeEntitlementsRead) && !token.HasScope(ScopeEntitlementsSync) { + return SyncResult{}, ErrForbidden + } + if err := s.requireEnabled(ctx, token.ProjectID); err != nil { + s.syncResults.Add(ctx, 1, metric.WithAttributes(attribute.String("result", "unavailable"))) + return SyncResult{}, err + } + + view, err := s.repository.CurrentSnapshot(ctx, token.ProjectID, token.EnvironmentID, token.CustomerID) + if err != nil { + if errors.Is(err, ErrNotFound) { + // A customer who has never been projected in this Environment holds + // no snapshot. That is `unknown`, not `inactive`, and it is reported + // as an empty snapshot with a pending projection rather than as an + // error the SDK would have to interpret. + view = emptyView(token, s.now()) + } else { + s.syncResults.Add(ctx, 1, metric.WithAttributes(attribute.String("result", "error"))) + return SyncResult{}, err + } + } else { + status, statusErr := s.repository.ProjectionStatusFor(ctx, token.ProjectID, token.EnvironmentID, token.CustomerID) + if statusErr == nil { + view.Projection = status + } + } + + issuedAt := s.now() + entityTag := EntityTag(view) + + // The conditional answer requires both the version and the validator to + // match. The version is the monotonicity key; the entity tag is an opaque + // equality token. Matching on version alone would confirm a cache whose + // contents were rebuilt under a new rule version at the same version + // number. + if request.KnownSnapshotVersion > 0 && request.KnownSnapshotVersion == view.SnapshotVersion && + (request.EntityTag == "" || request.EntityTag == entityTag) && view.SnapshotVersion > 0 { + + record := UnchangedRecord(view, issuedAt, s.freshness, request.CorrelationID) + payload, encodeErr := CanonicalJSON(Envelope("snapshotUnchanged", record)) + if encodeErr != nil { + return SyncResult{}, encodeErr + } + bounded := s.freshness.Bounded() + s.syncResults.Add(ctx, 1, metric.WithAttributes(attribute.String("result", "unchanged"))) + span.SetAttributes(attribute.Bool("mosaic.billing.sync.unchanged", true)) + return SyncResult{ + Unchanged: true, Payload: payload, EntityTag: entityTag, + RefreshAfter: issuedAt.Add(bounded.RefreshAfter), + ValidUntil: issuedAt.Add(bounded.ValidFor), + StaleGrace: bounded.StaleGrace, + }, nil + } + + record, err := SnapshotRecord(view, issuedAt, s.freshness, request.CorrelationID, request.RequestedKeys) + if err != nil { + return SyncResult{}, err + } + payload, err := CanonicalJSON(Envelope("customerEntitlementSnapshot", record)) + if err != nil { + return SyncResult{}, err + } + bounded := s.freshness.Bounded() + s.syncResults.Add(ctx, 1, metric.WithAttributes(attribute.String("result", "snapshot"))) + span.SetAttributes( + attribute.Bool("mosaic.billing.sync.unchanged", false), + attribute.Int64("mosaic.billing.sync.version", view.SnapshotVersion)) + return SyncResult{ + Payload: payload, EntityTag: entityTag, + RefreshAfter: issuedAt.Add(bounded.RefreshAfter), + ValidUntil: issuedAt.Add(bounded.ValidFor), + StaleGrace: bounded.StaleGrace, + }, nil +} + +// emptyView is the representation of a customer with no committed projection in +// an Environment. Version 0 with no entries is deliberate: version 0 is the +// "no snapshot has ever been committed" sentinel, and real snapshots start at 1 +// (writeCustomerSnapshot increments from 0). Numbering the placeholder 1 would +// make the first genuine projection collide with it, so a device that cached +// the placeholder would treat the first real snapshot as not newer and keep an +// empty entitlement set. The snapshot is still structurally valid, carries a +// pending projection status, and says nothing about access — which is exactly +// the truth. +func emptyView(token Token, at time.Time) SnapshotView { + return SnapshotView{ + SnapshotID: "pending." + token.CustomerID, + ProjectID: token.ProjectID, + EnvironmentID: token.EnvironmentID, + CustomerID: token.CustomerID, + SnapshotVersion: 0, + RuleVersion: 1, + ComputedAt: at, + AsOf: at, + ChangeReason: "initial_projection", + Projection: ProjectionStatus{ + State: ProjectionPending, LastProjectedAt: at, PendingFactCount: 0, + }, + } +} + +// Check answers the trusted-server multi-key access question. +// +// The answer is never a bare boolean. Every key carries a state, an +// explanation, and the snapshot version and as-of instant it was derived from, +// so a caller that acts on it can say afterwards which state it acted on. +func (s *Service) Check(ctx context.Context, rawKey string, environmentID string, request CheckRequest) ([]byte, error) { + ctx, span := s.tracer.Start(ctx, "billing.entitlement.check") + defer span.End() + + scope, err := s.keys.AuthenticateServerKey(ctx, rawKey) + if err != nil { + return nil, ErrUnauthenticated + } + if environmentID == "" { + environmentID = scope.EnvironmentID + } + if environmentID != scope.EnvironmentID { + return nil, ErrForbidden + } + if len(request.EntitlementKeys) == 0 || len(request.EntitlementKeys) > 64 { + return nil, ErrInvalid + } + issuedAt := s.now() + + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + // Billing disabled is reported through the contract, not as an HTTP + // error: the caller asked a question Mosaic declines to answer, and + // every key comes back `unavailable` with the reason attached. + record := CheckResultRecord(request.CustomerID, scope.ProjectID, environmentID, nil, + request.EntitlementKeys, issuedAt, request.CorrelationID, + "provider_unavailable", "billing_disabled") + return CanonicalJSON(Envelope("entitlementCheckResult", record)) + } + + if _, err := s.repository.Customer(ctx, scope.ProjectID, request.CustomerID); err != nil { + return nil, ErrNotFound + } + + view, err := s.repository.CurrentSnapshot(ctx, scope.ProjectID, environmentID, request.CustomerID) + if err != nil { + if !errors.Is(err, ErrNotFound) { + return nil, err + } + // Never projected here. Not an error and not `inactive`: Mosaic has no + // answer yet. + record := CheckResultRecord(request.CustomerID, scope.ProjectID, environmentID, nil, + request.EntitlementKeys, issuedAt, request.CorrelationID, + "missing_fact", "no_qualifying_source") + return CanonicalJSON(Envelope("entitlementCheckResult", record)) + } + if status, statusErr := s.repository.ProjectionStatusFor(ctx, scope.ProjectID, environmentID, request.CustomerID); statusErr == nil { + view.Projection = status + } + + 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)) +} + +// Snapshot reads a customer's current snapshot for a trusted server. The read is +// audited: an operator credential reading a named customer's entitlement state +// is exactly the access a later investigation needs to be able to reconstruct. +func (s *Service) Snapshot(ctx context.Context, rawKey, environmentID, customerID, correlationID string) ([]byte, error) { + scope, err := s.keys.AuthenticateServerKey(ctx, rawKey) + if err != nil { + return nil, ErrUnauthenticated + } + if environmentID == "" { + environmentID = scope.EnvironmentID + } + if environmentID != scope.EnvironmentID { + return nil, ErrForbidden + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return nil, err + } + view, err := s.repository.CurrentSnapshot(ctx, scope.ProjectID, environmentID, customerID) + if err != nil { + return nil, err + } + if status, statusErr := s.repository.ProjectionStatusFor(ctx, scope.ProjectID, environmentID, customerID); statusErr == nil { + view.Projection = status + } + _ = s.repository.RecordAudit(ctx, scope.ProjectID, environmentID, scope.APIKeyID, + "billing.entitlement.snapshot_read", "billing_customer", customerID, nil, s.now()) + + record, err := SnapshotRecord(view, s.now(), s.freshness, correlationID, nil) + if err != nil { + return nil, err + } + return CanonicalJSON(Envelope("customerEntitlementSnapshot", record)) +} + +// Customer reads one Billing Customer directly. +func (s *Service) Customer(ctx context.Context, rawKey, customerID string) (CustomerView, error) { + scope, err := s.keys.AuthenticateServerKey(ctx, rawKey) + if err != nil { + return CustomerView{}, ErrUnauthenticated + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return CustomerView{}, err + } + return s.repository.Customer(ctx, scope.ProjectID, customerID) +} + +// Subscriptions lists a customer's projected subscriptions, keyset-paginated. +func (s *Service) Subscriptions(ctx context.Context, rawKey, environmentID, customerID string, limit int, cursor string) ([]SubscriptionView, string, error) { + scope, err := s.keys.AuthenticateServerKey(ctx, rawKey) + if err != nil { + return nil, "", ErrUnauthenticated + } + if environmentID == "" { + environmentID = scope.EnvironmentID + } + if environmentID != scope.EnvironmentID { + return nil, "", ErrForbidden + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return nil, "", err + } + return s.repository.Subscriptions(ctx, scope.ProjectID, environmentID, customerID, boundedLimit(limit), cursor) +} + +// Subscription reads one projected Subscription Instance as a contract record. +func (s *Service) Subscription(ctx context.Context, rawKey, instanceID, correlationID string) ([]byte, error) { + scope, err := s.keys.AuthenticateServerKey(ctx, rawKey) + if err != nil { + return nil, ErrUnauthenticated + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return nil, err + } + view, err := s.repository.Subscription(ctx, scope.ProjectID, instanceID) + if err != nil { + return nil, err + } + if view.EnvironmentID != scope.EnvironmentID { + return nil, ErrNotFound + } + record, err := SubscriptionRecord(view, correlationID) + if err != nil { + return nil, err + } + return CanonicalJSON(Envelope("subscriptionSnapshot", record)) +} + +// Timeline reads one Subscription Instance's append-only explanation history. +func (s *Service) Timeline(ctx context.Context, rawKey, instanceID string, limit int, cursor string) ([]TimelineEntry, string, error) { + scope, err := s.keys.AuthenticateServerKey(ctx, rawKey) + if err != nil { + return nil, "", ErrUnauthenticated + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return nil, "", err + } + view, err := s.repository.Subscription(ctx, scope.ProjectID, instanceID) + if err != nil { + return nil, "", err + } + if view.EnvironmentID != scope.EnvironmentID { + return nil, "", ErrNotFound + } + return s.repository.Timeline(ctx, scope.ProjectID, instanceID, boundedLimit(limit), cursor) +} diff --git a/apps/api/internal/billingaccess/wire.go b/apps/api/internal/billingaccess/wire.go new file mode 100644 index 00000000..c223e48c --- /dev/null +++ b/apps/api/internal/billingaccess/wire.go @@ -0,0 +1,728 @@ +package billingaccess + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// This file is the only place in Mosaic that produces Authoritative Entitlement +// Contract v1 records. Two decisions make it the only place: +// +// 1. Records are built as map[string]any rather than as tagged structs. The +// contract's canonical serialization must omit absent members and must +// never emit null, and a tagged struct with pointer fields makes "absent" +// and "null" one keystroke apart. A map cannot carry a member it was not +// given. +// 2. The response body IS the canonical serialization. An SDK recomputes +// contentDigest over what it received; if the body and the digested bytes +// were produced by two different code paths, a digest mismatch would be a +// serialization bug reported to users as cache corruption. + +// ContractTimestamp renders an instant in the contract's fixed form: RFC 3339 +// UTC with exactly three fractional digits and a literal Z. The precision is +// fixed rather than optional because the same instant at a different precision +// digests differently. +func ContractTimestamp(at time.Time) string { + return at.UTC().Format("2006-01-02T15:04:05.000Z") +} + +// CanonicalJSON renders the contract's canonical serialization: minified, keys +// ascending at every depth, array order preserved, absent members omitted. +// +// Go's encoder already sorts map keys and preserves slice order; HTML escaping +// is disabled because the contract requires minimal escaping and an escaped +// `&` would change the digest for a value no other implementation escapes. +func CanonicalJSON(value any) ([]byte, error) { + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return nil, fmt.Errorf("serialize contract record: %w", err) + } + return bytes.TrimRight(buffer.Bytes(), "\n"), nil +} + +// contentDigest is SHA-256 over the canonical serialization of a payload with +// the excluded member removed. It is corruption and binding detection, not +// authentication: it covers the customer, Project, Environment, and snapshot +// version, so a snapshot cannot be accepted into another customer's cache. +func contentDigest(payload map[string]any, excludedMember string) (string, error) { + reduced := make(map[string]any, len(payload)) + for key, value := range payload { + if key == excludedMember { + continue + } + reduced[key] = value + } + encoded, err := CanonicalJSON(reduced) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// Envelope wraps a payload in the contract's house envelope. +func Envelope(recordType string, payload map[string]any) map[string]any { + return map[string]any{ + "authoritativeEntitlementContractVersion": ContractVersion, + "recordType": recordType, + "payload": payload, + } +} + +// --------------------------------------------------------------------------- +// Vocabulary translation +// --------------------------------------------------------------------------- +// +// Phase 9B's storage vocabulary and the frozen contract vocabulary are not +// identical: the schema landed before the contracts froze, and the contract's +// enumerations are closed and over-provisioned in ways storage is not. The +// translation lives here, in one direction only (storage to wire), with an +// explicit default for every table. Anything not named falls back to the +// honest answer rather than to a guess. + +// wireSourceType maps a stored entitlement-source type onto the contract's +// closed sourceType vocabulary. +func wireSourceType(stored string) string { + switch stored { + case "verified_grace_period": + return "grace_period" + case "accepted_billing_retry": + return "billing_retry" + case "active_subscription", "trial", "one_time_non_consumable", "family_shared": + return stored + default: + // An unknown stored type is still a subscription source; reporting it + // as a one-time purchase would tell a reader it never expires. + return "active_subscription" + } +} + +// storedExplanations maps the projection engine's explanation codes onto the +// contract's closed explanationCode vocabulary. +var storedExplanations = map[string]string{ + "trial_active": "active_trial_period", + "verified_grace_period": "active_grace_period", + "billing_retry": "active_billing_retry_allowance", + "subscription_active": "active_subscription_period", + "subscription_trialing": "active_trial_period", + "subscription_grace_period": "active_grace_period", + "subscription_billing_retry": "active_billing_retry_allowance", + "subscription_paused": "subscription_paused", + "subscription_expired": "subscription_expired", + "subscription_revoked": "subscription_revoked", + "subscription_refunded": "subscription_refunded", + "subscription_superseded": "subscription_superseded", + "subscription_unknown": "no_qualifying_source", + "one_time_purchase_owned": "permanent_one_time_purchase", + "one_time_purchase_refunded": "subscription_refunded", + "one_time_purchase_revoked": "subscription_revoked", + "one_time_purchase_unknown": "no_qualifying_source", + "permanent_source_active": "permanent_one_time_purchase", + "no_active_source": "no_qualifying_source", + "unresolved_evidence": "identity_unresolved", + "family_shared": "family_shared_source", + "scheduled_pause_pending": "scheduled_pause_not_yet_effective", + "cancelled_access_until_end": "subscription_cancelled_access_until_period_end", + "grant_version_ended": "grant_version_ended", + "product_unresolved": "product_unresolved", + "conflicting_facts": "conflicting_facts", + "projection_failed": "projection_failed", + "provider_evidence_stale": "provider_evidence_stale", + "unsupported_provider_state": "unsupported_provider_state", +} + +// wireExplanation maps a stored explanation code onto the contract's closed +// vocabulary. An unmapped code becomes `no_qualifying_source`: a reader may +// render its own copy for a code it knows, but a producer must never invent +// one, and inventing one is exactly what passing an unmapped value through +// would do. +func wireExplanation(stored string, state string) string { + if mapped, ok := storedExplanations[stored]; ok { + return mapped + } + if state == "unknown" { + return "identity_unresolved" + } + return "no_qualifying_source" +} + +// wireUncertaintyForState refines a stored uncertainty reason so an +// uncertain state is always explainable. The contract requires that a +// non-definitive state carries a reason other than `none`; storage permits the +// combination, so it is repaired here rather than served. +func wireUncertaintyForState(state, reason string) string { + if state == "active" || state == "inactive" { + return reason + } + if reason == "" || reason == "none" { + return "missing_fact" + } + return reason +} + +// wireChangeReason maps the projection's stored change reason onto the +// contract's closed changeReason vocabulary. +func wireChangeReason(stored string, previousVersion int64) string { + switch stored { + case "initial_projection", "subscription_state_changed", "subscription_period_changed", + "renewal_intent_changed", "source_added", "source_ended", "refund_applied", + "revocation_applied", "grant_version_changed", "identity_changed", + "identity_conflict_opened", "identity_conflict_resolved", "projection_replayed", + "projection_rule_upgraded", "projection_recovered", "projection_failed", + "manual_reprojection": + return stored + } + if previousVersion == 0 { + return "initial_projection" + } + return "subscription_state_changed" +} + +// wireStorePlatform maps Mosaic's provider identifier onto the contract's +// storePlatform vocabulary. +func wireStorePlatform(provider string) string { + switch provider { + case "app_store", "apple_app_store": + return "apple_app_store" + default: + return "google_play" + } +} + +// SourceIdentity is the contract's stable source id, derived from (purchase +// lineage, Mosaic Product, grant version) and never from a fact identifier or a +// per-generation row id. +// +// It must be stable across snapshots: a reader resolves an entry's sourceIds +// against the sources array of the same snapshot, but an operator comparing two +// snapshots reads the same source under the same identity. The per-generation +// row id would change on every projection and make that comparison impossible. +func SourceIdentity(lineageID, productID, grantVersionID string) string { + sum := sha256.Sum256([]byte("mosaic-entitlement-source-v1\x00" + + lineageID + "\x00" + productID + "\x00" + grantVersionID)) + return "esrc." + hex.EncodeToString(sum[:16]) +} + +// EntityTag builds the opaque HTTP validator. It carries no ordering: a reader +// compares it for equality only, and snapshot monotonicity is decided by +// snapshotVersion alone. +func EntityTag(view SnapshotView) string { + sum := sha256.Sum256([]byte(view.CustomerID + "\x00" + view.EnvironmentID + "\x00" + + fmt.Sprint(view.SnapshotVersion) + "\x00" + hex.EncodeToString(view.Checksum))) + return "ces." + hex.EncodeToString(sum[:12]) +} + +// --------------------------------------------------------------------------- +// Record builders +// --------------------------------------------------------------------------- + +// projectionStatusRecord renders the projection health block. A degraded or +// failed projection does not make a snapshot unreadable; it makes the entries +// it could not determine unknown. +func projectionStatusRecord(status ProjectionStatus) map[string]any { + record := map[string]any{ + "state": status.State, + "lastProjectedAt": ContractTimestamp(status.LastProjectedAt), + } + if status.State == ProjectionPending { + record["pendingFactCount"] = status.PendingFactCount + } + if status.State == ProjectionDegraded || status.State == ProjectionFailed { + code := status.DiagnosticCode + if code == "" { + code = "entitlement.projection.degraded" + } + record["diagnosticCode"] = code + } + return record +} + +// uncertaintyRecord renders the uncertainty object. A definitive state carries +// no `since` instant; a non-definitive one always does. +func uncertaintyRecord(reason string, since time.Time, resolution string) map[string]any { + if reason == "" { + reason = "none" + } + record := map[string]any{"reason": reason} + if reason == "none" { + return record + } + record["since"] = ContractTimestamp(since) + if resolution != "" { + record["expectedResolution"] = resolution + } + return record +} + +// expectedResolutionFor is guidance for a reader deciding whether to retry, not +// a promise. +func expectedResolutionFor(reason string) string { + switch reason { + case "provider_unavailable", "stale_validation": + return "automatic_retry" + case "missing_fact": + return "next_provider_notification" + case "projection_failed": + return "next_projection_run" + case "identity_unresolved", "conflicting_facts", "product_unresolved": + return "operator_action" + case "unsupported_provider_state": + return "operator_action" + default: + return "" + } +} + +// sourceRecord renders one source summary. Mosaic Product and Subscription +// Instance identity live here and nowhere else, so two places cannot disagree +// when several sources grant one Entitlement. +func sourceRecord(source SnapshotSource, asOf time.Time) map[string]any { + sourceType := wireSourceType(source.SourceType) + record := map[string]any{ + "sourceId": SourceIdentity(source.PurchaseLineageID, source.ProductID, source.GrantVersionID), + "sourceType": sourceType, + "mosaicProductId": source.ProductID, + "grantVersionId": source.GrantVersionID, + "sourceState": wireSourceState(source.SourceState), + "explanationCode": wireExplanation(source.ExplanationCode, source.SourceState), + "isTestSource": source.IsTestSource, + } + // sourceSnapshotId names the projected state generation this source cites. + // A subscription source cites its Subscription Snapshot; a one-time source + // has no snapshot row of its own, so it cites the per-generation source row + // that recorded it, which is the same thing at a different granularity. + if source.SourceSnapshotID != "" { + record["sourceSnapshotId"] = source.SourceSnapshotID + } else { + record["sourceSnapshotId"] = source.RowID + } + if sourceType == "one_time_non_consumable" { + record["oneTimePurchaseInstanceId"] = source.OneTimePurchaseInstanceID + } else { + record["subscriptionInstanceId"] = source.SubscriptionInstanceID + } + if source.StorePlatform != "" { + record["storePlatform"] = wireStorePlatform(source.StorePlatform) + } + if source.SourceStart != nil { + record["start"] = ContractTimestamp(*source.SourceStart) + } else { + record["start"] = ContractTimestamp(asOf) + } + // An absent end means this source has no finite end Mosaic can state. For a + // permanent source that is a fact; for an uncertain source the uncertainty + // explains why. + if source.EndKnown && source.SourceEnd != nil { + record["end"] = ContractTimestamp(*source.SourceEnd) + } + reason := source.UncertaintyReason + if record["sourceState"] == "unknown" { + reason = wireUncertaintyForState("unknown", reason) + } + record["uncertainty"] = uncertaintyRecord(reason, asOf, expectedResolutionFor(reason)) + return record +} + +func wireSourceState(stored string) string { + switch stored { + case "active": + return "granting" + case "unknown": + return "unknown" + default: + return "not_granting" + } +} + +// entryRecord renders one Entitlement entry. Product and Subscription Instance +// identity are deliberately absent: they live on the contributing sources. +func entryRecord(entry SnapshotEntry, sourceIDs []string, asOf time.Time) map[string]any { + state := entry.State + record := map[string]any{ + "entitlementId": entry.EntitlementID, + "entitlementKey": entry.EntitlementKey, + "state": state, + "endKnown": entry.EndKnown, + "sourceIds": sourceIDs, + // sourceCount is the count of the sources actually named. Storing a + // count that can disagree with the list is the defect the contract's + // entry-source-count-disagrees fixture exists to catch. + "sourceCount": len(sourceIDs), + "primaryExplanation": map[string]any{ + "code": wireExplanation(entry.ExplanationCode, state), + }, + } + if entry.EffectiveStart != nil { + record["effectiveStart"] = ContractTimestamp(*entry.EffectiveStart) + } else if state == "active" { + // An active entry must state when it started; falling back to the + // evaluation instant keeps the record valid rather than unserializable. + record["effectiveStart"] = ContractTimestamp(asOf) + } + if entry.EndKnown && entry.EffectiveEnd != nil { + record["effectiveEnd"] = ContractTimestamp(*entry.EffectiveEnd) + } + if state == "unknown" { + reason := wireUncertaintyForState(state, entry.UncertaintyReason) + record["uncertainty"] = uncertaintyRecord(reason, asOf, expectedResolutionFor(reason)) + } else if entry.UncertaintyReason != "" && entry.UncertaintyReason != "none" { + record["uncertainty"] = uncertaintyRecord(entry.UncertaintyReason, asOf, + expectedResolutionFor(entry.UncertaintyReason)) + } + return record +} + +// SnapshotRecord builds the customerEntitlementSnapshot payload. +// +// requestedKeys, when non-empty, narrows the entries to those keys. Sources are +// narrowed with them so a reader never sees a source no entry references — the +// contract's orphan-source fixture is exactly that defect. +func SnapshotRecord(view SnapshotView, issuedAt time.Time, freshness Freshness, correlationID string, requestedKeys []string) (map[string]any, error) { + freshness = freshness.Bounded() + issuedAt = issuedAt.UTC() + + keyFilter := map[string]bool{} + for _, key := range requestedKeys { + keyFilter[key] = true + } + + sourcesByRow := map[string]SnapshotSource{} + for _, source := range view.Sources { + sourcesByRow[source.RowID] = source + } + + entries := make([]map[string]any, 0, len(view.Entries)) + usedSources := map[string]bool{} + sortedEntries := append([]SnapshotEntry(nil), view.Entries...) + sort.Slice(sortedEntries, func(i, j int) bool { + return sortedEntries[i].EntitlementKey < sortedEntries[j].EntitlementKey + }) + for _, entry := range sortedEntries { + if len(keyFilter) > 0 && !keyFilter[entry.EntitlementKey] { + continue + } + identities := make([]string, 0, len(entry.SourceIDs)) + for _, rowID := range entry.SourceIDs { + source, ok := sourcesByRow[rowID] + if !ok { + continue + } + identity := SourceIdentity(source.PurchaseLineageID, source.ProductID, source.GrantVersionID) + identities = append(identities, identity) + usedSources[rowID] = true + } + sort.Strings(identities) + identities = dedupe(identities) + entries = append(entries, entryRecord(entry, identities, view.AsOf)) + } + + sources := make([]map[string]any, 0, len(view.Sources)) + sortedSources := append([]SnapshotSource(nil), view.Sources...) + sort.Slice(sortedSources, func(i, j int) bool { + left := SourceIdentity(sortedSources[i].PurchaseLineageID, sortedSources[i].ProductID, sortedSources[i].GrantVersionID) + right := SourceIdentity(sortedSources[j].PurchaseLineageID, sortedSources[j].ProductID, sortedSources[j].GrantVersionID) + return left < right + }) + seenSource := map[string]bool{} + for _, source := range sortedSources { + if len(keyFilter) > 0 && !usedSources[source.RowID] { + continue + } + identity := SourceIdentity(source.PurchaseLineageID, source.ProductID, source.GrantVersionID) + if seenSource[identity] { + continue + } + seenSource[identity] = true + sources = append(sources, sourceRecord(source, view.AsOf)) + } + + payload := map[string]any{ + "snapshotId": view.SnapshotID, + "billingCustomerId": view.CustomerID, + "projectId": view.ProjectID, + "environmentId": view.EnvironmentID, + "snapshotVersion": view.SnapshotVersion, + "projectionRuleVersion": view.RuleVersion, + "issuedAt": ContractTimestamp(issuedAt), + "asOf": ContractTimestamp(view.AsOf), + "refreshAfter": ContractTimestamp(issuedAt.Add(freshness.RefreshAfter)), + "validUntil": ContractTimestamp(issuedAt.Add(freshness.ValidFor)), + "staleGraceSeconds": int(freshness.StaleGrace / time.Second), + "entityTag": EntityTag(view), + "entries": entries, + "sources": sources, + "projectionStatus": projectionStatusRecord(view.Projection), + "changeReason": wireChangeReason(view.ChangeReason, view.PreviousSnapshotVersion), + "correlationId": safeCorrelation(correlationID), + } + if view.PreviousSnapshotVersion > 0 { + payload["previousSnapshotVersion"] = view.PreviousSnapshotVersion + } + digest, err := contentDigest(payload, "contentDigest") + if err != nil { + return nil, err + } + payload["contentDigest"] = digest + return payload, nil +} + +// UnchangedRecord builds the snapshotUnchanged payload. It carries no entries: +// it confirms the cached snapshot and slides its freshness window, so a +// confirmed-current snapshot never expires merely because it was confirmed +// instead of resent. +func UnchangedRecord(view SnapshotView, issuedAt time.Time, freshness Freshness, correlationID string) map[string]any { + freshness = freshness.Bounded() + issuedAt = issuedAt.UTC() + return map[string]any{ + "billingCustomerId": view.CustomerID, + "projectId": view.ProjectID, + "environmentId": view.EnvironmentID, + "snapshotVersion": view.SnapshotVersion, + "entityTag": EntityTag(view), + "issuedAt": ContractTimestamp(issuedAt), + "asOf": ContractTimestamp(view.AsOf), + "refreshAfter": ContractTimestamp(issuedAt.Add(freshness.RefreshAfter)), + "validUntil": ContractTimestamp(issuedAt.Add(freshness.ValidFor)), + "staleGraceSeconds": int(freshness.StaleGrace / time.Second), + "projectionStatus": projectionStatusRecord(view.Projection), + "correlationId": safeCorrelation(correlationID), + } +} + +// CheckResultRecord builds the entitlementCheckResult payload. The answer is +// never a bare boolean: every key carries a state, an explanation, and the +// snapshot version and as-of instant it was derived from. +// +// A nil view means no snapshot could be read at all, in which case every result +// is `unavailable` — which says Mosaic could not answer, not that the customer +// lacks access. +func CheckResultRecord(customerID, projectID, environmentID string, view *SnapshotView, keys []string, + issuedAt time.Time, correlationID string, unavailableReason, unavailableExplanation string) map[string]any { + + issuedAt = issuedAt.UTC() + requested := append([]string(nil), keys...) + sort.Strings(requested) + requested = dedupe(requested) + + results := make([]map[string]any, 0, len(requested)) + if view == nil { + since := issuedAt + for _, key := range requested { + results = append(results, map[string]any{ + "entitlementKey": key, + "state": "unavailable", + "sourceCount": 0, + "endKnown": false, + "primaryExplanation": map[string]any{ + "code": unavailableExplanation, + }, + "uncertainty": uncertaintyRecord(unavailableReason, since, + expectedResolutionFor(unavailableReason)), + }) + } + return map[string]any{ + "billingCustomerId": customerID, + "projectId": projectID, + "environmentId": environmentID, + "issuedAt": ContractTimestamp(issuedAt), + "results": results, + "correlationId": safeCorrelation(correlationID), + } + } + + byKey := map[string]SnapshotEntry{} + for _, entry := range view.Entries { + byKey[entry.EntitlementKey] = entry + } + sourcesByRow := map[string]SnapshotSource{} + for _, source := range view.Sources { + sourcesByRow[source.RowID] = source + } + + for _, key := range requested { + entry, found := byKey[key] + if !found { + // A key the Project defines but no source contributes to is + // inactive, not unknown: the absence is definite. + results = append(results, map[string]any{ + "entitlementKey": key, + "state": "inactive", + "sourceCount": 0, + "endKnown": true, + "primaryExplanation": map[string]any{ + "code": "no_qualifying_source", + }, + }) + continue + } + identities := make([]string, 0, len(entry.SourceIDs)) + testSource := false + for _, rowID := range entry.SourceIDs { + source, ok := sourcesByRow[rowID] + if !ok { + continue + } + identities = append(identities, SourceIdentity(source.PurchaseLineageID, source.ProductID, source.GrantVersionID)) + if source.IsTestSource { + testSource = true + } + } + sort.Strings(identities) + identities = dedupe(identities) + + result := map[string]any{ + "entitlementKey": key, + "state": entry.State, + "sourceCount": len(identities), + "endKnown": entry.EndKnown, + "primaryExplanation": map[string]any{ + "code": wireExplanation(entry.ExplanationCode, entry.State), + }, + } + if len(identities) > 0 { + result["sourceIds"] = identities + } + if entry.EffectiveStart != nil { + result["effectiveStart"] = ContractTimestamp(*entry.EffectiveStart) + } + if entry.EndKnown && entry.EffectiveEnd != nil { + result["effectiveEnd"] = ContractTimestamp(*entry.EffectiveEnd) + } + if testSource { + result["isTestSource"] = true + } + if entry.State == "unknown" { + reason := wireUncertaintyForState(entry.State, entry.UncertaintyReason) + result["uncertainty"] = uncertaintyRecord(reason, view.AsOf, expectedResolutionFor(reason)) + } + results = append(results, result) + } + + return map[string]any{ + "billingCustomerId": customerID, + "projectId": projectID, + "environmentId": environmentID, + "snapshotVersion": view.SnapshotVersion, + "projectionRuleVersion": view.RuleVersion, + "issuedAt": ContractTimestamp(issuedAt), + "asOf": ContractTimestamp(view.AsOf), + "results": results, + "projectionStatus": projectionStatusRecord(view.Projection), + "correlationId": safeCorrelation(correlationID), + } +} + +// SubscriptionRecord builds the subscriptionSnapshot payload. +func SubscriptionRecord(view SubscriptionView, correlationID string) (map[string]any, error) { + payload := map[string]any{ + "subscriptionSnapshotId": view.SnapshotID, + "subscriptionInstanceId": view.SubscriptionInstanceID, + "billingCustomerId": view.CustomerID, + "projectId": view.ProjectID, + "environmentId": view.EnvironmentID, + "projectionVersion": view.ProjectionVersion, + "projectionRuleVersion": view.RuleVersion, + "computedAt": ContractTimestamp(view.ComputedAt), + "asOf": ContractTimestamp(view.AsOf), + "storePlatform": wireStorePlatform(view.StorePlatform), + "mosaicProductId": view.ProductID, + "accessState": view.AccessState, + "lifecycleState": view.LifecycleState, + "renewalIntent": view.RenewalIntent, + "billingState": view.BillingState, + "isTestSource": view.IsTestSource, + "changeReason": wireChangeReason(view.ChangeReason, view.ProjectionVersion-1), + "correlationId": safeCorrelation(correlationID), + } + reason := wireUncertaintyForState(view.AccessState, view.UncertaintyReason) + payload["uncertainty"] = uncertaintyRecord(reason, view.AsOf, expectedResolutionFor(reason)) + + if view.PurchaseLineageID != "" { + payload["purchaseLineageId"] = view.PurchaseLineageID + } + if view.PriorProductID != "" { + payload["priorMosaicProductId"] = view.PriorProductID + } + if view.SupersededByInstanceID != "" { + payload["supersededBySubscriptionInstanceId"] = view.SupersededByInstanceID + } + if view.ExplanationCode != "" { + payload["explanationCode"] = wireExplanation(view.ExplanationCode, view.AccessState) + } + if view.SourceFactCount > 0 { + payload["sourceFactCount"] = view.SourceFactCount + } + for member, instant := range map[string]*time.Time{ + "periodStart": view.PeriodStart, + "periodEnd": view.PeriodEnd, + "gracePeriodEnd": view.GracePeriodEnd, + "billingRetryStart": view.BillingRetryStart, + "pauseEffectiveAt": view.PauseEffectiveAt, + "pauseResumeAt": view.PauseResumeAt, + "cancellationEffectiveAt": view.CancellationEffectiveAt, + "expirationEffectiveAt": view.ExpirationEffectiveAt, + "revocationEffectiveAt": view.RevocationEffectiveAt, + "refundEffectiveAt": view.RefundEffectiveAt, + } { + if instant != nil { + payload[member] = ContractTimestamp(*instant) + } + } + checksum, err := contentDigest(payload, "checksum") + if err != nil { + return nil, err + } + payload["checksum"] = checksum + return payload, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func dedupe(values []string) []string { + if len(values) < 2 { + return values + } + result := values[:1] + for _, value := range values[1:] { + if value != result[len(result)-1] { + result = append(result, value) + } + } + return result +} + +// safeCorrelation bounds a caller-supplied correlation id to the contract's +// identifier shape. A caller cannot inject control characters, a newline, or an +// unbounded string into a record Mosaic signs its own name to. +func safeCorrelation(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "mosaic" + } + if len(value) > 128 { + value = value[:128] + } + cleaned := make([]rune, 0, len(value)) + for index, char := range value { + switch { + case char >= 'A' && char <= 'Z', char >= 'a' && char <= 'z', char >= '0' && char <= '9': + cleaned = append(cleaned, char) + case index > 0 && (char == '.' || char == '_' || char == ':' || char == '-'): + cleaned = append(cleaned, char) + } + } + if len(cleaned) == 0 { + return "mosaic" + } + return string(cleaned) +} diff --git a/apps/api/internal/billingaccess/wire_test.go b/apps/api/internal/billingaccess/wire_test.go new file mode 100644 index 00000000..fbd00474 --- /dev/null +++ b/apps/api/internal/billingaccess/wire_test.go @@ -0,0 +1,269 @@ +package billingaccess + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + "time" +) + +// The canonical serialization is the one thing five implementations must agree +// on byte for byte: an SDK recomputes contentDigest over what it received, and a +// disagreement is reported to a user as cache corruption rather than as the +// serialization bug it is. These vectors are the shared cross-implementation +// reference set, so this test is the Go side of that agreement. + +type digestVectorFile struct { + Vectors []struct { + ID string `json:"id"` + // The vector payloads are already reduced: the excluded member + // (contentDigest or checksum) has been removed by the generator, so the + // digest is taken over the payload exactly as given. + Payload map[string]any `json:"payload"` + Canonical string `json:"canonicalSerialization"` + Digest string `json:"digest"` + ByteLen int `json:"canonicalByteLength"` + } `json:"vectors"` +} + +func repositoryFile(t *testing.T, relative string) string { + t.Helper() + directory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for range 8 { + candidate := filepath.Join(directory, relative) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + parent := filepath.Dir(directory) + if parent == directory { + break + } + directory = parent + } + t.Fatalf("could not locate %s from the test working directory", relative) + return "" +} + +func TestCanonicalSerializationMatchesReferenceVectors(t *testing.T) { + path := repositoryFile(t, "packages/test-fixtures/src/entitlement-snapshot-digest-vectors.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var file digestVectorFile + if err := json.Unmarshal(raw, &file); err != nil { + t.Fatal(err) + } + if len(file.Vectors) == 0 { + t.Fatal("the reference vector file carried no vectors") + } + + checked := 0 + for _, vector := range file.Vectors { + if vector.Digest == "" || vector.Canonical == "" { + t.Fatalf("%s: the reference vector carried no digest or canonical form", vector.ID) + } + encoded, err := CanonicalJSON(vector.Payload) + if err != nil { + t.Fatalf("%s: %v", vector.ID, err) + } + if string(encoded) != vector.Canonical { + t.Fatalf("%s: canonical form\n got %s\nwant %s", vector.ID, encoded, vector.Canonical) + } + if vector.ByteLen != 0 && len(encoded) != vector.ByteLen { + t.Fatalf("%s: canonical length %d, want %d", vector.ID, len(encoded), vector.ByteLen) + } + // contentDigest is taken over the same bytes with the excluded member + // already absent, so the two derivations must agree. + digest, err := contentDigest(vector.Payload, "contentDigest") + if err != nil { + t.Fatalf("%s: %v", vector.ID, err) + } + if digest != vector.Digest { + t.Fatalf("%s: digest %s, want %s", vector.ID, digest, vector.Digest) + } + checked++ + } + if checked < len(file.Vectors) { + t.Fatalf("only %d of %d vectors were checked", checked, len(file.Vectors)) + } +} + +func instant(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed.UTC() +} + +func at(value string) *time.Time { + parsed := instant(value) + return &parsed +} + +func sampleView() SnapshotView { + return SnapshotView{ + SnapshotID: "ces_1", ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1", + SnapshotVersion: 4, PreviousSnapshotVersion: 3, RuleVersion: 1, + ComputedAt: instant("2026-07-28T11:59:58Z"), AsOf: instant("2026-07-28T11:59:58Z"), + Checksum: []byte("0123456789abcdef0123456789abcdef"), ChangeReason: "entitlements_changed", + Entries: []SnapshotEntry{ + { + EntitlementID: "ent_pro", EntitlementKey: "pro", State: "active", + EffectiveStart: at("2026-07-01T09:00:00Z"), EffectiveEnd: at("2026-08-01T09:00:00Z"), + EndKnown: true, SourceCount: 1, UncertaintyReason: "none", + ExplanationCode: "subscription_active", SourceIDs: []string{"esr_1"}, + }, + { + EntitlementID: "ent_beta", EntitlementKey: "beta", State: "unknown", + EndKnown: true, SourceCount: 0, UncertaintyReason: "identity_unresolved", + ExplanationCode: "unresolved_evidence", + }, + }, + Sources: []SnapshotSource{{ + RowID: "esr_1", EntitlementID: "ent_pro", PurchaseLineageID: "plin_1", + ProductID: "prod_pro", GrantVersionID: "pegv_1", SubscriptionInstanceID: "sub_1", + SourceSnapshotID: "bss_1", StorePlatform: "app_store", SourceType: "active_subscription", + SourceState: "active", SourceStart: at("2026-07-01T09:00:00Z"), + SourceEnd: at("2026-08-01T09:00:00Z"), EndKnown: true, UncertaintyReason: "none", + ExplanationCode: "subscription_active", + }}, + Projection: ProjectionStatus{State: ProjectionCurrent, LastProjectedAt: instant("2026-07-28T11:59:58Z")}, + } +} + +// The contract's invalid-fixture set names these three defects explicitly: +// entries out of canonical order, a source count that disagrees with the source +// list, and an entry referencing a source the snapshot does not carry. All three +// are producer bugs a schema check alone would not catch here, because the +// producer is this code. +func TestSnapshotRecordSatisfiesStructuralInvariants(t *testing.T) { + record, err := SnapshotRecord(sampleView(), instant("2026-07-28T12:00:00Z"), + DefaultFreshness(), "corr-1", nil) + if err != nil { + t.Fatal(err) + } + + entries, _ := record["entries"].([]map[string]any) + if len(entries) != 2 { + t.Fatalf("got %d entries, want 2", len(entries)) + } + keys := make([]string, 0, len(entries)) + for _, entry := range entries { + keys = append(keys, entry["entitlementKey"].(string)) + } + if !sort.StringsAreSorted(keys) { + t.Fatalf("entries are not ascending by entitlementKey: %v", keys) + } + + sources, _ := record["sources"].([]map[string]any) + known := map[string]bool{} + for _, source := range sources { + known[source["sourceId"].(string)] = true + } + for _, entry := range entries { + ids, _ := entry["sourceIds"].([]string) + if entry["sourceCount"].(int) != len(ids) { + t.Fatalf("entry %v reports sourceCount %v with %d source ids", + entry["entitlementKey"], entry["sourceCount"], len(ids)) + } + for _, id := range ids { + if !known[id] { + t.Fatalf("entry %v references source %q that the snapshot does not carry", + entry["entitlementKey"], id) + } + } + } + + // An unknown entry must remain explainable, and its uncertainty must carry + // an instant: a definite state carries no `since`, so a non-definite one + // that also lacks it is unreadable. + unknown := entries[0] + if unknown["entitlementKey"] != "beta" { + unknown = entries[1] + } + uncertainty, ok := unknown["uncertainty"].(map[string]any) + if !ok || uncertainty["reason"] == "none" || uncertainty["since"] == nil { + t.Fatalf("an unknown entry carried no definite uncertainty: %v", unknown["uncertainty"]) + } + + // The digest must cover the binding fields, so a snapshot cannot be + // accepted into another customer's cache. + digest, _ := record["contentDigest"].(string) + moved := sampleView() + moved.CustomerID = "bcu_2" + movedRecord, err := SnapshotRecord(moved, instant("2026-07-28T12:00:00Z"), DefaultFreshness(), "corr-1", nil) + if err != nil { + t.Fatal(err) + } + if movedRecord["contentDigest"] == digest { + t.Fatal("the content digest did not change when the snapshot named a different customer") + } +} + +// A narrowed request must not leave a source behind that no entry references — +// the contract's orphan-source rejection. +func TestNarrowedSnapshotCarriesNoOrphanSources(t *testing.T) { + record, err := SnapshotRecord(sampleView(), instant("2026-07-28T12:00:00Z"), + DefaultFreshness(), "corr-1", []string{"beta"}) + if err != nil { + t.Fatal(err) + } + entries, _ := record["entries"].([]map[string]any) + if len(entries) != 1 || entries[0]["entitlementKey"] != "beta" { + t.Fatalf("narrowing returned %d entries", len(entries)) + } + sources, _ := record["sources"].([]map[string]any) + if len(sources) != 0 { + t.Fatalf("narrowing left %d sources no entry references", len(sources)) + } +} + +// The combined horizon — validity plus bounded grace — may never exceed thirty +// days. Past that a device that never reconnects holds an entitlement forever, +// which is the failure the whole bounded-grace policy exists to prevent. +func TestFreshnessHorizonIsBounded(t *testing.T) { + bounded := Freshness{ + RefreshAfter: time.Hour, + ValidFor: 25 * 24 * time.Hour, + StaleGrace: 25 * 24 * time.Hour, + }.Bounded() + + if bounded.ValidFor+bounded.StaleGrace > MaxCombinedHorizon { + t.Fatalf("combined horizon %v exceeds the contract maximum", bounded.ValidFor+bounded.StaleGrace) + } + if bounded.ValidFor != 25*24*time.Hour { + t.Fatalf("validity was shortened to %v; the grace window should absorb the overflow", bounded.ValidFor) + } + if bounded.RefreshAfter > bounded.ValidFor { + t.Fatal("refreshAfter is later than validUntil") + } +} + +// The source id must be stable across snapshots and derived from the source's +// identity rather than from a per-generation row or a fact. +func TestSourceIdentityIsStableAndDerivedFromIdentity(t *testing.T) { + first := SourceIdentity("plin_1", "prd_pro", "pegv_1") + second := SourceIdentity("plin_1", "prd_pro", "pegv_1") + if first != second { + t.Fatal("source identity is not deterministic") + } + if SourceIdentity("plin_1", "prd_pro", "pegv_2") == first { + t.Fatal("a different grant version produced the same source identity") + } + if SourceIdentity("plin_1", "prd_other", "pegv_1") == first { + t.Fatal("a different Mosaic Product produced the same source identity") + } + sum := sha256.Sum256([]byte("unrelated")) + if first == hex.EncodeToString(sum[:]) { + t.Fatal("source identity collided with an unrelated digest") + } +} diff --git a/apps/api/internal/billingcustomer/errors.go b/apps/api/internal/billingcustomer/errors.go new file mode 100644 index 00000000..fc0c89d4 --- /dev/null +++ b/apps/api/internal/billingcustomer/errors.go @@ -0,0 +1,33 @@ +package billingcustomer + +import "errors" + +// Stable domain errors. Callers compare with errors.Is, never by message. +var ( + // ErrBillingDisabled is returned by every read and write when the Project + // has billing turned off. It maps to `unavailable` on entitlement + // surfaces, never to `inactive`: a disabled integration says nothing about + // whether a customer paid. + ErrBillingDisabled = errors.New("billing is not enabled for this Project") + + ErrNotFound = errors.New("billing customer not found") + ErrForbidden = errors.New("actor may not access this Project") + ErrUnavailable = errors.New("billing identity storage is unavailable") + ErrConflict = errors.New("alias already resolves to another customer") + ErrFrozen = errors.New("customer identity is frozen by an open conflict") + ErrInvalidAlias = errors.New("alias value is not acceptable") + ErrNotIdentified = errors.New("no customer could be resolved from the supplied evidence") + + // ErrIdentityConflict is returned when a request would have moved an + // identity away from a customer that already holds it. It is deliberately + // distinct from ErrConflict: the caller is being told that an operator + // resolution has been opened and that nothing was reassigned, which is a + // different instruction from "retry, you raced someone". Corrects review + // finding I-10. + ErrIdentityConflict = errors.New("identity is claimed by another customer and is held for operator resolution") + + // ErrUnauthenticated is returned when a trusted-server API key does not + // authenticate. It never distinguishes unknown from revoked from wrong + // tenant. + ErrUnauthenticated = errors.New("the presented API key is not valid") +) diff --git a/apps/api/internal/billingcustomer/lineage.go b/apps/api/internal/billingcustomer/lineage.go new file mode 100644 index 00000000..324c82cf --- /dev/null +++ b/apps/api/internal/billingcustomer/lineage.go @@ -0,0 +1,23 @@ +package billingcustomer + +// SameLineage reports whether two facts belong to one lineage. It exists so +// the rule has one statement: lineages are joined only by provider-stated +// chain identity, never because two purchases share a Product, a customer, a +// price, or a time window. +// +// This file used to also carry `LineageKey`, `WalkChainRoot`, and `ChainLink`: +// a second implementation of "derive the chain root and key a lineage on it". +// It had no production caller. The canonical implementation lives in the +// fact-commit transaction (`billingpostgres.materializeLineage` and +// `chainRootDigest`), which resolves the root by walking supersession edges in +// SQL inside the same transaction as the fact — the only place that can do it +// consistently. Two implementations of a persisted digest domain is one edit +// away from lineages that can never join to the facts they were created for, +// which is exactly the shape of defect D-1, so the duplicate is gone rather +// than kept in sync by hand. +func SameLineage(leftChainDigest, rightChainDigest []byte) bool { + if len(leftChainDigest) == 0 || len(rightChainDigest) == 0 { + return false + } + return string(leftChainDigest) == string(rightChainDigest) +} diff --git a/apps/api/internal/billingcustomer/model.go b/apps/api/internal/billingcustomer/model.go new file mode 100644 index 00000000..5ff3db6d --- /dev/null +++ b/apps/api/internal/billingcustomer/model.go @@ -0,0 +1,288 @@ +// Package billingcustomer owns Mosaic's billing identity: the Project-scoped +// Billing Customer, its typed aliases, the evidence that links validated +// provider facts to it, and the Environment-scoped purchase lineages those +// facts belong to. +// +// The package exists to make one failure structurally impossible: silently +// creating or selecting the wrong customer. Customers are created lazily, +// aliases are digest-only, an installation identifier can never select a +// customer, and a lineage claimed by two customers freezes rather than +// resolving to a guess. +package billingcustomer + +import "time" + +// ResolverVersion is stamped on every piece of association evidence, so a +// later change to the resolution rules is a version bump with a replay rather +// than an unexplained change of history. +const ResolverVersion = 1 + +// Customer statuses. +// +// `absorbed` marks a purchase-anchored customer whose only lineage was adopted +// by an identified customer (plan §5a rule 3). The row is kept rather than +// deleted: entitlement snapshots, evidence, and audit events already cite it, +// and an investigation has to be able to follow the purchase from the anchor to +// the person. +const ( + StatusActive = "active" + StatusFrozen = "frozen" + StatusAnonymized = "anonymized" + StatusAbsorbed = "absorbed" +) + +// Alias types. Every one is stored as a digest; no raw value is persisted. +const ( + AliasApplicationUser = "application_user_id" + AliasInstallation = "installation_id" + AliasAppleAppAccountToken = "apple_app_account_token" + AliasGoogleObfuscatedAcount = "google_obfuscated_account_id" +) + +// Alias source authorities. The authority records who asserted the link, which +// is what makes "a public SDK key can never claim an application user" an +// auditable property rather than a routing convention. +const ( + AuthorityTrustedServer = "trusted_server" + AuthoritySDKInstallation = "sdk_installation" + AuthorityProviderPayload = "provider_payload" + AuthorityOperator = "operator" + AuthorityRestore = "restore" +) + +// Evidence types (OD-2(b)). `installation_observation` is deliberately +// included and deliberately never resolving: it is recorded for attribution +// and diagnostics only. +const ( + EvidenceAppAccountToken = "app_account_token" + EvidenceObfuscatedAccount = "obfuscated_external_account_id" + EvidenceTrustedServer = "trusted_server_observation" + EvidencePriorLineage = "prior_lineage_association" + EvidenceRestoreLink = "restore_link" + EvidenceOperatorRepair = "operator_repair" + EvidenceInstallation = "installation_observation" + // EvidencePurchaseAnchor records that no evidence resolved a customer and + // one was created to hold the purchase (plan §5a rules 1 and 2). It is + // written *after* the customer exists and is never offered to the resolver, + // so it can never select a customer — which is what keeps it from becoming a + // route to someone else's entitlements. + EvidencePurchaseAnchor = "purchase_anchor" + // EvidenceTokenBoundSubmission records that an observation submitted over a + // *public SDK key* carried a Customer Access Token naming a customer. + // + // It is deliberately distinct from EvidenceTrustedServer and ranks below + // EvidencePriorLineage. The two claims are not the same act: a secret server + // key proves the application's own backend is speaking, while a public SDK + // key is shipped inside every install and proves only that the caller holds + // a token — which a device that once legitimately held one keeps holding + // after it stops being that person's device. Ranking a token-bound + // submission above a prior association would let anyone able to present a + // token take a lineage away from the customer that already holds it, or + // freeze it in a conflict. Both are remote denial-of-access primitives, and + // the rank is what makes them unreachable. + EvidenceTokenBoundSubmission = "token_bound_submission" + // EvidenceAnchorAdoption records that an identified customer adopted a + // purchase-anchored customer's lineage (plan §5a rule 3), with the ownership + // proof that permitted it in the diagnostic code. + EvidenceAnchorAdoption = "anchored_customer_adoption" +) + +// Association outcomes. +const ( + OutcomeResolved = "resolved" + OutcomeUnresolved = "unresolved" + OutcomeConflicting = "conflicting" + OutcomeUnsupported = "unsupported" +) + +// Lineage types. +const ( + LineageSubscription = "subscription" + LineageOneTime = "one_time" +) + +// Customer is the Project-scoped authoritative subject. +type Customer struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Status string `json:"status"` + CurrentProjectionVersion int64 `json:"currentProjectionVersion"` + LastProjectedAt *time.Time `json:"lastProjectedAt,omitempty"` + DiagnosticsStatus string `json:"diagnosticsStatus"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Alias is one accepted external identity bound to a customer. The digest is +// never rendered on any operator surface: an alias digest is still a stable +// per-person identifier, and exposing it would let one tenant's export be +// joined against another's. +type Alias struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + BillingCustomerID string `json:"billingCustomerId"` + AliasType string `json:"aliasType"` + digest []byte `json:"-"` + SourceAuthority string `json:"sourceAuthority"` + VerificationStatus string `json:"verificationStatus"` + EffectiveStart time.Time `json:"effectiveStart"` + EffectiveEnd *time.Time `json:"effectiveEnd,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// Digest exposes the stored digest to the persistence layer inside the +// application, without putting it on the JSON surface. +func (a Alias) Digest() []byte { return a.digest } + +// WithDigest returns a copy carrying the digest, used by repositories when +// hydrating rows. +func (a Alias) WithDigest(digest []byte) Alias { + a.digest = digest + return a +} + +// Evidence is one append-only association observation. +type Evidence struct { + ID string + ProjectID string + EnvironmentID string + PurchaseLineageID string + EvidenceType string + EvidenceDigest []byte + RawInputID string + TransactionReferenceDigest []byte + BillingCustomerID string + ResolverVersion int + Outcome string + DiagnosticCode string + ObservedAt time.Time + CreatedAt time.Time +} + +// Lineage is one provider purchase chain. +type Lineage struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + EnvironmentMode string `json:"-"` + ApplicationID string `json:"applicationId"` + Provider string `json:"provider"` + StoreEnvironment string `json:"storeEnvironment"` + LineageKeyDigest []byte `json:"-"` + LineageType string `json:"lineageType"` + BillingCustomerID string `json:"billingCustomerId,omitempty"` + SupersededByLineageID string `json:"supersededByLineageId,omitempty"` + ProjectionFrozen bool `json:"projectionFrozen"` + DiagnosticStatus string `json:"diagnosticStatus"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Conflict scopes. A conflict is either about a purchase lineage two customers +// claim, or about an application-user alias that already resolves elsewhere. +// The two need different repair actions, so they are not collapsed into one +// shape an operator has to guess at. +const ( + ConflictScopeLineage = "lineage" + ConflictScopeAlias = "alias" +) + +// Conflict diagnostic codes. They are stable machine-readable strings, safe to +// render on an operator surface, and carry no alias value. +const ( + // DiagnosticMultipleClaims is the equal-authority disagreement the resolver + // itself detects. + DiagnosticMultipleClaims = "multiple_customers_claim_lineage" + // DiagnosticReassignmentBlocked marks a lineage whose evidence now names a + // different customer than the one it is already attached to. Corrects + // review finding I-10: this used to move the lineage silently. + DiagnosticReassignmentBlocked = "reassignment_requires_operator_resolution" + // DiagnosticAliasClaimsTwoCustomers marks an application-user alias that + // already resolves to a different customer (plan §5a rule 4). + DiagnosticAliasClaimsTwoCustomers = "application_user_alias_claims_two_customers" + // DiagnosticTokenBoundCannotReassign marks a token-bound public-SDK-key + // submission that named a customer other than the one already holding the + // lineage. It is recorded and ignored: it neither reassigns nor conflicts, + // because a public key plus a token is not enough to move a purchase and + // must not be enough to freeze one either. + DiagnosticTokenBoundCannotReassign = "token_bound_submission_cannot_reassign_lineage" + // DiagnosticAdoptionRequiresProof marks an adoption claim against a + // purchase-anchored customer that carried no ownership proof. The claim is + // recorded unresolved and waits for an operator. + DiagnosticAdoptionRequiresProof = "anchor_adoption_requires_ownership_proof" + // DiagnosticAnchorAdopted marks the accepted adoption itself. + DiagnosticAnchorAdopted = "purchase_anchored_customer_adopted" +) + +// Ownership proofs that permit an identified customer to adopt a +// purchase-anchored customer's lineage (plan §5a rule 3). +// +// The Apple/Google asymmetry is deliberate and is documented in +// docs/backend/phase-9b-authoritative-entitlements.md. A Google purchase token +// is an unguessable secret issued by the store, so a submission that carries it +// proves possession of the purchase. An Apple transaction identifier is a short +// decimal number: possession of one proves nothing, so Apple adoption needs a +// provider correlator match or a secret-server-key submission instead. +const ( + ProofPurchaseTokenPossession = "purchase_token_possession" + ProofProviderCorrelator = "provider_correlator_match" + ProofTrustedServer = "trusted_server_submission" +) + +// Conflict is one disputed association held open for operator resolution. +type Conflict struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + // Scope is ConflictScopeLineage or ConflictScopeAlias. Exactly one of + // PurchaseLineageID and the alias fields is populated. + Scope string `json:"scope"` + PurchaseLineageID string `json:"purchaseLineageId,omitempty"` + // AliasType names the alias family in dispute. The alias *value* is a + // digest and is never part of this struct's JSON surface. + AliasType string `json:"aliasType,omitempty"` + aliasDigest []byte `json:"-"` + Status string `json:"status"` + FirstCustomerID string `json:"firstCustomerId"` + SecondCustomerID string `json:"secondCustomerId"` + DiagnosticCode string `json:"diagnosticCode,omitempty"` + OpenedAt time.Time `json:"openedAt"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + ResolutionAction string `json:"resolutionAction,omitempty"` + // ResolutionReason is the operator's stated justification, required at + // resolution time. It is operator-authored free text about a dispute, never + // an alias value, and is safe on an operator surface. + ResolutionReason string `json:"resolutionReason,omitempty"` +} + +// Digest exposes the disputed alias digest to the persistence layer without +// putting it on the JSON surface, exactly as Alias does. +func (c Conflict) Digest() []byte { return c.aliasDigest } + +// WithDigest returns a copy carrying the disputed alias digest. +func (c Conflict) WithDigest(digest []byte) Conflict { + c.aliasDigest = digest + return c +} + +// ConflictDetail is one conflict with the lineage it disputes, which is what an +// operator needs to decide the repair. It is empty for alias-scoped conflicts, +// which dispute no lineage. +type ConflictDetail struct { + Conflict Conflict `json:"conflict"` + Lineage *Lineage `json:"lineage,omitempty"` +} + +// SyncRequest is the handle returned when a manual projection is requested. It +// carries no state of its own: the scope key is what the projection job queue +// coalesces on, so an operator polling projection status uses it directly. +type SyncRequest struct { + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + BillingCustomerID string `json:"billingCustomerId"` + ScopeKey string `json:"projectionScopeKey"` + Kind string `json:"triggerKind"` + RequestedAt time.Time `json:"requestedAt"` +} + +// Actor is the authenticated operator principal. +type Actor struct{ ID string } diff --git a/apps/api/internal/billingcustomer/ports.go b/apps/api/internal/billingcustomer/ports.go new file mode 100644 index 00000000..81e56e39 --- /dev/null +++ b/apps/api/internal/billingcustomer/ports.go @@ -0,0 +1,52 @@ +package billingcustomer + +import ( + "context" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +// KeyScope is the tenant a secret server key authenticates into. It mirrors the +// scope the access surfaces already resolve; identity deliberately declares its +// own port rather than importing the access module, because access is expected +// to depend on identity and not the other way around. +type KeyScope struct { + APIKeyID string + OrganizationID string + ProjectID string + EnvironmentID string + EnvironmentMode string + ApplicationID string +} + +// ServerKeyAuthenticator resolves a secret server key into a tenant. +// +// Only a secret server key is accepted anywhere in this package. There is +// deliberately no public-SDK-key authenticator here: an application-user alias +// is assertable only by the customer's own backend, so a surface that could +// accept a public key would be an impersonate-anyone vulnerability regardless +// of what the handler in front of it checked (plan §5a security corollaries). +type ServerKeyAuthenticator interface { + AuthenticateServerKey(ctx context.Context, raw string) (KeyScope, error) +} + +// ServerKeyAuthenticatorFunc adapts a function into the port. It exists so the +// composition root can bridge an existing key authenticator in one line without +// either module depending on the other. +type ServerKeyAuthenticatorFunc func(ctx context.Context, raw string) (KeyScope, error) + +func (f ServerKeyAuthenticatorFunc) AuthenticateServerKey(ctx context.Context, raw string) (KeyScope, error) { + return f(ctx, raw) +} + +// Reprojector schedules recomputation of a customer's committed entitlement +// aggregate. +// +// Identity owns *who* a purchase belongs to; the moment that answer changes, +// the previously computed aggregate is stale and keeps granting whatever it +// last decided. Identity therefore has to be able to say "recompute that", and +// this narrow port is the whole of that dependency: it cannot read, project, or +// commit anything. +type Reprojector interface { + Enqueue(ctx context.Context, scope billingprojection.Scope, kind string) error +} diff --git a/apps/api/internal/billingcustomer/repository.go b/apps/api/internal/billingcustomer/repository.go new file mode 100644 index 00000000..2159680d --- /dev/null +++ b/apps/api/internal/billingcustomer/repository.go @@ -0,0 +1,89 @@ +package billingcustomer + +import ( + "context" + "time" +) + +// Repository is the persistence port for billing identity. Authorization for +// operator-facing reads is enforced in SQL alongside the query, following the +// analytics and 9A billing precedent, so no caller can reach another tenant's +// customers by forgetting a check. +type Repository interface { + // BillingEnabled reports whether the Project may hold billing identity. + BillingEnabled(ctx context.Context, projectID string) (bool, error) + + // CreateCustomer inserts a new customer. It is called only from the two + // lazy-creation paths (trusted identify, fact attachment). + CreateCustomer(ctx context.Context, customer Customer) (Customer, error) + Customer(ctx context.Context, actor Actor, projectID, customerID string) (Customer, error) + // CustomerForAlias returns the customer an active alias resolves to. + CustomerForAlias(ctx context.Context, projectID, aliasType string, digest []byte) (Customer, error) + ListCustomers(ctx context.Context, actor Actor, projectID string, limit int, cursor string) ([]Customer, string, error) + SetCustomerStatus(ctx context.Context, projectID, customerID, status string, now time.Time) error + // PurchaseAnchoredOnly reports whether a customer exists solely to hold a + // purchase: it carries `purchase_anchor` evidence, nothing has ever + // identified it, and it holds no aliases of any kind. It is the + // precondition for adoption (plan §5a rule 3) — a customer that fails it + // has a person behind it, and taking its purchase away is an operator + // decision rather than a resolver one. + PurchaseAnchoredOnly(ctx context.Context, projectID, customerID string) (bool, error) + // LineageCountForCustomer counts the purchase lineages a customer still + // holds, across every Environment. + LineageCountForCustomer(ctx context.Context, projectID, customerID string) (int, error) + + // AttachAlias records an alias, failing with ErrConflict when the digest + // already has a live resolution to a different customer. The uniqueness is + // enforced by a partial unique index, so a race loses at the database + // rather than in application logic. + AttachAlias(ctx context.Context, alias Alias) (Alias, error) + RevokeAlias(ctx context.Context, actor Actor, projectID, aliasID string, now time.Time) error + ListAliases(ctx context.Context, actor Actor, projectID, customerID string) ([]Alias, error) + // ActiveAliasResolutions returns digest→customer for the supplied digests, + // which is the lookup the pure resolver consumes. + ActiveAliasResolutions(ctx context.Context, projectID string, digests [][]byte) (map[string]string, error) + + RecordEvidence(ctx context.Context, evidence Evidence) error + // PriorLineageCustomers returns customers named by persisted prior-lineage + // evidence for this lineage. It is the retry record for a move: if the + // pointer commit succeeds but scheduling either aggregate fails, the next + // identical resolution can still find and reproject the customer that lost + // the purchase. + PriorLineageCustomers(ctx context.Context, projectID, lineageID string) ([]string, error) + AdoptionRecorded(ctx context.Context, projectID, lineageID, adopterID string) (bool, error) + // EvidenceForReference reads the association correlators parsed from raw + // inputs that share a transaction reference digest. Fact provenance is + // first-writer-wins and therefore not authoritative, so authority is + // resolved by scanning inputs rather than by trusting the fact's own + // source input. + EvidenceForReference(ctx context.Context, projectID string, referenceDigest []byte) ([]Evidence, error) + + Lineage(ctx context.Context, projectID, lineageID string) (Lineage, error) + // LineageByKey reads the lineage for one provider chain key. It is the only + // key-based lookup: the fact-commit transaction is the sole writer of + // purchase lineages, so this module reads them and never creates them. + LineageByKey(ctx context.Context, environmentID, provider string, keyDigest []byte) (Lineage, error) + AttachLineageCustomer(ctx context.Context, projectID, lineageID, customerID string, now time.Time) error + SetLineageSupersededBy(ctx context.Context, projectID, lineageID, supersededBy string, now time.Time) error + SetLineageFrozen(ctx context.Context, projectID, lineageID string, frozen bool, diagnostic string, now time.Time) error + + // OpenConflict creates the single open conflict for a lineage, or returns + // the existing one. Freezing the lineage happens in the same transaction: + // a conflict that did not freeze would let the next projection grant + // access to whichever candidate happened to be read first. + // For an alias-scoped conflict there is no lineage to freeze; the customer + // the caller tried to extend is frozen instead, which is what stops the + // next request from quietly retrying the same reassignment. + OpenConflict(ctx context.Context, conflict Conflict) (Conflict, error) + Conflict(ctx context.Context, actor Actor, projectID, conflictID string) (Conflict, error) + ListConflicts(ctx context.Context, actor Actor, projectID string, status string) ([]Conflict, error) + // ResolveConflict applies an operator's decision. `reason` is the operator's + // stated justification and is required: a resolution moves a purchase between + // two customers, and an investigation months later needs the why alongside + // the what. It is stored on the conflict's existing detail document, so the + // reason and the diagnostic that opened the conflict live in one place. + ResolveConflict(ctx context.Context, actor Actor, projectID, conflictID, action, assignedCustomerID, reason string, now time.Time) (Conflict, error) + + // RecordAudit writes an audit event in the caller's transaction scope. + RecordAudit(ctx context.Context, actor Actor, projectID, action, resourceType, resourceID string, metadata map[string]string, now time.Time) error +} diff --git a/apps/api/internal/billingcustomer/resolver.go b/apps/api/internal/billingcustomer/resolver.go new file mode 100644 index 00000000..7bf92679 --- /dev/null +++ b/apps/api/internal/billingcustomer/resolver.go @@ -0,0 +1,239 @@ +package billingcustomer + +import ( + "sort" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" +) + +// AliasDigest is the one-way representation of an alias value. +// +// The domain separation matters more than usual here. An application user id +// and an Apple app-account token are both opaque strings chosen by someone +// else; without a domain prefix, a value that happened to be identical across +// two alias types would collapse into one active resolution and silently join +// two people. The alias type is folded in for the same reason. +// It delegates to the billing module's implementation rather than repeating the +// hash. The validator has to produce the identical digest for a provider +// correlator it parses, and two copies of a persisted digest domain is one edit +// away from two records of the same person that no longer match. +func AliasDigest(aliasType, value string) []byte { + return billing.AliasDigest(aliasType, value) +} + +// Observation is one piece of evidence offered to the resolver. +type Observation struct { + EvidenceType string + // Digest is the alias digest of the correlator value, computed + // server-side. The resolver never receives a raw correlator. + Digest []byte + // CustomerID is set only for evidence that names a customer directly: a + // trusted server observation, a restore link, an operator repair, or a + // prior association already recorded on the lineage. + CustomerID string + // AliasType is the alias the digest belongs to, used to look up an + // existing active resolution. + AliasType string + RawInputID string + // PossessionProof is true when this observation was recorded under a + // transaction reference that IS the purchase chain's own unguessable + // provider secret — a Google purchase token. It is never set for Apple, + // whose transaction identifiers are short decimal numbers that prove + // nothing about who made the purchase. + // + // It does not raise the observation's authority. It is consulted only by + // the anchored-customer adoption path, which needs proof of ownership + // rather than an ordering. + PossessionProof bool +} + +// Resolution is the deterministic verdict for one lineage. +type Resolution struct { + Outcome string + CustomerID string + // ConflictWith is the second candidate when the outcome is conflicting. + ConflictWith string + DiagnosticCode string + // Considered is every observation the resolver examined, in the order it + // examined them, each stamped with the outcome it produced. It is + // persisted as association evidence so the decision is reconstructable. + Considered []Observation + // DecidingRank is the authority rank the winning candidates were drawn + // from. The caller needs it because what a verdict is allowed to *do* + // depends on how it was reached: a verdict carried only by a token-bound + // public-SDK-key submission may attach an unattached lineage but may never + // move or freeze an attached one. + DecidingRank int +} + +// authorityRank orders evidence types by how much authority they carry. The +// ranking is explicit rather than implied by evaluation order, because an +// implicit ranking is one refactor away from silently changing which evidence +// wins. +// +// A trusted server observation outranks a provider correlator because the +// application backend knows who its user is, while a correlator only says two +// purchases came from the same store account. An installation observation +// carries no authority at all and is listed to make that explicit: it is +// evidence for attribution and can never select a customer (OD-4(a)), because +// a client-generated identifier that could select a customer is a +// read-someone-else's-entitlements vulnerability. +// A token-bound submission over a *public SDK key* sits below +// prior_lineage_association on purpose. Rank 90 is reserved for a submission +// authenticated by the application's secret server key, which is the only +// credential that proves the application's own backend is speaking. A public +// SDK key ships inside every install, so a caller presenting one plus a +// Customer Access Token is making a weaker claim than the association a lineage +// already carries — and if it outranked that association it could take a +// purchase away from its owner, or freeze it in a conflict, from any device +// that ever held a token. Both are remote denial-of-access primitives. +func authorityRank(evidenceType string) int { + switch evidenceType { + case EvidenceOperatorRepair: + return 100 + case EvidenceTrustedServer: + return 90 + case EvidenceRestoreLink: + return 80 + case EvidenceAnchorAdoption: + return 75 + case EvidencePriorLineage: + return 70 + case EvidenceTokenBoundSubmission: + return 65 + case EvidenceAppAccountToken, EvidenceObfuscatedAccount: + return 60 + default: + // Including installation observations. + return 0 + } +} + +// RankTokenBoundSubmission is the authority a token-bound public-SDK-key +// submission carries. It is exported so the application service can recognise a +// verdict that was reached on that evidence alone without restating the number. +var RankTokenBoundSubmission = authorityRank(EvidenceTokenBoundSubmission) + +// OwnershipProof reports the proof, if any, that `candidate` owns the purchase +// chain these observations describe. +// +// This is a different question from "which customer does the evidence name", +// which Resolve answers. Adoption takes a lineage away from a customer that +// already holds it, so naming is not enough: something has to demonstrate that +// the claimant is the buyer. Three things do, and the Apple/Google asymmetry is +// the reason there are three rather than one: +// +// - Possession of the Google purchase token. The token is an unguessable +// secret the store issued to the purchasing device; presenting it is proof. +// Apple has no equivalent — its transaction identifiers are short decimal +// numbers — so possession is never accepted for Apple. +// - A provider correlator (Apple `appAccountToken`, Google +// `obfuscatedExternalAccountId`) that already resolves to the candidate. +// The store itself echoed a value the candidate's backend chose. +// - A submission authenticated by the secret server key, which is the +// application's own backend speaking. +func OwnershipProof(observations []Observation, activeAliases map[string]string, candidate string) (string, bool) { + if candidate == "" { + return "", false + } + correlator := false + trusted := false + for _, observation := range observations { + switch observation.EvidenceType { + case EvidenceTokenBoundSubmission, EvidenceTrustedServer: + if observation.CustomerID != candidate { + continue + } + if observation.PossessionProof { + return ProofPurchaseTokenPossession, true + } + if observation.EvidenceType == EvidenceTrustedServer { + trusted = true + } + case EvidenceAppAccountToken, EvidenceObfuscatedAccount: + if len(observation.Digest) > 0 && activeAliases[string(observation.Digest)] == candidate { + correlator = true + } + } + } + switch { + case correlator: + return ProofProviderCorrelator, true + case trusted: + return ProofTrustedServer, true + default: + return "", false + } +} + +// Resolve decides which Billing Customer a lineage belongs to. +// +// It is a pure function of the observations and the currently active alias +// resolutions, so a dry run has no side effects and a replay reaches the same +// verdict. It never guesses: two candidates of equal authority conflict rather +// than one being picked. +// +// `activeAliases` maps an alias digest (hex-free, compared by value) to the +// customer that alias currently resolves to. +func Resolve(observations []Observation, activeAliases map[string]string) Resolution { + resolution := Resolution{Outcome: OutcomeUnresolved, DiagnosticCode: "no_accepted_evidence"} + + ranked := append([]Observation(nil), observations...) + sort.SliceStable(ranked, func(i, j int) bool { + return authorityRank(ranked[i].EvidenceType) > authorityRank(ranked[j].EvidenceType) + }) + resolution.Considered = ranked + + // Candidates at the highest authority level that produced any candidate. + bestRank := -1 + candidates := map[string]struct{}{} + for _, observation := range ranked { + rank := authorityRank(observation.EvidenceType) + if rank == 0 { + // Zero-authority evidence is recorded and ignored. This is the + // structural guarantee that an installation id cannot select a + // customer. + continue + } + candidate := observation.CustomerID + if candidate == "" && len(observation.Digest) > 0 { + candidate = activeAliases[string(observation.Digest)] + } + if candidate == "" { + continue + } + if rank > bestRank { + bestRank, candidates = rank, map[string]struct{}{candidate: {}} + continue + } + if rank == bestRank { + candidates[candidate] = struct{}{} + } + } + + resolution.DecidingRank = bestRank + switch len(candidates) { + case 0: + resolution.DecidingRank = 0 + return resolution + case 1: + for candidate := range candidates { + resolution.Outcome, resolution.CustomerID = OutcomeResolved, candidate + resolution.DiagnosticCode = "" + } + return resolution + default: + // Two equally authoritative pieces of evidence naming different + // customers. Neither is granted anything: the lineage freezes and an + // operator resolves it (OD-10(a)). + names := make([]string, 0, len(candidates)) + for candidate := range candidates { + names = append(names, candidate) + } + sort.Strings(names) + resolution.Outcome = OutcomeConflicting + resolution.CustomerID, resolution.ConflictWith = names[0], names[1] + resolution.DiagnosticCode = "multiple_customers_claim_lineage" + return resolution + } +} diff --git a/apps/api/internal/billingcustomer/resolver_test.go b/apps/api/internal/billingcustomer/resolver_test.go new file mode 100644 index 00000000..a28d4217 --- /dev/null +++ b/apps/api/internal/billingcustomer/resolver_test.go @@ -0,0 +1,156 @@ +package billingcustomer + +import "testing" + +// Association decides whose entitlements a purchase becomes. A wrong verdict +// is either a customer reading someone else's paid access or a paying customer +// silently losing theirs, so these tests pin the rules that prevent both. + +// An installation identifier is client-generated and guessable. If it could +// select a customer, anyone able to forge one could read another person's +// entitlements. It must be recorded and ignored (OD-4(a)). +func TestInstallationEvidenceCannotSelectCustomer(t *testing.T) { + digest := AliasDigest(AliasInstallation, "install-abc") + resolution := Resolve( + []Observation{{EvidenceType: EvidenceInstallation, Digest: digest, AliasType: AliasInstallation}}, + map[string]string{string(digest): "bcu_victim"}, + ) + + if resolution.Outcome != OutcomeUnresolved { + t.Fatalf("installation evidence resolved to %q/%q; it must never select a customer", + resolution.Outcome, resolution.CustomerID) + } + if resolution.CustomerID != "" { + t.Fatalf("installation evidence selected customer %q", resolution.CustomerID) + } + if len(resolution.Considered) != 1 { + t.Fatal("ignored evidence must still be recorded for attribution and diagnostics") + } +} + +// A trusted server observation outranks a provider correlator: the application +// backend knows who its user is, while a store correlator only proves two +// purchases share a store account (which Family Sharing and shared devices +// make an unreliable person identifier). +func TestTrustedServerOutranksProviderCorrelator(t *testing.T) { + correlator := AliasDigest(AliasAppleAppAccountToken, "token-1") + resolution := Resolve([]Observation{ + {EvidenceType: EvidenceAppAccountToken, Digest: correlator, AliasType: AliasAppleAppAccountToken}, + {EvidenceType: EvidenceTrustedServer, CustomerID: "bcu_trusted"}, + }, map[string]string{string(correlator): "bcu_correlator"}) + + if resolution.Outcome != OutcomeResolved || resolution.CustomerID != "bcu_trusted" { + t.Fatalf("got %q/%q, want resolved/bcu_trusted", resolution.Outcome, resolution.CustomerID) + } +} + +// Two equally authoritative pieces of evidence naming different customers must +// conflict, not pick one. Picking would put one customer's purchase on another +// customer's account, which no later repair fully undoes. +func TestEqualAuthorityDisagreementConflicts(t *testing.T) { + resolution := Resolve([]Observation{ + {EvidenceType: EvidenceTrustedServer, CustomerID: "bcu_one"}, + {EvidenceType: EvidenceTrustedServer, CustomerID: "bcu_two"}, + }, nil) + + if resolution.Outcome != OutcomeConflicting { + t.Fatalf("outcome %q, want conflicting", resolution.Outcome) + } + if resolution.CustomerID == "" || resolution.ConflictWith == "" || + resolution.CustomerID == resolution.ConflictWith { + t.Fatalf("conflict did not name two distinct candidates: %+v", resolution) + } + if resolution.DiagnosticCode == "" { + t.Fatal("a conflict must carry a diagnostic an operator can act on") + } +} + +// The resolver must be deterministic and order-independent, or a replay could +// reach a different owner for the same purchase than the original run did. +func TestResolutionIsOrderIndependent(t *testing.T) { + correlator := AliasDigest(AliasGoogleObfuscatedAcount, "obf-1") + aliases := map[string]string{string(correlator): "bcu_correlator"} + observations := []Observation{ + {EvidenceType: EvidenceAppAccountToken, Digest: correlator, AliasType: AliasGoogleObfuscatedAcount}, + {EvidenceType: EvidencePriorLineage, CustomerID: "bcu_prior"}, + {EvidenceType: EvidenceInstallation, Digest: []byte("ignored")}, + } + reversed := []Observation{observations[2], observations[1], observations[0]} + + forward := Resolve(observations, aliases) + backward := Resolve(reversed, aliases) + if forward.Outcome != backward.Outcome || forward.CustomerID != backward.CustomerID { + t.Fatalf("resolution depends on input order: %+v vs %+v", forward, backward) + } + // A prior accepted association outranks a bare correlator. + if forward.CustomerID != "bcu_prior" { + t.Fatalf("resolved to %q, want the prior lineage association", forward.CustomerID) + } +} + +// Alias digests are domain-separated by type. Without that, one person's +// application user id colliding with another's store correlator would collapse +// two people into one active resolution. +func TestAliasDigestIsDomainSeparatedByType(t *testing.T) { + value := "same-value" + if string(AliasDigest(AliasApplicationUser, value)) == string(AliasDigest(AliasInstallation, value)) { + t.Fatal("the same value digests identically across alias types") + } + if string(AliasDigest(AliasApplicationUser, value)) != string(AliasDigest(AliasApplicationUser, value)) { + t.Fatal("alias digest is not stable") + } +} + +// Adoption moves an already-granting purchase, so each accepted proof needs a +// pinning test. These cases protect against weakening Apple reference handling +// to match Google's bearer-grade token semantics. +func TestOwnershipProofForAnchorAdoption(t *testing.T) { + appleCorrelator := AliasDigest(AliasAppleAppAccountToken, "apple-account-token") + googleCorrelator := AliasDigest(AliasGoogleObfuscatedAcount, "google-account") + tests := []struct { + name string + observations []Observation + aliases map[string]string + wantProof string + wantProven bool + }{ + { + name: "google purchase token possession", + observations: []Observation{{EvidenceType: EvidenceTokenBoundSubmission, + CustomerID: "bcu_person", PossessionProof: true}}, + wantProof: ProofPurchaseTokenPossession, wantProven: true, + }, + { + name: "google provider correlator", + observations: []Observation{{EvidenceType: EvidenceObfuscatedAccount, Digest: googleCorrelator}}, + aliases: map[string]string{string(googleCorrelator): "bcu_person"}, + wantProof: ProofProviderCorrelator, wantProven: true, + }, + { + name: "apple provider correlator", + observations: []Observation{{EvidenceType: EvidenceAppAccountToken, Digest: appleCorrelator}}, + aliases: map[string]string{string(appleCorrelator): "bcu_person"}, + wantProof: ProofProviderCorrelator, wantProven: true, + }, + { + name: "secret server submission", + observations: []Observation{{EvidenceType: EvidenceTrustedServer, CustomerID: "bcu_person"}}, + wantProof: ProofTrustedServer, wantProven: true, + }, + { + name: "apple transaction reference possession is not proof", + observations: []Observation{{EvidenceType: EvidenceTokenBoundSubmission, + CustomerID: "bcu_person", PossessionProof: false}}, + wantProven: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + proof, proven := OwnershipProof(test.observations, test.aliases, "bcu_person") + if proven != test.wantProven || proof != test.wantProof { + t.Fatalf("proof = %q/%v, want %q/%v", proof, proven, test.wantProof, test.wantProven) + } + }) + } +} diff --git a/apps/api/internal/billingcustomer/seam.go b/apps/api/internal/billingcustomer/seam.go new file mode 100644 index 00000000..3346bb74 --- /dev/null +++ b/apps/api/internal/billingcustomer/seam.go @@ -0,0 +1,347 @@ +package billingcustomer + +import ( + "context" + "errors" + "time" + + "go.opentelemetry.io/otel/attribute" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" +) + +// FactAttachment is one committed Transaction Fact's claim on an identity. +// +// It carries digests only. The raw correlators were hashed in the validator, at +// the point they were parsed off the provider's authoritative response, and +// nothing in this package has ever been in a position to see one. +type FactAttachment struct { + ProjectID string + EnvironmentID string + Provider string + // LineageKeyDigest is the chain root the fact-commit transaction keyed the + // lineage on. + LineageKeyDigest []byte + // FactChainDigest is the fact's *own* chain digest, which differs from the + // root when the purchase chain has been handed a new provider token. + FactChainDigest []byte + RawInputID string + // ReferenceDigests are the transaction-reference digests under which a + // submitted observation may have recorded submission-context evidence. + ReferenceDigests [][]byte + // Correlators are the hashed provider correlators, in the evidence + // vocabulary. + Correlators []AttachmentCorrelator + ObservedAt time.Time +} + +// AttachmentCorrelator is one hashed provider correlator. +type AttachmentCorrelator struct { + EvidenceType string + AliasType string + Digest []byte +} + +// provesPossession reports whether a submission recorded under `referenceDigest` +// demonstrated possession of the purchase chain's own provider secret. +// +// Only Google qualifies, and the asymmetry is not an oversight. A Google +// transaction reference IS the SHA-256 of the purchase token — an unguessable +// secret the store issued to the purchasing device — so a submission keyed on it +// could only have come from something that held the token. An Apple reference is +// derived from a transaction identifier: a short decimal number, enumerable by +// anyone, which proves nothing about who bought anything. Accepting Apple +// possession as proof would turn adoption into a guessing game against every +// purchase-anchored customer in the Environment. +func (a FactAttachment) provesPossession(referenceDigest []byte) bool { + if a.Provider != billing.ProviderGooglePlay || len(referenceDigest) == 0 { + return false + } + return SameLineage(referenceDigest, a.LineageKeyDigest) || + SameLineage(referenceDigest, a.FactChainDigest) +} + +// AttachLineageForFact decides which Billing Customer owns the Purchase Lineage +// a newly committed fact belongs to, and applies the decision. +// +// This is the identity half of the Phase 9A→9B seam. It is the production +// caller `ResolveLineageCustomer`, `RecordSupersession`, and +// `EvidenceForReference` never had (defect D-1). +// +// The evidence ladder is OD-2's, in authority order, and the resolver — not this +// method — decides which rung wins: +// +// 1. Submission-context evidence. An SDK or an application backend that +// submitted an observation for this transaction while holding a Customer +// Access Token stated who it belongs to. This is the only thing in a +// deployed system that can attach a *first* purchase to an identified +// customer, because a store notification arrives out of band and names +// nobody. +// 2. Provider correlators — Apple's `appAccountToken`, Google's +// `obfuscatedExternalAccountId` — matched against alias digests a backend +// already attached. They rank below a submission because they say the app +// believed the purchase belonged to someone, not that the store agrees. +// 3. A prior association on the lineage itself, which `ResolveLineageCustomer` +// contributes. This is why a renewal on an established subscription does +// not have to re-prove identity. +// 4. Failing all of those, a purchase-anchored Billing Customer is created to +// hold the purchase (plan §5a rules 1 and 2). +// +// Conflicting evidence takes the existing conflict path: the lineage freezes, +// nobody is granted anything, and an operator resolves it (OD-10(a)). +// +// The whole method is idempotent. Re-running it for the same fact re-reads the +// same evidence, reaches the same verdict, and an association that already names +// the same customer is a no-op — which is what makes it safe to call after the +// fact's own transaction has already committed. +func (s *Service) AttachLineageForFact(ctx context.Context, attachment FactAttachment) (Resolution, error) { + ctx, span := s.tracer.Start(ctx, "billing.customer.attach_fact") + defer span.End() + + if err := s.requireEnabled(ctx, attachment.ProjectID); err != nil { + return Resolution{}, err + } + lineage, err := s.repository.LineageByKey(ctx, attachment.EnvironmentID, + attachment.Provider, attachment.LineageKeyDigest) + if err != nil { + // A fact with no lineage is not an error here. The fact-commit + // transaction creates one for every fact that names a purchase chain, so + // reaching this means the fact named none. + if errors.Is(err, ErrNotFound) { + return Resolution{Outcome: OutcomeUnresolved, DiagnosticCode: "no_lineage_for_fact"}, nil + } + return Resolution{}, ErrUnavailable + } + span.SetAttributes(attribute.String("mosaic.billing.lineage.id", lineage.ID)) + + if lineage.ProjectionFrozen { + // An operator owns this lineage's identity right now. Adding evidence to + // a dispute that is already open would neither help them nor change the + // answer, and re-running the resolver could open a second conflict for + // the same lineage. + return Resolution{Outcome: OutcomeConflicting, CustomerID: lineage.BillingCustomerID, + DiagnosticCode: "lineage_frozen_pending_operator"}, nil + } + + s.recordChainSupersession(ctx, attachment, lineage) + + observations, err := s.observationsFor(ctx, attachment) + if err != nil { + return Resolution{}, err + } + resolution, err := s.ResolveLineageCustomer(ctx, attachment.ProjectID, lineage.ID, observations) + if err != nil { + return Resolution{}, err + } + if resolution.Outcome != OutcomeUnresolved || lineage.BillingCustomerID != "" { + return resolution, nil + } + return s.anchorToNewCustomer(ctx, attachment, lineage) +} + +// observationsFor assembles rungs 1 and 2 of the ladder. Rung 3 is contributed +// by ResolveLineageCustomer from the lineage row itself. +func (s *Service) observationsFor(ctx context.Context, attachment FactAttachment) ([]Observation, error) { + observations := make([]Observation, 0, 4) + + seen := map[string]struct{}{} + for _, digest := range attachment.ReferenceDigests { + if len(digest) == 0 { + continue + } + if _, duplicate := seen[string(digest)]; duplicate { + continue + } + seen[string(digest)] = struct{}{} + entries, err := s.repository.EvidenceForReference(ctx, attachment.ProjectID, digest) + if err != nil { + return nil, ErrUnavailable + } + possession := attachment.provesPossession(digest) + for _, entry := range entries { + // Only evidence that named a customer is a candidate. An entry the + // resolver already refused, or one recorded for attribution alone, + // is history rather than a claim. + if entry.BillingCustomerID == "" || entry.PurchaseLineageID != "" { + continue + } + observations = append(observations, Observation{ + EvidenceType: entry.EvidenceType, + CustomerID: entry.BillingCustomerID, + RawInputID: entry.RawInputID, + PossessionProof: possession, + }) + } + } + + for _, correlator := range attachment.Correlators { + if len(correlator.Digest) == 0 { + continue + } + observations = append(observations, Observation{ + EvidenceType: correlator.EvidenceType, + AliasType: correlator.AliasType, + Digest: correlator.Digest, + RawInputID: attachment.RawInputID, + }) + } + return observations, nil +} + +// recordChainSupersession records a lineage-level supersession edge when the +// fact's own chain digest already had a lineage of its own that is not the root. +// +// A provider token handover inside one chain is *not* a lineage replacement — +// the projection loader walks those edges forward from the root and the whole +// chain is one lineage — so in the ordinary case there is no edge to record and +// this does nothing. The case it exists for is a link observed late: the +// successor token arrived first and was materialized as its own lineage, and +// only a later fact stated that it supersedes an earlier chain. Both lineages +// then exist, and the earlier one is the root. Nothing is deleted: the +// superseded lineage stops granting access and stays fully visible in history. +// +// A failure here is logged rather than returned. The edge is a refinement of +// history; refusing to attach a customer because it could not be written would +// deny access over a bookkeeping detail. +func (s *Service) recordChainSupersession(ctx context.Context, attachment FactAttachment, root Lineage) { + if len(attachment.FactChainDigest) == 0 || SameLineage(attachment.FactChainDigest, attachment.LineageKeyDigest) { + return + } + successor, err := s.repository.LineageByKey(ctx, attachment.EnvironmentID, + attachment.Provider, attachment.FactChainDigest) + if err != nil || successor.ID == "" || successor.ID == root.ID { + return + } + if successor.SupersededByLineageID == root.ID { + return + } + if err := s.RecordSupersession(ctx, attachment.ProjectID, successor.ID, root.ID); err != nil { + logSafely(ctx, "purchase lineage supersession edge could not be recorded", map[string]string{ + "project_id": attachment.ProjectID, "purchase_lineage_id": successor.ID, + }) + } +} + +// anchorToNewCustomer creates the Billing Customer a purchase with no +// identifying evidence attaches to (plan §5a rules 1 and 2). +// +// This is the second of exactly two ways a Billing Customer comes into +// existence, and it is what keeps an anonymous purchase from being lost: the +// store confirmed a real transaction, and Mosaic has to be able to answer for it +// on every entitlement surface whether or not anyone has said who bought it. +// Anchoring to the *lineage* rather than to the device is what makes it safe — +// the chain key survives reinstall, clear-data, and device change, so a +// reinstalling customer who restores resolves back to this same customer rather +// than accumulating a new one per install. That is the duplicate-customer trap +// plan §5a exists to avoid, and the reason installation identifiers are evidence +// and never anchors. +// +// When the person is identified later, `AttachApplicationUserAlias` appends the +// alias to this same customer. Login attaches; it never merges. +func (s *Service) anchorToNewCustomer(ctx context.Context, attachment FactAttachment, lineage Lineage) (Resolution, error) { + now := s.now() + customerID, err := s.newID("bcu") + if err != nil { + return Resolution{}, ErrUnavailable + } + customer, err := s.repository.CreateCustomer(ctx, Customer{ + ID: customerID, ProjectID: attachment.ProjectID, Status: StatusActive, + DiagnosticsStatus: "none", CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + return Resolution{}, ErrUnavailable + } + if err := s.repository.AttachLineageCustomer(ctx, attachment.ProjectID, lineage.ID, customer.ID, now); err != nil { + // The lineage was frozen or removed between the read above and here. + // The customer row that was just created is left in place rather than + // deleted: it holds nothing, it grants nothing, and deleting rows on a + // race is how an audit trail acquires holes. + return Resolution{}, ErrUnavailable + } + + evidenceID, err := s.newID("bae") + if err != nil { + return Resolution{}, ErrUnavailable + } + // No correlator digest, because the whole meaning of this row is that there + // was no correlator. + if err := s.repository.RecordEvidence(ctx, Evidence{ + ID: evidenceID, ProjectID: attachment.ProjectID, EnvironmentID: lineage.EnvironmentID, + PurchaseLineageID: lineage.ID, EvidenceType: EvidencePurchaseAnchor, + RawInputID: attachment.RawInputID, BillingCustomerID: customer.ID, + ResolverVersion: ResolverVersion, Outcome: OutcomeResolved, + DiagnosticCode: "no_identifying_evidence_purchase_anchored", + ObservedAt: now, CreatedAt: now, + }); err != nil { + return Resolution{}, ErrUnavailable + } + _ = s.repository.RecordAudit(ctx, Actor{}, attachment.ProjectID, "billing.customer.created", + "billing_customer", customer.ID, map[string]string{"creationPath": "purchase_anchor"}, now) + logSafely(ctx, "billing customer created", map[string]string{ + "project_id": attachment.ProjectID, "billing_customer_id": customer.ID, + "purchase_lineage_id": lineage.ID, "creation_path": "purchase_anchor", + }) + + // The projection job the fact-commit transaction enqueued was lineage-scoped, + // because at that moment the lineage had no customer. The aggregate that can + // actually mint an entitlement snapshot is the customer one, so it is + // enqueued now. + if err := s.scheduleReprojection(ctx, attachment.ProjectID, lineage.EnvironmentID, customer.ID); err != nil { + return Resolution{}, err + } + return Resolution{Outcome: OutcomeResolved, CustomerID: customer.ID, + DiagnosticCode: "purchase_anchored"}, nil +} + +// RecordSubmissionEvidence records that an observation was submitted by a caller +// holding a Customer Access Token for a named customer. +// +// It is keyed on the transaction reference rather than on a lineage because at +// submission time no lineage exists: the purchase has not been validated yet. +// `AttachLineageForFact` reads it back through `EvidenceForReference` once the +// fact commits, which is how a first purchase reaches an identified customer. +// +// The authority recorded depends on the credential that authenticated the +// request, and that distinction is the whole point of the parameter. +// +// `trusted_server_observation` (rank 90) is recorded only when the request +// itself was authenticated by the application's secret server key. That key +// lives on a server the application controls, so a submission carrying it is the +// application's own backend speaking. +// +// A request authenticated by the *public SDK key* records +// `token_bound_submission` instead, which ranks below a prior lineage +// association. The public key ships inside every install, so the only thing +// such a request proves is that the caller holds a Customer Access Token — and +// a device that legitimately held one keeps holding it after it stops being that +// person's device. Recording it at rank 90, as this used to, meant anyone able +// to present a token could take an established purchase away from its owner or +// freeze it in an identity conflict. Both were remote denial-of-access +// primitives against a paying customer. +func (s *Service) RecordSubmissionEvidence(ctx context.Context, projectID, environmentID, + rawInputID, customerID string, referenceDigest []byte, secretServerKey bool) error { + + if err := s.requireEnabled(ctx, projectID); err != nil { + return err + } + if customerID == "" || len(referenceDigest) == 0 { + return nil + } + id, err := s.newID("bae") + if err != nil { + return ErrUnavailable + } + evidenceType, diagnostic := EvidenceTokenBoundSubmission, "customer_access_token_submission_public_key" + if secretServerKey { + evidenceType, diagnostic = EvidenceTrustedServer, "customer_access_token_submission" + } + now := s.now() + return s.repository.RecordEvidence(ctx, Evidence{ + ID: id, ProjectID: projectID, EnvironmentID: environmentID, + EvidenceType: evidenceType, RawInputID: rawInputID, + TransactionReferenceDigest: referenceDigest, BillingCustomerID: customerID, + ResolverVersion: ResolverVersion, Outcome: OutcomeResolved, + DiagnosticCode: diagnostic, + ObservedAt: now, CreatedAt: now, + }) +} diff --git a/apps/api/internal/billingcustomer/service.go b/apps/api/internal/billingcustomer/service.go new file mode 100644 index 00000000..cbc1bd02 --- /dev/null +++ b/apps/api/internal/billingcustomer/service.go @@ -0,0 +1,874 @@ +package billingcustomer + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +// Service is the billing-identity application service. Handlers are thin +// wrappers over it; it owns every authorization decision, every transaction +// boundary, and the lazy-creation rules. +type Service struct { + repository Repository + keys ServerKeyAuthenticator + reprojector Reprojector + now func() time.Time + random io.Reader + tracer trace.Tracer +} + +type Option func(*Service) + +func WithClock(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +func WithRandom(random io.Reader) Option { + return func(s *Service) { + if random != nil { + s.random = random + } + } +} + +// NewService builds the identity service. +// +// The key authenticator and the reprojector are constructor arguments rather +// than options because neither is optional in a running system: without the +// first there is no trusted surface, and without the second an identity change +// leaves a stale grant in place, which is precisely review finding I-10. +func NewService(repository Repository, keys ServerKeyAuthenticator, reprojector Reprojector, options ...Option) *Service { + service := &Service{ + repository: repository, + keys: keys, + reprojector: reprojector, + now: func() time.Time { return time.Now().UTC() }, + random: rand.Reader, + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/billingcustomer"), + } + for _, option := range options { + option(service) + } + return service +} + +// CreateOrGetForApplicationUser is the trusted identify path: an application +// backend says "this is my user", and Mosaic returns the customer that user +// already has or creates one. +// +// This is one of exactly two ways a Billing Customer comes into existence +// (plan §5a). The other is a validated fact that needs somewhere to attach. +// SDK initialization and installation registration create nothing, which is +// what keeps Mosaic clear of the duplicate-customer trap that client-anchored +// systems fall into. +// +// The caller must already have been authenticated as a trusted server +// principal; a public SDK key can never reach this method, because an +// unverified user id accepted as authorization is an +// impersonate-anyone vulnerability. +// It reports whether the customer was created by this call, which is the only +// thing the caller cannot infer for itself and is what distinguishes a 201 from +// a 200 on the trusted surface. +func (s *Service) CreateOrGetForApplicationUser(ctx context.Context, projectID, applicationUserID string) (Customer, bool, error) { + ctx, span := s.tracer.Start(ctx, "billing.customer.identify") + defer span.End() + + if err := s.requireEnabled(ctx, projectID); err != nil { + return Customer{}, false, err + } + value := strings.TrimSpace(applicationUserID) + if value == "" || len(value) > 512 { + return Customer{}, false, ErrInvalidAlias + } + + digest := AliasDigest(AliasApplicationUser, value) + existing, err := s.repository.CustomerForAlias(ctx, projectID, AliasApplicationUser, digest) + switch { + case err == nil: + span.SetAttributes(attribute.Bool("mosaic.billing.customer.created", false)) + return existing, false, nil + case !errors.Is(err, ErrNotFound): + return Customer{}, false, ErrUnavailable + } + + now := s.now() + id, err := s.newID("bcu") + if err != nil { + return Customer{}, false, ErrUnavailable + } + customer, err := s.repository.CreateCustomer(ctx, Customer{ + ID: id, ProjectID: projectID, Status: StatusActive, + DiagnosticsStatus: "none", CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + return Customer{}, false, ErrUnavailable + } + + aliasID, err := s.newID("bca") + if err != nil { + return Customer{}, false, ErrUnavailable + } + alias := Alias{ + ID: aliasID, ProjectID: projectID, BillingCustomerID: customer.ID, + AliasType: AliasApplicationUser, SourceAuthority: AuthorityTrustedServer, + VerificationStatus: "verified", EffectiveStart: now, CreatedAt: now, + }.WithDigest(digest) + if _, err := s.repository.AttachAlias(ctx, alias); err != nil { + if errors.Is(err, ErrConflict) { + // Another request created the same identity concurrently. The + // partial unique index is the arbiter; re-read rather than + // creating a second customer for the same person. + if resolved, readErr := s.repository.CustomerForAlias(ctx, projectID, AliasApplicationUser, digest); readErr == nil { + return resolved, false, nil + } + } + return Customer{}, false, ErrUnavailable + } + _ = s.repository.RecordAudit(ctx, Actor{}, projectID, "billing.customer.created", + "billing_customer", customer.ID, map[string]string{"aliasType": AliasApplicationUser}, now) + logSafely(ctx, "billing customer created", map[string]string{ + "project_id": projectID, "billing_customer_id": customer.ID, "creation_path": "trusted_identify", + }) + span.SetAttributes(attribute.Bool("mosaic.billing.customer.created", true)) + return customer, true, nil +} + +// AttachApplicationUserAlias links an application user to an existing +// customer. Login attaches; it never merges (plan §5a rule 3). When the alias +// already resolves to a different customer with real purchases, the result is +// a conflict for an operator, not an automatic reassignment. +func (s *Service) AttachApplicationUserAlias(ctx context.Context, actor Actor, projectID, customerID, applicationUserID string) (Alias, error) { + if err := s.requireEnabled(ctx, projectID); err != nil { + return Alias{}, err + } + value := strings.TrimSpace(applicationUserID) + if value == "" || len(value) > 512 { + return Alias{}, ErrInvalidAlias + } + customer, err := s.repository.Customer(ctx, actor, projectID, customerID) + if err != nil { + return Alias{}, err + } + if customer.Status == StatusFrozen { + return Alias{}, ErrFrozen + } + + now := s.now() + aliasID, err := s.newID("bca") + if err != nil { + return Alias{}, ErrUnavailable + } + digest := AliasDigest(AliasApplicationUser, value) + alias, err := s.repository.AttachAlias(ctx, Alias{ + ID: aliasID, ProjectID: projectID, BillingCustomerID: customerID, + AliasType: AliasApplicationUser, SourceAuthority: AuthorityTrustedServer, + VerificationStatus: "verified", EffectiveStart: now, CreatedAt: now, + }.WithDigest(digest)) + if err != nil { + // Corrects review finding I-10. A digest that already has a live + // resolution elsewhere used to surface as a bare ErrConflict, which + // reads like a lost race and invites the caller to retry. It is not a + // race: one application user is claiming two customers, which is + // exactly the quarantine case of plan §5a rule 4. Open the conflict, + // freeze, audit, and tell the caller an operator now owns it. + if errors.Is(err, ErrConflict) { + return Alias{}, s.openAliasConflict(ctx, actor, projectID, customerID, digest, now) + } + return Alias{}, err + } + _ = s.repository.RecordAudit(ctx, actor, projectID, "billing.customer.alias_attached", + "billing_customer", customerID, map[string]string{"aliasType": AliasApplicationUser}, now) + return alias, nil +} + +// RecordInstallationEvidence records an installation identifier as association +// evidence and nothing else (plan §5a rule 2a, OD-4(a)). +// +// It deliberately returns no customer. The installation id is client-generated +// and guessable, so letting it select a customer would mean anyone who can +// forge one reads someone else's entitlements. It exists here to give +// purchase→install attribution at zero proliferation cost. +func (s *Service) RecordInstallationEvidence(ctx context.Context, projectID, environmentID, customerID, installationID string) error { + if err := s.requireEnabled(ctx, projectID); err != nil { + return err + } + value := strings.TrimSpace(installationID) + if value == "" || len(value) > 512 { + return ErrInvalidAlias + } + id, err := s.newID("bae") + if err != nil { + return ErrUnavailable + } + now := s.now() + return s.repository.RecordEvidence(ctx, Evidence{ + ID: id, ProjectID: projectID, EnvironmentID: environmentID, + EvidenceType: EvidenceInstallation, EvidenceDigest: AliasDigest(AliasInstallation, value), + BillingCustomerID: customerID, ResolverVersion: ResolverVersion, + // Always unsupported as a resolution input, by design. + Outcome: OutcomeUnsupported, DiagnosticCode: "installation_is_evidence_only", + ObservedAt: now, CreatedAt: now, + }) +} + +// ResolveLineageCustomer runs the association resolver for one lineage and +// applies its verdict. +// +// A resolved lineage is attached and projection may proceed. An unresolved one +// keeps its facts and projects nothing to any customer. A conflicting one +// opens a conflict and freezes the lineage, so neither candidate is granted +// anything automatically (OD-10(a)). +func (s *Service) ResolveLineageCustomer(ctx context.Context, projectID, lineageID string, observations []Observation) (Resolution, error) { + ctx, span := s.tracer.Start(ctx, "billing.customer.resolve_association") + defer span.End() + + if err := s.requireEnabled(ctx, projectID); err != nil { + return Resolution{}, err + } + lineage, err := s.repository.Lineage(ctx, projectID, lineageID) + if err != nil { + return Resolution{}, err + } + + digests := make([][]byte, 0, len(observations)) + for _, observation := range observations { + if len(observation.Digest) > 0 { + digests = append(digests, observation.Digest) + } + } + active, err := s.repository.ActiveAliasResolutions(ctx, projectID, digests) + if err != nil { + return Resolution{}, ErrUnavailable + } + if lineage.BillingCustomerID != "" { + // An association already accepted for this lineage is itself the + // highest-authority non-operator evidence: it is why a renewal on an + // established subscription does not have to re-prove identity. + observations = append(observations, Observation{ + EvidenceType: EvidencePriorLineage, CustomerID: lineage.BillingCustomerID, + }) + } + + resolution := Resolve(observations, active) + // Prior association normally outranks an observation. Purchase-anchored + // customers are the one deliberate exception: they represent nobody, and a + // claimant that proves ownership must be able to adopt the purchase even + // when its evidence rank is lower than the prior pointer. This is evaluated + // before the reassignment switch so all later safety rules remain shared. + if lineage.BillingCustomerID != "" && resolution.CustomerID == lineage.BillingCustomerID { + if anchored, anchorErr := s.repository.PurchaseAnchoredOnly(ctx, projectID, lineage.BillingCustomerID); anchorErr == nil && anchored { + provenCandidates := map[string]string{} + for _, observation := range observations { + candidateID := observation.CustomerID + if candidateID == "" && len(observation.Digest) > 0 { + candidateID = active[string(observation.Digest)] + } + if candidateID == "" || candidateID == lineage.BillingCustomerID { + continue + } + if proof, proven := OwnershipProof(observations, active, candidateID); proven { + provenCandidates[candidateID] = proof + } + } + if len(provenCandidates) == 1 { + for candidateID := range provenCandidates { + resolution.Outcome = OutcomeResolved + resolution.CustomerID = candidateID + } + } + } + } + + // A lineage that is already attached and whose evidence now names someone + // else is a reassignment, and there are three different right answers + // depending on who the incumbent is and what the challenger proved. + reassignedFrom, adoptedFrom, adoptionProof := "", "", "" + if resolution.Outcome == OutcomeResolved && lineage.BillingCustomerID != "" && + lineage.BillingCustomerID != resolution.CustomerID { + + anchored, anchorErr := s.repository.PurchaseAnchoredOnly(ctx, projectID, lineage.BillingCustomerID) + if anchorErr != nil { + // Fail closed. An unreadable incumbent must not be treated as an + // adoptable anchor, because adoption moves a purchase. + anchored = false + } + proof, proven := OwnershipProof(observations, active, resolution.CustomerID) + + switch { + case anchored && proven: + // Plan §5a rule 3, the adoption case. The incumbent exists only + // because a purchase needed somewhere to attach: it has no aliases + // and no evidence beyond the anchor itself, so nobody is behind it + // to lose access. The identified customer proved ownership, so it + // adopts the lineage rather than being told to wait for an operator + // who has strictly less information than the store just supplied. + adoptedFrom, adoptionProof = lineage.BillingCustomerID, proof + resolution.DiagnosticCode = DiagnosticAnchorAdopted + + case anchored: + // An adoption claim with nothing behind it. Recorded so it is + // visible on the operator's purchase-anchored customer view, and + // refused: without proof this is indistinguishable from someone + // asking to be given a stranger's purchase. + resolution.Outcome = OutcomeUnresolved + resolution.DiagnosticCode = DiagnosticAdoptionRequiresProof + + case resolution.DecidingRank <= RankTokenBoundSubmission: + // Corrects the Stage 5 blocking finding. A verdict carried only by + // a token-bound public-SDK-key submission (or by weaker evidence) + // may not move an established lineage, and — the part that matters + // — may not open a conflict either. Freezing on this evidence was a + // remote denial-of-access primitive: anybody able to present a + // token for a transaction could freeze the purchase of the customer + // who actually owns it. The claim is recorded and ignored. + resolution.Outcome = OutcomeUnresolved + resolution.DiagnosticCode = DiagnosticTokenBoundCannotReassign + + default: + // Corrects review finding I-10. Reassignment away from a customer + // that already holds the purchase is never automatic on strong + // evidence either. The move is downgraded to a conflict *before* + // evidence is written, so the persisted evidence records + // `conflicting` rather than a resolution that never happened. + reassignedFrom = lineage.BillingCustomerID + resolution.Outcome = OutcomeConflicting + // The incumbent is named first; the challenger the evidence proposed + // is second. An operator resolving with `assigned_second` is the + // explicit reassignment this path refuses to perform on its own. + resolution.ConflictWith = resolution.CustomerID + resolution.CustomerID = lineage.BillingCustomerID + resolution.DiagnosticCode = DiagnosticReassignmentBlocked + } + } + + now := s.now() + for _, observation := range resolution.Considered { + id, idErr := s.newID("bae") + if idErr != nil { + return Resolution{}, ErrUnavailable + } + outcome := resolution.Outcome + if authorityRank(observation.EvidenceType) == 0 { + outcome = OutcomeUnsupported + } + observationCustomerID := observation.CustomerID + if observationCustomerID == "" && len(observation.Digest) > 0 { + observationCustomerID = active[string(observation.Digest)] + } + if err := s.repository.RecordEvidence(ctx, Evidence{ + ID: id, ProjectID: projectID, EnvironmentID: lineage.EnvironmentID, + PurchaseLineageID: lineageID, EvidenceType: observation.EvidenceType, + EvidenceDigest: observation.Digest, RawInputID: observation.RawInputID, + // Preserve the customer this individual observation named. Using the + // final winner here erased the previous owner from prior-lineage + // evidence, which made a pointer move impossible to recover after a + // projection enqueue failure. + BillingCustomerID: observationCustomerID, ResolverVersion: ResolverVersion, + Outcome: outcome, DiagnosticCode: resolution.DiagnosticCode, + ObservedAt: now, CreatedAt: now, + }); err != nil { + return Resolution{}, ErrUnavailable + } + } + + switch resolution.Outcome { + case OutcomeResolved: + if lineage.BillingCustomerID == resolution.CustomerID { + // A previous attempt may have committed the pointer and then failed + // while enqueueing one of the affected customer aggregates. Persisted + // prior-lineage evidence is the retry record for that half-finished + // move, so an identical request converges instead of returning early + // with a stale or double grant still committed. + if err := s.recoverCommittedAssociation(ctx, projectID, lineage, observations, + active, resolution.CustomerID, now); err != nil { + return Resolution{}, err + } + return resolution, nil + } + if err := s.repository.AttachLineageCustomer(ctx, projectID, lineageID, resolution.CustomerID, now); err != nil { + return Resolution{}, ErrUnavailable + } + _ = s.repository.RecordAudit(ctx, Actor{}, projectID, "billing.lineage.customer_attached", + "purchase_lineage", lineageID, map[string]string{"billingCustomerId": resolution.CustomerID}, now) + + // An association that establishes who owns a purchase has to reach the + // customer aggregate, or the customer holds a lineage their committed + // snapshot does not mention. Any projection already queued for this + // lineage is lineage-scoped — it was queued when the lineage had no + // customer — and a lineage-scoped command deliberately mints no customer + // snapshot (defect D-4). This is the trigger that does. + // + // The error is returned rather than swallowed: attaching is idempotent, + // so a caller's retry re-reaches this point, whereas dropping the trigger + // leaves the grant unmade until some unrelated event happens to enqueue a + // projection. + if adoptedFrom != "" { + if err := s.completeAdoption(ctx, projectID, lineage, adoptedFrom, + resolution.CustomerID, adoptionProof, now); err != nil { + return Resolution{}, err + } + } + if err := s.scheduleReprojection(ctx, projectID, lineage.EnvironmentID, resolution.CustomerID); err != nil { + return Resolution{}, err + } + + case OutcomeConflicting: + conflictID, idErr := s.newID("bic") + if idErr != nil { + return Resolution{}, ErrUnavailable + } + if _, err := s.repository.OpenConflict(ctx, Conflict{ + ID: conflictID, ProjectID: projectID, Scope: ConflictScopeLineage, + PurchaseLineageID: lineageID, + Status: "open", FirstCustomerID: resolution.CustomerID, + SecondCustomerID: resolution.ConflictWith, DiagnosticCode: resolution.DiagnosticCode, + OpenedAt: now, + }); err != nil { + return Resolution{}, ErrUnavailable + } + _ = s.repository.RecordAudit(ctx, Actor{}, projectID, "billing.lineage.identity_conflict_opened", + "purchase_lineage", lineageID, map[string]string{ + "conflictScope": ConflictScopeLineage, "diagnosticCode": resolution.DiagnosticCode, + }, now) + logSafely(ctx, "billing lineage frozen by identity conflict", map[string]string{ + "project_id": projectID, "purchase_lineage_id": lineageID, + "diagnostic_code": resolution.DiagnosticCode, + }) + + // Corrects review finding I-10. Freezing the lineage only stops the + // *next* projection from using it; the customer that was already + // granted this purchase still has a committed snapshot saying so. The + // error is returned rather than swallowed: the freeze and the conflict + // are idempotent, so the caller's retry re-reaches this point, whereas + // dropping the trigger would leave the stale grant standing until some + // unrelated event happened to enqueue a projection. + if reassignedFrom != "" { + if err := s.scheduleReprojection(ctx, projectID, lineage.EnvironmentID, reassignedFrom); err != nil { + return Resolution{}, err + } + } + } + span.SetAttributes(attribute.String("mosaic.billing.association.outcome", resolution.Outcome)) + return resolution, nil +} + +// recoverCommittedAssociation finishes the projection side effects of an +// association whose lineage pointer already reached its intended customer. +// Prior-lineage evidence is written before the pointer, so it survives exactly +// the failure window this method repairs. +func (s *Service) recoverCommittedAssociation(ctx context.Context, projectID string, lineage Lineage, + observations []Observation, active map[string]string, currentCustomerID string, now time.Time) error { + + previous, err := s.repository.PriorLineageCustomers(ctx, projectID, lineage.ID) + if err != nil { + return ErrUnavailable + } + proof, proven := OwnershipProof(observations, active, currentCustomerID) + for _, previousCustomerID := range previous { + if previousCustomerID == "" || previousCustomerID == currentCustomerID { + continue + } + anchored, anchorErr := s.repository.PurchaseAnchoredOnly(ctx, projectID, previousCustomerID) + if anchorErr != nil { + return ErrUnavailable + } + recorded, recordErr := s.repository.AdoptionRecorded(ctx, projectID, lineage.ID, currentCustomerID) + if recordErr != nil { + return ErrUnavailable + } + if anchored && recorded { + remaining, countErr := s.repository.LineageCountForCustomer(ctx, projectID, previousCustomerID) + if countErr != nil { + return ErrUnavailable + } + if remaining == 0 { + if err := s.repository.SetCustomerStatus(ctx, projectID, previousCustomerID, StatusAbsorbed, now); err != nil { + return ErrUnavailable + } + } + if err := s.scheduleReprojection(ctx, projectID, lineage.EnvironmentID, previousCustomerID); err != nil { + return err + } + continue + } + if anchored && proven { + if err := s.completeAdoption(ctx, projectID, lineage, previousCustomerID, + currentCustomerID, proof, now); err != nil { + return err + } + continue + } + if err := s.scheduleReprojection(ctx, projectID, lineage.EnvironmentID, previousCustomerID); err != nil { + return err + } + } + return s.scheduleReprojection(ctx, projectID, lineage.EnvironmentID, currentCustomerID) +} + +// completeAdoption finishes an anchored-customer adoption (plan §5a rule 3) +// after the lineage pointer has already moved. +// +// The anchor row is never deleted. Entitlement snapshots, association evidence, +// and audit events already cite it, and a support investigation has to be able +// to follow the purchase from the anchor to the person who turned out to own it. +// It is marked `absorbed` instead — but only once it holds no lineages at all, +// because a customer that still holds a granting purchase is not absorbed, it is +// active, and saying otherwise on an operator surface would be a lie about who +// currently owns what. An anchor holds exactly one lineage by construction +// (`anchorToNewCustomer` mints one customer per unattached lineage and nothing +// else ever attaches one to it), so in practice the guard always passes; it +// exists so that if that ever stops being true the consequence is a visible +// leftover rather than a silent misstatement. +// +// Both customers are reprojected. The loser is the one holding a committed +// snapshot that still grants the purchase, which is exactly the stale grant +// review finding I-10 is about. +func (s *Service) completeAdoption(ctx context.Context, projectID string, lineage Lineage, + anchorID, adopterID, proof string, now time.Time) error { + + evidenceID, err := s.newID("bae") + if err != nil { + return ErrUnavailable + } + if err := s.repository.RecordEvidence(ctx, Evidence{ + ID: evidenceID, ProjectID: projectID, EnvironmentID: lineage.EnvironmentID, + PurchaseLineageID: lineage.ID, EvidenceType: EvidenceAnchorAdoption, + BillingCustomerID: adopterID, ResolverVersion: ResolverVersion, + Outcome: OutcomeResolved, DiagnosticCode: DiagnosticAnchorAdopted + ":" + proof, + ObservedAt: now, CreatedAt: now, + }); err != nil { + return ErrUnavailable + } + + remaining, err := s.repository.LineageCountForCustomer(ctx, projectID, anchorID) + if err != nil { + return ErrUnavailable + } + if remaining == 0 { + if err := s.repository.SetCustomerStatus(ctx, projectID, anchorID, StatusAbsorbed, now); err != nil { + return ErrUnavailable + } + } + _ = s.repository.RecordAudit(ctx, Actor{}, projectID, "billing.customer.anchor_adopted", + "billing_customer", anchorID, map[string]string{ + "adoptedByBillingCustomerId": adopterID, + "purchaseLineageId": lineage.ID, + "ownershipProof": proof, + }, now) + logSafely(ctx, "purchase-anchored billing customer adopted", map[string]string{ + "project_id": projectID, "billing_customer_id": anchorID, + "adopted_by_billing_customer_id": adopterID, "purchase_lineage_id": lineage.ID, + "ownership_proof": proof, + }) + + return s.scheduleReprojection(ctx, projectID, lineage.EnvironmentID, anchorID) +} + +// RecordSupersession records that one lineage was replaced by another. Nothing +// is deleted: the superseded lineage stops granting access and stays fully +// visible in history. +func (s *Service) RecordSupersession(ctx context.Context, projectID, supersededID, successorID string) error { + if err := s.requireEnabled(ctx, projectID); err != nil { + return err + } + if supersededID == successorID { + return ErrConflict + } + now := s.now() + if err := s.repository.SetLineageSupersededBy(ctx, projectID, supersededID, successorID, now); err != nil { + return ErrUnavailable + } + return s.repository.RecordAudit(ctx, Actor{}, projectID, "billing.lineage.superseded", + "purchase_lineage", supersededID, map[string]string{"supersededByLineageId": successorID}, now) +} + +// Customer reads one customer for an authorized operator. +func (s *Service) Customer(ctx context.Context, actor Actor, projectID, customerID string) (Customer, error) { + if err := s.requireEnabled(ctx, projectID); err != nil { + return Customer{}, err + } + return s.repository.Customer(ctx, actor, projectID, customerID) +} + +// ListCustomers pages a Project's customers for an authorized operator. +func (s *Service) ListCustomers(ctx context.Context, actor Actor, projectID string, limit int, cursor string) ([]Customer, string, error) { + if err := s.requireEnabled(ctx, projectID); err != nil { + return nil, "", err + } + if limit <= 0 || limit > 100 { + limit = 50 + } + return s.repository.ListCustomers(ctx, actor, projectID, limit, cursor) +} + +// ListAliases returns a customer's alias history. Digest values are not part +// of the response shape: an alias digest is still a stable per-person +// identifier and nothing on an operator surface needs it. +func (s *Service) ListAliases(ctx context.Context, actor Actor, projectID, customerID string) ([]Alias, error) { + if err := s.requireEnabled(ctx, projectID); err != nil { + return nil, err + } + return s.repository.ListAliases(ctx, actor, projectID, customerID) +} + +// ListConflicts returns open identity conflicts for operator resolution. +func (s *Service) ListConflicts(ctx context.Context, actor Actor, projectID, status string) ([]Conflict, error) { + if err := s.requireEnabled(ctx, projectID); err != nil { + return nil, err + } + if status == "" { + status = "open" + } + return s.repository.ListConflicts(ctx, actor, projectID, status) +} + +// ConflictDetail returns one conflict with the lineage it disputes, which is +// what an operator needs before choosing a resolution action. +func (s *Service) ConflictDetail(ctx context.Context, actor Actor, projectID, conflictID string) (ConflictDetail, error) { + if err := s.requireEnabled(ctx, projectID); err != nil { + return ConflictDetail{}, err + } + conflict, err := s.repository.Conflict(ctx, actor, projectID, strings.TrimSpace(conflictID)) + if err != nil { + return ConflictDetail{}, err + } + detail := ConflictDetail{Conflict: conflict} + if conflict.Scope != ConflictScopeAlias && conflict.PurchaseLineageID != "" { + lineage, lineageErr := s.repository.Lineage(ctx, projectID, conflict.PurchaseLineageID) + if lineageErr != nil { + // The conflict is still worth returning without it; an operator can + // act on the customer identifiers alone. + return detail, nil + } + detail.Lineage = &lineage + } + return detail, nil +} + +// MaxResolutionReasonLength bounds the operator's stated justification. The +// conflict detail document is capped at 2 KiB by the schema, so the reason is +// bounded well inside it rather than being allowed to consume the whole budget. +const MaxResolutionReasonLength = 500 + +// ResolveConflict applies an operator's decision and unfreezes the lineage. +// There is deliberately no automatic-merge path: automatic merge stays an ADR +// checkpoint, not something a heuristic reaches on its own. +// +// `reason` is required. The three actions map to the OD-10 vocabulary: +// `assigned_first` keeps the incumbent, `assigned_second` reassigns to the +// challenger, and `detached_both` is the operator split that awards the +// disputed subject to neither. All three move committed access for at least one +// customer, so none of them may be taken without a recorded justification. +func (s *Service) ResolveConflict(ctx context.Context, actor Actor, projectID, conflictID, action, assignedCustomerID, reason string) (Conflict, error) { + if err := s.requireEnabled(ctx, projectID); err != nil { + return Conflict{}, err + } + switch action { + case "assigned_first", "assigned_second", "detached_both": + default: + return Conflict{}, ErrConflict + } + reason = strings.TrimSpace(reason) + if reason == "" || len(reason) > MaxResolutionReasonLength { + return Conflict{}, ErrInvalidAlias + } + now := s.now() + conflict, err := s.repository.ResolveConflict(ctx, actor, projectID, conflictID, action, assignedCustomerID, reason, now) + if err != nil { + return Conflict{}, err + } + + environmentID := "" + switch conflict.Scope { + case ConflictScopeAlias: + // An alias conflict froze the customer the caller tried to extend, + // because there was no lineage to freeze. Release it. + if err := s.repository.SetCustomerStatus(ctx, projectID, conflict.FirstCustomerID, StatusActive, now); err != nil { + return Conflict{}, ErrUnavailable + } + default: + lineage, lineageErr := s.repository.Lineage(ctx, projectID, conflict.PurchaseLineageID) + if lineageErr != nil { + return Conflict{}, ErrUnavailable + } + environmentID = lineage.EnvironmentID + if err := s.repository.SetLineageFrozen(ctx, projectID, conflict.PurchaseLineageID, false, "none", now); err != nil { + return Conflict{}, ErrUnavailable + } + } + + // The reason is on the audit event as well as on the conflict row. The row + // is the current state of one dispute; the audit trail is what an + // investigation reads, and it must not have to join back to a row that a + // later resolution could have rewritten. + _ = s.repository.RecordAudit(ctx, actor, projectID, "billing.identity_conflict.resolved", + "billing_identity_conflict", conflictID, map[string]string{ + "action": action, "conflictScope": conflict.Scope, "reason": reason, + }, now) + + // Corrects review finding I-10. Both candidates are reprojected, not only + // the assigned one: whichever customer loses the lineage is the one holding + // a committed snapshot that still grants it, and that is the stale grant + // the finding is about. Reprojection is idempotent and a no-change + // projection is a first-class outcome, so reprojecting the winner too costs + // nothing and removes a whole class of "which side needed it" reasoning. + if environmentID != "" { + for _, customerID := range dedupe(conflict.FirstCustomerID, conflict.SecondCustomerID, assignedCustomerID) { + if err := s.scheduleReprojection(ctx, projectID, environmentID, customerID); err != nil { + return Conflict{}, err + } + } + } + return conflict, nil +} + +// openAliasConflict records that one application-user alias claims two +// customers, freezes the customer the caller tried to extend, and refuses the +// attachment. Corrects review finding I-10. +// +// The freeze lands on the customer named in the request rather than on both: +// the other customer's grants come from its own lineages and are not in +// dispute, and freezing a paying customer's identity because someone else's +// backend sent a bad attach would be a self-inflicted outage. Freezing the +// requested customer is what makes the next identical request fail closed +// instead of quietly retrying the same reassignment. +func (s *Service) openAliasConflict(ctx context.Context, actor Actor, projectID, customerID string, digest []byte, now time.Time) error { + other, err := s.repository.CustomerForAlias(ctx, projectID, AliasApplicationUser, digest) + if err != nil { + // The alias could not be read back. Report the original conflict rather + // than inventing a conflict record against an unknown counterparty. + return ErrConflict + } + if other.ID == customerID { + // The alias is already attached to this very customer. Attaching twice + // is not a conflict worth an operator's time. + return ErrConflict + } + + conflictID, idErr := s.newID("bic") + if idErr != nil { + return ErrUnavailable + } + if _, err := s.repository.OpenConflict(ctx, Conflict{ + ID: conflictID, ProjectID: projectID, Scope: ConflictScopeAlias, + AliasType: AliasApplicationUser, Status: "open", + FirstCustomerID: customerID, SecondCustomerID: other.ID, + DiagnosticCode: DiagnosticAliasClaimsTwoCustomers, OpenedAt: now, + }.WithDigest(digest)); err != nil { + return ErrUnavailable + } + if err := s.repository.SetCustomerStatus(ctx, projectID, customerID, StatusFrozen, now); err != nil { + return ErrUnavailable + } + _ = s.repository.RecordAudit(ctx, actor, projectID, "billing.customer.identity_conflict_opened", + "billing_customer", customerID, map[string]string{ + "conflictScope": ConflictScopeAlias, "aliasType": AliasApplicationUser, + "diagnosticCode": DiagnosticAliasClaimsTwoCustomers, + }, now) + // Identifiers only. The alias value and its digest never reach a log. + logSafely(ctx, "billing customer frozen by alias identity conflict", map[string]string{ + "project_id": projectID, "billing_customer_id": customerID, + "billing_identity_conflict_id": conflictID, + }) + return ErrIdentityConflict +} + +// scheduleReprojection asks the projection module to recompute one customer's +// aggregate. Corrects review finding I-10: an identity change that does not +// reach here leaves the previous owner's committed snapshot granting a purchase +// it no longer holds. +func (s *Service) scheduleReprojection(ctx context.Context, projectID, environmentID, customerID string) error { + if customerID == "" || environmentID == "" { + return nil + } + if s.reprojector == nil { + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("billing_customer_id", customerID). + Msg("no reprojector is wired; a stale entitlement grant cannot be recomputed") + return ErrUnavailable + } + err := s.reprojector.Enqueue(ctx, billingprojection.Scope{ + ProjectID: projectID, EnvironmentID: environmentID, CustomerID: customerID, + }, billingprojection.KindAssociationEstablished) + switch { + case err == nil: + return nil + case errors.Is(err, billingprojection.ErrBillingDisabled): + return ErrBillingDisabled + default: + return ErrUnavailable + } +} + +func dedupe(values ...string) []string { + seen := map[string]struct{}{} + result := make([]string, 0, len(values)) + for _, value := range values { + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +// requireEnabled fails closed. A Project that turned billing off holds no +// billing identity, and a transient read failure must not be able to break +// that promise — so an unreadable setting is treated as disabled and reported, +// exactly as the 9A ingestion path does. +func (s *Service) requireEnabled(ctx context.Context, projectID string) error { + enabled, err := s.repository.BillingEnabled(ctx, projectID) + if err != nil { + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("billing_error_kind", fmt.Sprintf("%T", err)). + Msg("billing enablement could not be read; treating the Project as disabled") + return ErrBillingDisabled + } + if !enabled { + return ErrBillingDisabled + } + return nil +} + +func (s *Service) newID(prefix string) (string, error) { + buffer := make([]byte, 16) + if _, err := io.ReadFull(s.random, buffer); err != nil { + return "", fmt.Errorf("generate billing identity identifier: %w", err) + } + return prefix + "_" + base64.RawURLEncoding.EncodeToString(buffer), nil +} + +// logSafely writes an operator line with identifiers only. No alias value, no +// digest, and no correlator ever reaches a log. +func logSafely(ctx context.Context, message string, fields map[string]string) { + event := zerolog.Ctx(ctx).Info() + for key, value := range fields { + if value != "" { + event = event.Str(key, value) + } + } + event.Msg(message) +} diff --git a/apps/api/internal/billingcustomer/service_test.go b/apps/api/internal/billingcustomer/service_test.go new file mode 100644 index 00000000..361330f4 --- /dev/null +++ b/apps/api/internal/billingcustomer/service_test.go @@ -0,0 +1,402 @@ +package billingcustomer + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +// These tests protect the two identity outcomes that cannot be undone later: a +// purchase moving to the wrong customer, and a client-generated identifier +// being able to reach someone else's customer at all. + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +type recordedProjection struct { + scope billingprojection.Scope + kind string +} + +type stubReprojector struct { + enqueued []recordedProjection + failures int +} + +func (s *stubReprojector) Enqueue(_ context.Context, scope billingprojection.Scope, kind string) error { + s.enqueued = append(s.enqueued, recordedProjection{scope: scope, kind: kind}) + if s.failures > 0 { + s.failures-- + return errors.New("injected enqueue failure") + } + return nil +} + +// stubRepository records what the service asked persistence to do. It answers +// reads from fields the test sets, and every write is captured rather than +// applied, so an assertion can distinguish "was not attempted" from "was +// attempted and failed". +type stubRepository struct { + lineage Lineage + aliasResolution map[string]string + + createdCustomers []Customer + attachedAliases []Alias + evidence []Evidence + conflicts []Conflict + lineageAttachments []string + frozenLineages map[string]bool + customerStatusCalls map[string]string + // anchoredCustomers and lineageCounts drive the anchored-customer adoption + // precondition (plan §5a rule 3). + anchoredCustomers map[string]bool + lineageCounts map[string]int + adoptions map[string]bool +} + +func newStubRepository() *stubRepository { + return &stubRepository{ + aliasResolution: map[string]string{}, + frozenLineages: map[string]bool{}, + customerStatusCalls: map[string]string{}, + anchoredCustomers: map[string]bool{}, + lineageCounts: map[string]int{}, + adoptions: map[string]bool{}, + } +} + +func (s *stubRepository) BillingEnabled(context.Context, string) (bool, error) { return true, nil } + +func (s *stubRepository) CreateCustomer(_ context.Context, customer Customer) (Customer, error) { + s.createdCustomers = append(s.createdCustomers, customer) + return customer, nil +} + +func (s *stubRepository) Customer(_ context.Context, _ Actor, projectID, customerID string) (Customer, error) { + return Customer{ID: customerID, ProjectID: projectID, Status: StatusActive}, nil +} + +func (s *stubRepository) CustomerForAlias(_ context.Context, projectID, _ string, digest []byte) (Customer, error) { + if id, ok := s.aliasResolution[string(digest)]; ok { + return Customer{ID: id, ProjectID: projectID, Status: StatusActive}, nil + } + return Customer{}, ErrNotFound +} + +func (s *stubRepository) ListCustomers(context.Context, Actor, string, int, string) ([]Customer, string, error) { + return nil, "", nil +} + +func (s *stubRepository) SetCustomerStatus(_ context.Context, _, customerID, status string, _ time.Time) error { + s.customerStatusCalls[customerID] = status + return nil +} + +func (s *stubRepository) AttachAlias(_ context.Context, alias Alias) (Alias, error) { + if _, taken := s.aliasResolution[string(alias.Digest())]; taken { + return Alias{}, ErrConflict + } + s.attachedAliases = append(s.attachedAliases, alias) + s.aliasResolution[string(alias.Digest())] = alias.BillingCustomerID + return alias, nil +} + +func (s *stubRepository) RevokeAlias(context.Context, Actor, string, string, time.Time) error { + return nil +} + +func (s *stubRepository) ListAliases(context.Context, Actor, string, string) ([]Alias, error) { + return nil, nil +} + +func (s *stubRepository) ActiveAliasResolutions(context.Context, string, [][]byte) (map[string]string, error) { + return s.aliasResolution, nil +} + +func (s *stubRepository) RecordEvidence(_ context.Context, evidence Evidence) error { + s.evidence = append(s.evidence, evidence) + if evidence.EvidenceType == EvidenceAnchorAdoption { + s.adoptions[evidence.PurchaseLineageID+"\x00"+evidence.BillingCustomerID] = true + } + return nil +} + +func (s *stubRepository) PriorLineageCustomers(_ context.Context, _, lineageID string) ([]string, error) { + customers := []string{} + for _, evidence := range s.evidence { + if evidence.PurchaseLineageID == lineageID && evidence.EvidenceType == EvidencePriorLineage && evidence.BillingCustomerID != "" { + customers = append(customers, evidence.BillingCustomerID) + } + } + return dedupe(customers...), nil +} + +func (s *stubRepository) AdoptionRecorded(_ context.Context, _, lineageID, adopterID string) (bool, error) { + return s.adoptions[lineageID+"\x00"+adopterID], nil +} + +func (s *stubRepository) EvidenceForReference(context.Context, string, []byte) ([]Evidence, error) { + return nil, nil +} + +func (s *stubRepository) PurchaseAnchoredOnly(_ context.Context, _, customerID string) (bool, error) { + return s.anchoredCustomers[customerID], nil +} + +func (s *stubRepository) LineageCountForCustomer(_ context.Context, _, customerID string) (int, error) { + return s.lineageCounts[customerID], nil +} + +func (s *stubRepository) Lineage(context.Context, string, string) (Lineage, error) { + return s.lineage, nil +} + +func (s *stubRepository) LineageByKey(context.Context, string, string, []byte) (Lineage, error) { + return s.lineage, nil +} + +func (s *stubRepository) AttachLineageCustomer(_ context.Context, _, lineageID, customerID string, _ time.Time) error { + s.lineageAttachments = append(s.lineageAttachments, lineageID+"->"+customerID) + s.lineage.BillingCustomerID = customerID + return nil +} + +func (s *stubRepository) SetLineageSupersededBy(context.Context, string, string, string, time.Time) error { + return nil +} + +func (s *stubRepository) SetLineageFrozen(_ context.Context, _, lineageID string, frozen bool, _ string, _ time.Time) error { + s.frozenLineages[lineageID] = frozen + return nil +} + +func (s *stubRepository) OpenConflict(_ context.Context, conflict Conflict) (Conflict, error) { + s.conflicts = append(s.conflicts, conflict) + if conflict.Scope == ConflictScopeLineage { + s.frozenLineages[conflict.PurchaseLineageID] = true + s.lineage.ProjectionFrozen = true + } + return conflict, nil +} + +func (s *stubRepository) Conflict(context.Context, Actor, string, string) (Conflict, error) { + return Conflict{}, ErrNotFound +} + +func (s *stubRepository) ListConflicts(context.Context, Actor, string, string) ([]Conflict, error) { + return nil, nil +} + +func (s *stubRepository) ResolveConflict(context.Context, Actor, string, string, string, string, string, time.Time) (Conflict, error) { + return Conflict{}, ErrNotFound +} + +func (s *stubRepository) RecordAudit(context.Context, Actor, string, string, string, string, map[string]string, time.Time) error { + return nil +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// Review finding I-10. Higher-authority evidence naming a different customer +// used to move an already-attached lineage silently. Two things went wrong at +// once: no operator ever saw that a purchase changed hands, and the customer +// that lost the purchase kept a committed entitlement snapshot still granting +// it — a stale grant with no event left to recompute it. +// +// The realistic failure this catches is one backend sending an incorrect +// application user id for an existing subscriber: the subscription silently +// transfers, the original paying customer keeps access they no longer own, and +// nothing in the system records that it happened. +func TestReassignmentOpensConflictAndReprojectsPreviousCustomer(t *testing.T) { + repository := newStubRepository() + repository.lineage = Lineage{ + ID: "bpl_1", ProjectID: "prj_1", EnvironmentID: "env_1", + BillingCustomerID: "bcu_incumbent", + } + reprojector := &stubReprojector{} + service := NewService(repository, nil, reprojector, + WithClock(func() time.Time { return time.Unix(1700000000, 0).UTC() })) + + resolution, err := service.ResolveLineageCustomer(context.Background(), "prj_1", "bpl_1", + []Observation{{EvidenceType: EvidenceTrustedServer, CustomerID: "bcu_challenger"}}) + if err != nil { + t.Fatalf("resolve returned %v", err) + } + + if resolution.Outcome != OutcomeConflicting { + t.Fatalf("outcome %q/%q; a reassignment away from an attached customer must conflict, not resolve", + resolution.Outcome, resolution.CustomerID) + } + if len(repository.lineageAttachments) != 0 { + t.Fatalf("the lineage was moved to %v; reassignment must never happen automatically", + repository.lineageAttachments) + } + if len(repository.conflicts) != 1 { + t.Fatalf("opened %d conflicts, want exactly one operator-resolvable record", len(repository.conflicts)) + } + conflict := repository.conflicts[0] + if conflict.Scope != ConflictScopeLineage || + conflict.FirstCustomerID != "bcu_incumbent" || conflict.SecondCustomerID != "bcu_challenger" { + t.Fatalf("conflict %+v does not name the incumbent first and the challenger second", conflict) + } + if conflict.DiagnosticCode != DiagnosticReassignmentBlocked { + t.Fatalf("conflict diagnostic %q, want %q", conflict.DiagnosticCode, DiagnosticReassignmentBlocked) + } + if !repository.frozenLineages["bpl_1"] { + t.Fatal("the disputed lineage was not frozen; the next projection would grant one candidate anyway") + } + + if len(reprojector.enqueued) != 1 { + t.Fatalf("scheduled %d reprojections, want one for the customer losing the purchase", + len(reprojector.enqueued)) + } + scheduled := reprojector.enqueued[0] + if scheduled.scope.CustomerID != "bcu_incumbent" || + scheduled.scope.ProjectID != "prj_1" || scheduled.scope.EnvironmentID != "env_1" { + t.Fatalf("reprojected %+v; the previously attached customer holds the stale grant", scheduled.scope) + } + + // Persisted evidence must record the outcome that actually happened. A row + // stamped `resolved` for a resolution the service refused to apply would + // make a later replay reconstruct a transfer that never occurred. + for _, evidence := range repository.evidence { + if evidence.Outcome == OutcomeResolved { + t.Fatalf("evidence %q was recorded as resolved for a blocked reassignment", evidence.EvidenceType) + } + } +} + +// An installation identifier is client-generated and guessable. If it could +// select or create a customer, forging one would read someone else's +// entitlements (OD-4(a), plan §5a rule 2a). resolver_test.go pins the pure +// rule; this pins the service paths that persist, which is where a future +// convenience shortcut would actually be added. +func TestInstallationAliasNeverSelectsOrCreatesCustomer(t *testing.T) { + repository := newStubRepository() + repository.lineage = Lineage{ID: "bpl_2", ProjectID: "prj_1", EnvironmentID: "env_1"} + // A victim customer already owns this installation digest. + installationDigest := AliasDigest(AliasInstallation, "install-abc") + repository.aliasResolution[string(installationDigest)] = "bcu_victim" + + service := NewService(repository, nil, &stubReprojector{}, + WithClock(func() time.Time { return time.Unix(1700000000, 0).UTC() })) + + // Recording installation evidence creates nothing. + if err := service.RecordInstallationEvidence(context.Background(), + "prj_1", "env_1", "", "install-abc"); err != nil { + t.Fatalf("record installation evidence: %v", err) + } + if len(repository.createdCustomers) != 0 { + t.Fatalf("installation registration created %d customers; it must create none", + len(repository.createdCustomers)) + } + if len(repository.attachedAliases) != 0 { + t.Fatal("installation registration attached an alias; the installation id is evidence only") + } + + // And it cannot select one either, even when it already resolves. + resolution, err := service.ResolveLineageCustomer(context.Background(), "prj_1", "bpl_2", + []Observation{{ + EvidenceType: EvidenceInstallation, Digest: installationDigest, + AliasType: AliasInstallation, + }}) + if err != nil { + t.Fatalf("resolve returned %v", err) + } + if resolution.Outcome != OutcomeUnresolved || resolution.CustomerID != "" { + t.Fatalf("installation evidence produced %q/%q; it must never select a customer", + resolution.Outcome, resolution.CustomerID) + } + if len(repository.lineageAttachments) != 0 { + t.Fatalf("the lineage was attached via installation evidence: %v", repository.lineageAttachments) + } + if len(repository.createdCustomers) != 0 { + t.Fatal("resolution created a customer from installation evidence") + } +} + +// A public SDK key plus a Customer Access Token is intentionally weaker than +// an established purchase association. The realistic abuse is a device that +// retained an old token submitting somebody else's transaction reference to +// freeze or steal that paying customer's lineage. +func TestTokenBoundSubmissionCannotReassignOrFreezeAttachedLineage(t *testing.T) { + repository := newStubRepository() + repository.lineage = Lineage{ID: "bpl_attached", ProjectID: "prj_1", EnvironmentID: "env_1", BillingCustomerID: "bcu_owner"} + reprojector := &stubReprojector{} + service := NewService(repository, nil, reprojector) + + resolution, err := service.ResolveLineageCustomer(context.Background(), "prj_1", "bpl_attached", + []Observation{{EvidenceType: EvidenceTokenBoundSubmission, CustomerID: "bcu_attacker"}}) + if err != nil { + t.Fatalf("resolve token-bound claim: %v", err) + } + if resolution.CustomerID != "bcu_owner" || repository.lineage.BillingCustomerID != "bcu_owner" { + t.Fatalf("token-bound claim moved lineage to %q", repository.lineage.BillingCustomerID) + } + if len(repository.conflicts) != 0 || repository.frozenLineages["bpl_attached"] { + t.Fatal("token-bound claim opened a conflict or froze the attached lineage") + } +} + +// The pointer and the prior-lineage evidence may commit before the projection +// queue reports a transient failure. Retrying the same adoption must find both +// affected customers and converge, otherwise the anchor keeps a stale grant +// while the adopter receives a second one. +func TestAdoptionRetryAfterReprojectorFailureReprojectsBothCustomers(t *testing.T) { + repository := newStubRepository() + repository.lineage = Lineage{ID: "bpl_anchor", ProjectID: "prj_1", EnvironmentID: "env_1", BillingCustomerID: "bcu_anchor"} + repository.anchoredCustomers["bcu_anchor"] = true + repository.lineageCounts["bcu_anchor"] = 0 + reprojector := &stubReprojector{failures: 1} + service := NewService(repository, nil, reprojector) + observation := []Observation{{EvidenceType: EvidenceTrustedServer, CustomerID: "bcu_person"}} + + if _, err := service.ResolveLineageCustomer(context.Background(), "prj_1", "bpl_anchor", observation); !errors.Is(err, ErrUnavailable) { + t.Fatalf("first adoption error = %v, want unavailable", err) + } + if repository.lineage.BillingCustomerID != "bcu_person" { + t.Fatalf("pointer did not commit before injected failure: %q", repository.lineage.BillingCustomerID) + } + if _, err := service.ResolveLineageCustomer(context.Background(), "prj_1", "bpl_anchor", observation); err != nil { + t.Fatalf("retry adoption: %v", err) + } + + reprojected := map[string]bool{} + for _, projection := range reprojector.enqueued { + reprojected[projection.scope.CustomerID] = true + } + if !reprojected["bcu_anchor"] || !reprojected["bcu_person"] { + t.Fatalf("retry projections = %v, want anchor and adopter", reprojector.enqueued) + } + if repository.customerStatusCalls["bcu_anchor"] != StatusAbsorbed { + t.Fatalf("anchor status = %q, want absorbed", repository.customerStatusCalls["bcu_anchor"]) + } +} + +// Opening and freezing a reassignment conflict is durable before enqueueing +// the incumbent. A transient queue error must be repairable by replaying the +// same evidence, or the last committed snapshot can keep granting a frozen +// lineage forever. +func TestConflictRetryAfterReprojectorFailureReprojectsIncumbent(t *testing.T) { + repository := newStubRepository() + repository.lineage = Lineage{ID: "bpl_conflict", ProjectID: "prj_1", EnvironmentID: "env_1", BillingCustomerID: "bcu_owner"} + reprojector := &stubReprojector{failures: 1} + service := NewService(repository, nil, reprojector) + observation := []Observation{{EvidenceType: EvidenceTrustedServer, CustomerID: "bcu_challenger"}} + + if _, err := service.ResolveLineageCustomer(context.Background(), "prj_1", "bpl_conflict", observation); !errors.Is(err, ErrUnavailable) { + t.Fatalf("first conflict error = %v, want unavailable", err) + } + if _, err := service.ResolveLineageCustomer(context.Background(), "prj_1", "bpl_conflict", observation); err != nil { + t.Fatalf("retry conflict: %v", err) + } + if got := reprojector.enqueued[len(reprojector.enqueued)-1].scope.CustomerID; got != "bcu_owner" { + t.Fatalf("retry reprojected %q, want incumbent", got) + } +} diff --git a/apps/api/internal/billingcustomer/trusted.go b/apps/api/internal/billingcustomer/trusted.go new file mode 100644 index 00000000..a84d8615 --- /dev/null +++ b/apps/api/internal/billingcustomer/trusted.go @@ -0,0 +1,216 @@ +package billingcustomer + +import ( + "context" + "strings" + + "go.opentelemetry.io/otel/attribute" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +// This file is the trusted-server application surface for billing identity +// (plan §11). Every method here starts by authenticating a secret server key +// and takes its tenant from that key alone — never from the request body. +// +// That is the whole of the impersonation defence. A caller cannot name a +// Project, an Environment, or an Application it does not hold a key for, so a +// careless or compromised backend can only damage its own tenant. It is also +// why there is no public-SDK-key path in this file: an application-user alias +// asserted by a public key would let any client claim any user. + +// TrustedActor is how a server key appears in the audit trail. The API key +// identifier is a stable handle an operator can revoke; the key itself never +// travels past the authenticator. +func trustedActor(scope KeyScope) Actor { return Actor{ID: "apikey:" + scope.APIKeyID} } + +func (s *Service) authenticate(ctx context.Context, rawKey string) (KeyScope, error) { + if s.keys == nil { + return KeyScope{}, ErrUnauthenticated + } + scope, err := s.keys.AuthenticateServerKey(ctx, strings.TrimSpace(rawKey)) + if err != nil { + return KeyScope{}, ErrUnauthenticated + } + if scope.ProjectID == "" { + return KeyScope{}, ErrUnauthenticated + } + return scope, nil +} + +// IdentifyCustomer is the trusted create-or-get path of plan §5a rule 1. +// +// It is one of exactly two ways a Billing Customer comes into existence. SDK +// initialization and installation registration reach nothing here, and there is +// no variant of this call that accepts an installation identifier: the only +// alias this method will act on is an application user id asserted by the +// backend that owns the user. +func (s *Service) IdentifyCustomer(ctx context.Context, rawKey, applicationUserID string) (Customer, bool, error) { + scope, err := s.authenticate(ctx, rawKey) + if err != nil { + return Customer{}, false, err + } + return s.CreateOrGetForApplicationUser(ctx, scope.ProjectID, applicationUserID) +} + +// AttachAliasForServer attaches an application-user alias to an existing +// customer. Login attaches; it never merges (plan §5a rule 3). When the alias +// already resolves elsewhere the call fails with ErrIdentityConflict and an +// operator-resolvable conflict has been opened. +func (s *Service) AttachAliasForServer(ctx context.Context, rawKey, customerID, applicationUserID string) (Alias, error) { + scope, err := s.authenticate(ctx, rawKey) + if err != nil { + return Alias{}, err + } + return s.AttachApplicationUserAlias(ctx, trustedActor(scope), scope.ProjectID, customerID, applicationUserID) +} + +// RevokeAliasForServer ends an alias's active resolution. Nothing is deleted: +// the alias row is end-dated, so the history of who was linked when survives a +// sign-out. +func (s *Service) RevokeAliasForServer(ctx context.Context, rawKey, aliasID string) error { + scope, err := s.authenticate(ctx, rawKey) + if err != nil { + return err + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return err + } + actor := trustedActor(scope) + if err := s.repository.RevokeAlias(ctx, actor, scope.ProjectID, strings.TrimSpace(aliasID), s.now()); err != nil { + return err + } + return s.repository.RecordAudit(ctx, actor, scope.ProjectID, "billing.customer.alias_revoked", + "billing_customer_alias", aliasID, nil, s.now()) +} + +// ListAliasesForServer returns a customer's alias history. Digests are not part +// of the returned shape and no caller can ask for them. +func (s *Service) ListAliasesForServer(ctx context.Context, rawKey, customerID string) ([]Alias, error) { + scope, err := s.authenticate(ctx, rawKey) + if err != nil { + return nil, err + } + return s.ListAliases(ctx, trustedActor(scope), scope.ProjectID, customerID) +} + +// ListConflictsForServer lists identity conflicts awaiting resolution. +func (s *Service) ListConflictsForServer(ctx context.Context, rawKey, status string) ([]Conflict, error) { + scope, err := s.authenticate(ctx, rawKey) + if err != nil { + return nil, err + } + switch status { + case "", "open", "resolved": + default: + return nil, ErrInvalidAlias + } + return s.ListConflicts(ctx, trustedActor(scope), scope.ProjectID, status) +} + +// ConflictDetailForServer returns one conflict with the lineage it disputes, +// which is what an operator needs before choosing a resolution action. +func (s *Service) ConflictDetailForServer(ctx context.Context, rawKey, conflictID string) (ConflictDetail, error) { + scope, err := s.authenticate(ctx, rawKey) + if err != nil { + return ConflictDetail{}, err + } + return s.ConflictDetail(ctx, trustedActor(scope), scope.ProjectID, conflictID) +} + +// RequestSync enqueues a manual recomputation of one customer's entitlement +// aggregate and returns the handle the projection queue coalesces on. +// +// It computes nothing itself. A support-facing "sync now" that derived its own +// answer would produce a second authoritative result alongside the projection's, +// so this schedules the same job every other trigger schedules and reports where +// to watch it. +func (s *Service) RequestSync(ctx context.Context, rawKey, customerID string) (SyncRequest, error) { + ctx, span := s.tracer.Start(ctx, "billing.customer.request_sync") + defer span.End() + + scope, err := s.authenticate(ctx, rawKey) + if err != nil { + return SyncRequest{}, err + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return SyncRequest{}, err + } + if scope.EnvironmentID == "" { + // A projection is Environment-scoped. A key that names no Environment + // cannot say what to recompute, and guessing one would recompute the + // wrong Environment's state. + return SyncRequest{}, ErrInvalidAlias + } + request, err := s.enqueueManualSync(ctx, trustedActor(scope), scope.ProjectID, scope.EnvironmentID, customerID) + if err != nil { + return SyncRequest{}, err + } + span.SetAttributes( + attribute.String("mosaic.billing.customer.id", request.BillingCustomerID), + attribute.String("mosaic.billing.projection.scope_key", request.ScopeKey)) + return request, nil +} + +// RequestSyncForOperator is the dashboard-authenticated manual sync. It reaches +// exactly the same enqueue the trusted surface does, so an operator's "sync +// now" and a backend's produce one job on one queue rather than two answers. +// +// The tenant comes from the route the principal middleware already authorized; +// the repository re-checks organization membership on the customer read, so a +// route that lost its check would still not read another tenant's customer. +func (s *Service) RequestSyncForOperator(ctx context.Context, actor Actor, projectID, environmentID, customerID string) (SyncRequest, error) { + ctx, span := s.tracer.Start(ctx, "billing.customer.request_sync") + defer span.End() + + if err := s.requireEnabled(ctx, projectID); err != nil { + return SyncRequest{}, err + } + if strings.TrimSpace(environmentID) == "" { + return SyncRequest{}, ErrInvalidAlias + } + request, err := s.enqueueManualSync(ctx, actor, projectID, environmentID, customerID) + if err != nil { + return SyncRequest{}, err + } + span.SetAttributes( + attribute.String("mosaic.billing.customer.id", request.BillingCustomerID), + attribute.String("mosaic.billing.projection.scope_key", request.ScopeKey)) + return request, nil +} + +// enqueueManualSync is the one body both manual-sync entry points share. It +// computes nothing: a support-facing "sync now" that derived its own answer +// would produce a second authoritative result alongside the projection's. +func (s *Service) enqueueManualSync(ctx context.Context, actor Actor, projectID, environmentID, customerID string) (SyncRequest, error) { + customer, err := s.repository.Customer(ctx, actor, projectID, strings.TrimSpace(customerID)) + if err != nil { + return SyncRequest{}, err + } + + projectionScope := billingprojection.Scope{ + ProjectID: projectID, EnvironmentID: environmentID, CustomerID: customer.ID, + } + if s.reprojector == nil { + return SyncRequest{}, ErrUnavailable + } + if err := s.reprojector.Enqueue(ctx, projectionScope, billingprojection.KindManualSync); err != nil { + return SyncRequest{}, ErrUnavailable + } + + now := s.now() + _ = s.repository.RecordAudit(ctx, actor, projectID, "billing.customer.sync_requested", + "billing_customer", customer.ID, map[string]string{ + "environmentId": environmentID, "triggerKind": billingprojection.KindManualSync, + }, now) + logSafely(ctx, "billing customer projection requested manually", map[string]string{ + "project_id": projectID, "environment_id": environmentID, + "billing_customer_id": customer.ID, + }) + + return SyncRequest{ + ProjectID: projectID, EnvironmentID: environmentID, + BillingCustomerID: customer.ID, ScopeKey: projectionScope.Key(), + Kind: billingprojection.KindManualSync, RequestedAt: now, + }, nil +} diff --git a/apps/api/internal/billingdiagnostics/model.go b/apps/api/internal/billingdiagnostics/model.go new file mode 100644 index 00000000..b8be4772 --- /dev/null +++ b/apps/api/internal/billingdiagnostics/model.go @@ -0,0 +1,68 @@ +// Package billingdiagnostics owns the Phase 9B projection health surface. +// +// It is a sibling of the Phase 9A billing health surface rather than an +// extension of it, because the two answer different questions. Billing health +// asks "is Mosaic still able to turn store notifications into facts?" — +// credentials, validation backlog, quarantine. Projection health asks "is the +// authoritative answer Mosaic gives about a customer's access still current?" — +// projection backlog, stale customers, unresolved identity, unknown +// entitlements, restore backlog, webhook backlog. An operator paged about one +// almost never wants the other's numbers mixed into the same object. +// +// Everything here is a count or a timestamp. Nothing on this surface can carry +// a customer value, an alias digest, a provider token, or a secret. +package billingdiagnostics + +import "time" + +// ProjectionHealth is the Environment-scoped projection health summary. +type ProjectionHealth struct { + EnvironmentID string `json:"environmentId"` + BillingEnabled bool `json:"billingEnabled"` + + // ActiveRuleVersion is the projection rule version new projections are + // computed under; RuleVersionCount is how many exist. A count above one + // with no replay in flight means a promotion was prepared and never run. + ActiveRuleVersion int `json:"activeProjectionRuleVersion"` + RuleVersionCount int `json:"projectionRuleVersionCount"` + + // Projection backlog. Depth alone cannot distinguish a busy queue from a + // stuck one, which is why the oldest age is the alerting signal. + ProjectionQueueDepth int64 `json:"projectionQueueDepth"` + ProjectionOldestAgeSecs float64 `json:"projectionOldestQueuedAgeSeconds"` + ProjectionFailedJobs int64 `json:"projectionFailedJobs"` + // ProjectionFailuresLastHour counts attempts that ended in failure, which + // is a rate signal the queue depth cannot give: a scope that fails and + // requeues forever keeps the depth at one. + ProjectionFailuresLastHour int64 `json:"projectionFailuresLastHour"` + + // StaleCustomers have committed state older than the staleness threshold. + StaleCustomers int64 `json:"staleCustomers"` + // NeverProjectedCustomers exist but have no committed projection at all. + NeverProjectedCustomers int64 `json:"neverProjectedCustomers"` + + // Identity. A conflict spike is a security-relevant signal, not a backlog. + OpenIdentityConflicts int64 `json:"openIdentityConflicts"` + FrozenLineages int64 `json:"frozenLineages"` + UnresolvedLineages int64 `json:"unresolvedLineages"` + + // UnknownEntitlementEntries counts entries on current snapshots that state + // `unknown`. It is the number that says how often Mosaic is declining to + // answer, which no queue metric reports. + UnknownEntitlementEntries int64 `json:"unknownEntitlementEntries"` + + RestoreBacklog int64 `json:"restoreBacklog"` + RestoreFailedJobs int64 `json:"restoreFailedJobs"` + WebhookBacklog int64 `json:"webhookDeliveryBacklog"` + WebhookExhausted int64 `json:"webhookDeliveriesExhausted"` + WebhookDestinations int64 `json:"activeWebhookDestinations"` + + LastProjectionCommittedAt *time.Time `json:"lastProjectionCommittedAt,omitempty"` + ObservedAt time.Time `json:"observedAt"` +} + +// StaleAfter is when a committed projection stops being treated as current for +// health purposes. It is deliberately the same threshold the read surfaces use +// to report a `stale` projection status, so an operator reading the dashboard +// and an SDK reading a snapshot disagree about nothing. +const StaleAfter = time.Hour diff --git a/apps/api/internal/billingdiagnostics/replay.go b/apps/api/internal/billingdiagnostics/replay.go new file mode 100644 index 00000000..c2e3f2d1 --- /dev/null +++ b/apps/api/internal/billingdiagnostics/replay.go @@ -0,0 +1,153 @@ +package billingdiagnostics + +import ( + "context" + "errors" + "time" + + "go.opentelemetry.io/otel/attribute" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +// ErrInvalid is a replay request that is not bounded, or that names a rule +// version this build does not derive under. +var ErrInvalid = errors.New("the replay request is not valid") + +// Replayer is the narrow port onto the projection service. +// +// It is an interface rather than the concrete service so this package cannot +// reach the projection commit path except through a bounded replay. Diagnostics +// is a read surface with exactly one write it is allowed to trigger, and the +// port is the whole of that permission. +type Replayer interface { + RunReplay(ctx context.Context, keys billingprojection.ReplayScopeKeys, + replay billingprojection.Replay, scope billingprojection.ReplayScope, limit int) ([]billingprojection.ReplayResult, error) +} + +// ReplayRequest is one bounded replay an operator asked for. +type ReplayRequest struct { + SubscriptionInstanceID string + CustomerID string + WindowStart *time.Time + WindowEnd *time.Time + // RuleVersion selects the projection semantics. Zero means the active one. + RuleVersion int + Limit int +} + +// bounded reports whether the request names something smaller than "everything". +// +// An unbounded replay is not a replay, it is a migration, and bulk migration +// tooling is explicitly out of Phase 9B (plan §18). A project-window replay +// counts as bounded only when it actually carries a window: without one, "the +// whole Project" is what would run. +func (r ReplayRequest) bounded() bool { + return r.SubscriptionInstanceID != "" || r.CustomerID != "" || + (r.WindowStart != nil && r.WindowEnd != nil) +} + +// ReplayOutcome is the result of one replayed scope, plus the rule version it +// was derived under so a caller comparing two runs can tell them apart. +type ReplayOutcome struct { + ScopeKey string `json:"projectionScopeKey"` + Comparison string `json:"comparison"` + Materialized bool `json:"materialized"` + Changed []string `json:"changedEntitlementIds,omitempty"` +} + +// ReplayResponse is the whole run. +type ReplayResponse struct { + RuleVersion int `json:"projectionRuleVersion"` + Scopes int `json:"scopesReplayed"` + Changed int `json:"scopesChanged"` + Outcomes []ReplayOutcome `json:"outcomes"` +} + +// Replay runs a bounded projection replay for an authorized operator. +// +// Replay is the operational expression of principle 2: a projection is derived +// state, so a corrupt checkpoint, a promoted rule version, or a mapping repair +// is answered by recomputing from the immutable facts rather than by patching +// what was derived. It reuses the ordinary projection command, so replayed +// state goes through the same lock, compare-and-swap, and atomic commit as live +// projection — there is no second write path that could diverge — and prior +// snapshots are never deleted. +// +// Provider asymmetry, stated rather than hidden: Apple replay is input-sourced, +// because a stored Apple payload re-validates to the same transaction. Google +// replay is fact-sourced, because Google validation re-queries live provider +// state and a re-query today does not reproduce what the provider said last +// month. +func (s *Service) Replay(ctx context.Context, actor Actor, projectID, environmentID string, + request ReplayRequest) (ReplayResponse, error) { + + ctx, span := s.tracer.Start(ctx, "billing.projection.replay") + defer span.End() + + if s.replayer == nil || s.replayScopes == nil { + return ReplayResponse{}, ErrUnavailable + } + if !request.bounded() { + return ReplayResponse{}, ErrInvalid + } + if !billingprojection.RuleVersionImplemented(request.RuleVersion) { + // Refused rather than approximated. Recomputing under the active engine + // and labelling the answer with the requested version would make the + // checksum comparison — the entire point of a replay — meaningless. + return ReplayResponse{}, ErrInvalid + } + if err := s.repository.AuthorizeReplay(ctx, actor, projectID, environmentID); err != nil { + return ReplayResponse{}, err + } + + results, err := s.replayer.RunReplay(ctx, s.replayScopes, + billingprojection.Replay{RuleVersion: request.RuleVersion}, + billingprojection.ReplayScope{ + ProjectID: projectID, + CustomerID: request.CustomerID, + SubscriptionInstanceID: request.SubscriptionInstanceID, + WindowStart: request.WindowStart, + WindowEnd: request.WindowEnd, + }, request.Limit) + if err != nil { + switch { + case errors.Is(err, billingprojection.ErrUnsupportedRuleVersion): + return ReplayResponse{}, ErrInvalid + case errors.Is(err, billingprojection.ErrBillingDisabled): + return ReplayResponse{}, ErrBillingDisabled + default: + return ReplayResponse{}, ErrUnavailable + } + } + + response := ReplayResponse{ + RuleVersion: billingprojection.ResolveRuleVersion(request.RuleVersion), + Scopes: len(results), + Outcomes: make([]ReplayOutcome, 0, len(results)), + } + for _, result := range results { + if result.Comparison == billingprojection.ComparisonChanged { + response.Changed++ + } + response.Outcomes = append(response.Outcomes, ReplayOutcome{ + ScopeKey: result.ScopeKey, Comparison: result.Comparison, + Materialized: result.Materialized, Changed: result.Changed, + }) + } + span.SetAttributes( + attribute.Int("mosaic.billing.replay.rule_version", response.RuleVersion), + attribute.Int("mosaic.billing.replay.scopes", response.Scopes), + attribute.Int("mosaic.billing.replay.changed", response.Changed)) + + // Audited: a replay is a write, and a checksum that moved is exactly the + // kind of change an investigation needs to be able to attribute. + if err := s.repository.RecordReplayAudit(ctx, actor, projectID, environmentID, + response.RuleVersion, response.Scopes, response.Changed, time.Now().UTC()); err != nil { + return response, nil + } + return response, nil +} + +// ErrBillingDisabled is a service state, never a statement about a customer. +var ErrBillingDisabled = errors.New("billing is not enabled for this Project") diff --git a/apps/api/internal/billingdiagnostics/service.go b/apps/api/internal/billingdiagnostics/service.go new file mode 100644 index 00000000..e5e388bd --- /dev/null +++ b/apps/api/internal/billingdiagnostics/service.go @@ -0,0 +1,107 @@ +package billingdiagnostics + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +// Stable domain errors, mapped onto HTTP in exactly one place by the handler. +var ( + ErrUnauthenticated = errors.New("an authenticated actor is required") + ErrForbidden = errors.New("the actor may not read this Project's projection health") + ErrNotFound = errors.New("the Environment was not found") + ErrUnavailable = errors.New("projection health could not be read") +) + +// Actor is the authenticated operator. Authorization is decided server-side by +// the repository against organization membership, never by the handler. +type Actor struct{ ID string } + +// Repository is the persistence port. +type Repository interface { + BillingEnabled(ctx context.Context, projectID string) (bool, error) + ProjectionHealth(ctx context.Context, actor Actor, projectID, environmentID string) (ProjectionHealth, error) + // AuthorizeReplay is separate from the health authorization because a + // replay is a write. It is the only permission check in this package that + // guards state change rather than a read. + AuthorizeReplay(ctx context.Context, actor Actor, projectID, environmentID string) error + RecordReplayAudit(ctx context.Context, actor Actor, projectID, environmentID string, + ruleVersion, scopes, changed int, now time.Time) error +} + +// Service is the diagnostics application service. It is thin by nature — the +// surface is a read — but it owns the enablement decision and the tracing, so +// the handler stays a transport adapter. +type Service struct { + repository Repository + tracer trace.Tracer + + replayer Replayer + replayScopes billingprojection.ReplayScopeKeys +} + +type Option func(*Service) + +// WithReplay enables the bounded projection-replay operation. +// +// Both halves are required together. A replay needs the projection command and +// the scope enumeration; having one without the other is not a degraded replay, +// it is no replay, so the option refuses a partial configuration rather than +// leaving an endpoint that fails at the first request. +func WithReplay(replayer Replayer, scopes billingprojection.ReplayScopeKeys) Option { + return func(s *Service) { + if replayer != nil && scopes != nil { + s.replayer, s.replayScopes = replayer, scopes + } + } +} + +func NewService(repository Repository, options ...Option) *Service { + service := &Service{ + repository: repository, + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/billingdiagnostics"), + } + for _, option := range options { + option(service) + } + return service +} + +// ProjectionHealth reports the Environment's projection health. +// +// A Project with billing disabled still gets an answer rather than an error: +// the surface exists to tell an operator what state Mosaic is in, and "billing +// is off" is one of those states. Every count is zero in that case, which is +// true — a disabled Project holds no billing state. +func (s *Service) ProjectionHealth(ctx context.Context, actor Actor, projectID, environmentID string) (ProjectionHealth, error) { + ctx, span := s.tracer.Start(ctx, "billing.projection.health") + defer span.End() + span.SetAttributes(attribute.String("mosaic.environment.id", environmentID)) + + health, err := s.repository.ProjectionHealth(ctx, actor, projectID, environmentID) + if err != nil { + if errors.Is(err, ErrUnauthenticated) || errors.Is(err, ErrForbidden) || errors.Is(err, ErrNotFound) { + return ProjectionHealth{}, err + } + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("environment_id", environmentID). + Str("diagnostics_error_kind", fmt.Sprintf("%T", err)). + Msg("projection health could not be read") + return ProjectionHealth{}, ErrUnavailable + } + span.SetAttributes( + attribute.Int64("mosaic.billing.projection.queue_depth", health.ProjectionQueueDepth), + attribute.Int64("mosaic.billing.projection.stale_customers", health.StaleCustomers), + attribute.Int64("mosaic.billing.identity.open_conflicts", health.OpenIdentityConflicts)) + return health, nil +} diff --git a/apps/api/internal/billinggrant/errors.go b/apps/api/internal/billinggrant/errors.go new file mode 100644 index 00000000..812ff783 --- /dev/null +++ b/apps/api/internal/billinggrant/errors.go @@ -0,0 +1,30 @@ +package billinggrant + +import "errors" + +// Stable domain errors, mapped onto HTTP in exactly one place by the handler. +var ( + ErrUnauthenticated = errors.New("an authenticated actor is required") + ErrForbidden = errors.New("the actor may not manage this Project's grant versions") + ErrNotFound = errors.New("the Product, Entitlement, or grant version was not found") + // ErrInvalid is a request that is well-formed but not a permitted grant + // change: a start that is not prospective, a paused-access override, an + // unsupported purchase type. + ErrInvalid = errors.New("the proposed grant version is not permitted") + // ErrOverlap is a proposed interval that would overlap a recorded one. + // Overlapping intervals make grant selection ambiguous, which turns a + // customer's access into a function of row order. + ErrOverlap = errors.New("the proposed interval overlaps a recorded grant version") + // ErrNotAdditiveSuperset is a retroactive change that would remove or narrow + // access. Retroactive change is the one operation that can take access from + // a customer who did nothing wrong, so the only retroactive shape accepted + // is the one that cannot. + ErrNotAdditiveSuperset = errors.New("a retroactive grant version must be an additive superset") + // ErrImmutable is an attempt to edit a published version in place. + ErrImmutable = errors.New("a published grant version cannot be edited") + // ErrConflict is a lost race: another publish for the same pair committed + // while this one was being validated. + ErrConflict = errors.New("the pair's grant history changed underneath this publish") + ErrBillingDisabled = errors.New("Mosaic Billing is not enabled for this Project") + ErrUnavailable = errors.New("grant versions could not be read") +) diff --git a/apps/api/internal/billinggrant/model.go b/apps/api/internal/billinggrant/model.go new file mode 100644 index 00000000..bdf7ec85 --- /dev/null +++ b/apps/api/internal/billinggrant/model.go @@ -0,0 +1,190 @@ +// Package billinggrant owns the management of Product-to-Entitlement Grant +// Versions: listing a pair's history, previewing the impact of a change, and +// publishing a new immutable version (Phase 9B WP9, plan §5/§7, OD-8). +// +// Selection of the grant version in force at a purchase's effective time is not +// here — it belongs to `billingprojection`, which is what the projection engine +// reads. This package decides what may be written; that one decides what a +// written version means. Keeping the two apart is what stops the management +// surface from acquiring a second, subtly different notion of "the current +// grant". +package billinggrant + +import ( + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +// Actor is the authenticated operator. Authorization is decided server-side +// against organization membership; no handler makes the decision. +type Actor struct{ ID string } + +// Organization roles that may publish. Reading a pair's history is permitted to +// any member of the owning organization; publishing is not. +const ( + RoleOwner = "owner" + RoleAdmin = "admin" +) + +// GrantPolicyVersion is the access-policy vocabulary these versions are written +// under (plan §7, policy version 1). It is recorded on every version so a later +// policy vocabulary does not silently reinterpret what an operator approved. +const GrantPolicyVersion = 1 + +// Purchase types a grant version may support. The set is closed because a grant +// that supports a purchase type Mosaic never projects is a grant that is +// configured and permanently inert. +const ( + PurchaseTypeAutoRenewable = "auto_renewable_subscription" + PurchaseTypeNonConsumable = "non_consumable" +) + +// DefaultPurchaseTypes matches the column default in migration 00033. +func DefaultPurchaseTypes() []string { + return []string{PurchaseTypeAutoRenewable, PurchaseTypeNonConsumable} +} + +// Version is one immutable Product-to-Entitlement grant interval as the +// management surface reports it. Intervals are half-open: [start, end). +type Version struct { + ID string + ProjectID string + ProductID string + ProductKey string + EntitlementID string + EntitlementKey string + + Version int + GrantPolicyVersion int + EffectiveStart time.Time + // EffectiveEnd is nil for the current, open-ended version. + EffectiveEnd *time.Time + + SupportedPurchaseTypes []string + Policy billingprojection.Policy + + CreatedAt time.Time + CreatedByActorID string + Reason string + // Retroactive records that this version was published with a start in the + // past under the additive-superset rule. It is derived from the audit trail + // at publish time and carried on the response so the dashboard can label the + // row without re-deriving the comparison. + Retroactive bool +} + +// Current reports whether this version is the open-ended one. +func (v Version) Current() bool { return v.EffectiveEnd == nil } + +// PublishInput is one proposed grant version. +// +// There is deliberately no version number and no identifier: both are assigned +// by the publish transaction under the pair's advisory lock, so a caller cannot +// choose where in the history its change lands. +type PublishInput struct { + ProductID string + EntitlementID string + // EffectiveStart is the instant the new meaning takes effect. Prospective + // publishing requires it to be now or later; a retroactive correction may + // name a past instant and is then held to the additive-superset rule. + EffectiveStart time.Time + Retroactive bool + + SupportedPurchaseTypes []string + Policy billingprojection.Policy + // GrantsInPaused is accepted only so it can be refused with a sentence. The + // projection Policy has no such field because paused access is not a policy + // question — Google's pause never grants — and the schema CHECK says the + // same. Dropping the member instead would let a caller believe Mosaic had + // read a setting it silently ignored. + GrantsInPaused bool + Reason string +} + +// Plan is what a validated publish will do. It exists so the decision and the +// write are separable: the decision is pure and testable, the write is a +// transaction that applies a decision it did not make. +type Plan struct { + // NextVersion is the version number the new row takes. + NextVersion int + // SupersededVersionID is the open version to close, or empty when the pair + // has no open version (it currently grants nothing). + SupersededVersionID string + // SupersededAt is the instant the superseded version closes: exactly the new + // version's effective start, so the two intervals abut with no gap and no + // overlap. + SupersededAt time.Time +} + +// Impact is the read-only preview of what a change would touch. +// +// Every number is counted from committed state at the moment of the call and +// nothing is written, so an operator can ask the question as often as they like +// before deciding. The counts are deliberately of *current* state: a preview +// that included historical snapshots would report a number no operator action +// can change. +type Impact struct { + ProductID string + EntitlementID string + // ImpactedProducts is 1 for a grant version — the pair names one Product — + // but is reported explicitly rather than assumed, because a Product with + // replacement predecessors reaches more catalog rows than its own id. + ImpactedProducts int + // ImpactedEntitlements is the number of Entitlements whose current meaning + // for this Product would change. + ImpactedEntitlements int + // ImpactedCustomers is the number of Billing Customers whose current + // entitlement snapshot cites this Product as a source. These are the + // customers a reprojection would recompute. + ImpactedCustomers int + // ImpactedActiveSources is how many of those citations are currently + // granting access. It is the number that answers "how many people could + // lose access if I get this wrong?", which the customer count alone does + // not. + ImpactedActiveSources int + // ImpactedLineages is the number of purchase lineages resolved to this + // Product, including ones with no customer resolved yet — purchases that + // would be affected but are invisible in the customer count. + ImpactedLineages int + // CurrentVersion describes the version being superseded, or nil when the + // pair currently grants nothing. + CurrentVersion *Version + // Retroactive echoes whether the previewed change was marked retroactive. + Retroactive bool + // AdditiveSuperset is meaningful only for a retroactive preview: it reports + // whether the proposed policy passes the widen-only rule. A preview never + // refuses; it reports, and the publish refuses. + AdditiveSuperset bool + // NarrowingCode names the first narrowing found when AdditiveSuperset is + // false, using the engine's own vocabulary. + NarrowingCode string + ObservedAt time.Time +} + +// ListFilter bounds a history read. +type ListFilter struct { + ProductID string + EntitlementID string + // VersionID reads exactly one recorded version by id, ignoring the pair + // filters. It is how the immutability answer identifies what the caller + // tried to edit. + VersionID string + // IncludeHistory returns closed versions as well as the current one. The + // default is the whole history, because "what does this Product grant?" and + // "what did it grant when that purchase was made?" are the same question + // asked at two instants, and only the second is ever in dispute. + CurrentOnly bool + Limit int +} + +// MaxListLimit bounds one history page. +const MaxListLimit = 200 + +// Bounded clamps a filter to what the surface will serve. +func (f ListFilter) Bounded() ListFilter { + if f.Limit <= 0 || f.Limit > MaxListLimit { + f.Limit = MaxListLimit + } + return f +} diff --git a/apps/api/internal/billinggrant/repository.go b/apps/api/internal/billinggrant/repository.go new file mode 100644 index 00000000..f821a9a9 --- /dev/null +++ b/apps/api/internal/billinggrant/repository.go @@ -0,0 +1,43 @@ +package billinggrant + +import ( + "context" + "time" +) + +// Repository is the persistence port. +type Repository interface { + BillingEnabled(ctx context.Context, projectID string) (bool, error) + + // Role resolves the actor's organization role for the Project. It returns + // ErrNotFound rather than ErrForbidden for a non-member, matching every + // other Mosaic surface: telling a caller that a Project exists but is not + // theirs is an existence oracle over other tenants' Projects. + Role(ctx context.Context, actor Actor, projectID string) (string, error) + + // ListVersions reads a pair's recorded history, newest version first. + ListVersions(ctx context.Context, projectID string, filter ListFilter) ([]Version, error) + + // CurrentVersion reads the open-ended version for one pair. The second + // return is false when the pair currently grants nothing, which is a normal + // state and not an error. + CurrentVersion(ctx context.Context, projectID, productID, entitlementID string) (Version, bool, error) + + // Impact counts what a change to one pair would touch, from committed state + // only. It writes nothing, including no audit event: a preview an operator + // runs five times while deciding must not leave five entries suggesting five + // changes were considered and four abandoned. + Impact(ctx context.Context, projectID, productID, entitlementID string) (Impact, error) + + // Publish applies one validated proposal atomically. + // + // The repository takes the pair's advisory lock, reads the recorded history + // inside the transaction, and hands it to `plan` — the caller's pure + // decision function — so the decision is made against a history that cannot + // move before the write. It then closes the superseded version, inserts the + // new one, writes the audit event, and enqueues a reprojection for every + // affected customer, all in the same transaction. Either the new meaning and + // the work to apply it both exist, or neither does. + Publish(ctx context.Context, actor Actor, projectID string, input PublishInput, + plan func(existing []Version, at time.Time) (Plan, error), now time.Time) (Version, error) +} diff --git a/apps/api/internal/billinggrant/service.go b/apps/api/internal/billinggrant/service.go new file mode 100644 index 00000000..f2a11b4b --- /dev/null +++ b/apps/api/internal/billinggrant/service.go @@ -0,0 +1,257 @@ +package billinggrant + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// Service is the grant-version application service. +// +// It owns three decisions and no persistence: whether Billing is on for the +// Project, whether the actor may do what they asked, and whether the proposed +// version is a permitted change. The handler makes none of them. +type Service struct { + repository Repository + now func() time.Time + tracer trace.Tracer +} + +type Option func(*Service) + +// WithClock makes publish timestamps deterministic for tests. +func WithClock(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +func NewService(repository Repository, options ...Option) *Service { + service := &Service{ + repository: repository, + now: func() time.Time { return time.Now().UTC() }, + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/billinggrant"), + } + for _, option := range options { + option(service) + } + return service +} + +// ListVersions reports a pair's grant history. +// +// Reading is permitted to any member of the owning organization. The history is +// the answer to "why is this customer entitled?", and an operator who can see +// the customer's entitlements but not the rule that produced them has been given +// a fact with no explanation. +func (s *Service) ListVersions(ctx context.Context, actor Actor, projectID string, filter ListFilter) ([]Version, error) { + ctx, span := s.tracer.Start(ctx, "billing.grant.versions.list") + defer span.End() + + if _, err := s.authorize(ctx, actor, projectID, false); err != nil { + return nil, err + } + if strings.TrimSpace(filter.ProductID) == "" { + return nil, fmt.Errorf("%w: a history read names one Product", ErrInvalid) + } + versions, err := s.repository.ListVersions(ctx, projectID, filter.Bounded()) + if err != nil { + return nil, s.unavailable(ctx, projectID, "grant versions could not be listed", err) + } + span.SetAttributes(attribute.Int("mosaic.billing.grant.versions", len(versions))) + return versions, nil +} + +// PreviewImpact reports what a change would touch, and changes nothing. +// +// It is a POST because it carries a proposal in the body, not because it has an +// effect. Nothing here writes, including the audit trail: an operator comparing +// three candidate policies before choosing one has not made three changes. +func (s *Service) PreviewImpact(ctx context.Context, actor Actor, projectID string, input PublishInput) (Impact, error) { + ctx, span := s.tracer.Start(ctx, "billing.grant.impact.preview") + defer span.End() + + // A preview is gated on the *write* permission even though it writes + // nothing. It is a step in a change workflow, and the counts it reports + // describe how much damage the change could do; an actor who may not make + // the change has no reason to be shown the blast radius. + if _, err := s.authorize(ctx, actor, projectID, true); err != nil { + return Impact{}, err + } + input = Normalize(input) + if err := ValidateShape(input); err != nil { + return Impact{}, err + } + + impact, err := s.repository.Impact(ctx, projectID, input.ProductID, input.EntitlementID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Impact{}, err + } + return Impact{}, s.unavailable(ctx, projectID, "grant impact could not be computed", err) + } + impact.Retroactive = input.Retroactive + impact.ObservedAt = s.now() + + current, found, err := s.repository.CurrentVersion(ctx, projectID, input.ProductID, input.EntitlementID) + if err != nil { + return Impact{}, s.unavailable(ctx, projectID, "the current grant version could not be read", err) + } + if found { + impact.CurrentVersion = ¤t + impact.AdditiveSuperset = true + if code, ok := CheckAdditiveSuperset(current, input); !ok { + impact.AdditiveSuperset, impact.NarrowingCode = false, code + } + } else { + // With nothing in force, every proposal is trivially a superset: there + // is no access to take away. + impact.AdditiveSuperset = true + } + + span.SetAttributes( + attribute.Int("mosaic.billing.grant.impact.customers", impact.ImpactedCustomers), + attribute.Int("mosaic.billing.grant.impact.active_sources", impact.ImpactedActiveSources), + attribute.Bool("mosaic.billing.grant.impact.retroactive", impact.Retroactive)) + return impact, nil +} + +// Publish records a new immutable grant version. +// +// Publishing is the explicit, separate act. Nothing else on this surface +// changes what a Product grants: the preview reports and the history reads, and +// only this call, carrying an actor and a reason, writes. The audit event and +// the reprojection it enqueues are part of the same transaction as the version +// itself. +func (s *Service) Publish(ctx context.Context, actor Actor, projectID string, input PublishInput) (Version, error) { + ctx, span := s.tracer.Start(ctx, "billing.grant.version.publish") + defer span.End() + + role, err := s.authorize(ctx, actor, projectID, true) + if err != nil { + return Version{}, err + } + input = Normalize(input) + if err := ValidateShape(input); err != nil { + return Version{}, err + } + if strings.TrimSpace(input.Reason) == "" { + // The reason is what an investigation reads months later, when the + // operator who published is gone and the only remaining question is why + // access changed. It costs one sentence now and is unrecoverable later. + return Version{}, fmt.Errorf("%w: a published grant version must state why", ErrInvalid) + } + // Granting access during billing retry contradicts both providers' + // documentation (plan §7), so it is closed by default and enabling it is an + // owner decision rather than an ordinary catalog edit. + if input.Policy.GrantsInBillingRetry && role != RoleOwner { + return Version{}, fmt.Errorf( + "%w: granting access during billing retry contradicts provider documentation and requires an organization owner", + ErrForbidden) + } + + now := s.now() + published, err := s.repository.Publish(ctx, actor, projectID, input, + func(existing []Version, at time.Time) (Plan, error) { + return PlanPublish(existing, input, at) + }, now) + if err != nil { + switch { + case errors.Is(err, ErrInvalid), errors.Is(err, ErrOverlap), + errors.Is(err, ErrNotAdditiveSuperset), errors.Is(err, ErrNotFound), + errors.Is(err, ErrConflict), errors.Is(err, ErrImmutable): + return Version{}, err + } + return Version{}, s.unavailable(ctx, projectID, "the grant version could not be published", err) + } + + span.SetAttributes( + attribute.String("mosaic.billing.grant.version.id", published.ID), + attribute.Int("mosaic.billing.grant.version.number", published.Version), + attribute.Bool("mosaic.billing.grant.version.retroactive", published.Retroactive)) + // The reason is operator-authored free text and is deliberately not logged: + // it is recorded in the audit event, which is access-controlled, while + // operator logs are not. + zerolog.Ctx(ctx).Info(). + Str("project_id", projectID). + Str("actor_id", actor.ID). + Str("product_id", published.ProductID). + Str("entitlement_id", published.EntitlementID). + Str("grant_version_id", published.ID). + Int("grant_version", published.Version). + Bool("retroactive", published.Retroactive). + Msg("product-to-entitlement grant version published") + return published, nil +} + +// Version reads one recorded version. +func (s *Service) Version(ctx context.Context, actor Actor, projectID, versionID string) (Version, error) { + if _, err := s.authorize(ctx, actor, projectID, false); err != nil { + return Version{}, err + } + versions, err := s.repository.ListVersions(ctx, projectID, ListFilter{Limit: 1, VersionID: versionID}) + if err != nil { + return Version{}, s.unavailable(ctx, projectID, "the grant version could not be read", err) + } + if len(versions) == 0 { + return Version{}, ErrNotFound + } + return versions[0], nil +} + +// authorize resolves the actor's role and applies the read/write split. +func (s *Service) authorize(ctx context.Context, actor Actor, projectID string, write bool) (string, error) { + if strings.TrimSpace(actor.ID) == "" { + return "", ErrUnauthenticated + } + enabled, err := s.repository.BillingEnabled(ctx, projectID) + if err != nil { + // Fails closed, matching the ingestion, projection, and access paths: an + // unreadable setting must not quietly re-enable a Project that asked + // Mosaic to hold no billing state. + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("grant_error_kind", fmt.Sprintf("%T", err)). + Msg("billing enablement could not be read; treating the Project as disabled") + return "", ErrBillingDisabled + } + if !enabled { + return "", ErrBillingDisabled + } + role, err := s.repository.Role(ctx, actor, projectID) + if err != nil { + if errors.Is(err, ErrNotFound) || errors.Is(err, ErrForbidden) || errors.Is(err, ErrUnauthenticated) { + return "", err + } + return "", s.unavailable(ctx, projectID, "the actor's role could not be resolved", err) + } + if !write { + return role, nil + } + switch role { + case RoleOwner, RoleAdmin: + return role, nil + default: + return "", ErrForbidden + } +} + +// unavailable logs the shape of a storage failure and returns the stable domain +// error. The cause never reaches the response: on this surface it can quote SQL +// and identifiers from other tenants' rows. +func (s *Service) unavailable(ctx context.Context, projectID, message string, err error) error { + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("grant_error_kind", fmt.Sprintf("%T", err)). + Msg(message) + return ErrUnavailable +} diff --git a/apps/api/internal/billinggrant/validate.go b/apps/api/internal/billinggrant/validate.go new file mode 100644 index 00000000..c0370446 --- /dev/null +++ b/apps/api/internal/billinggrant/validate.go @@ -0,0 +1,191 @@ +package billinggrant + +import ( + "fmt" + "sort" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +// Normalize applies the transport-independent defaults and trims. It is +// separate from validation so that what is defaulted and what is refused are +// two readable lists rather than one function that does both. +func Normalize(input PublishInput) PublishInput { + input.EffectiveStart = input.EffectiveStart.UTC() + if len(input.SupportedPurchaseTypes) == 0 { + input.SupportedPurchaseTypes = DefaultPurchaseTypes() + } + types := append([]string(nil), input.SupportedPurchaseTypes...) + sort.Strings(types) + deduped := types[:0] + for index, value := range types { + if index == 0 || value != types[index-1] { + deduped = append(deduped, value) + } + } + input.SupportedPurchaseTypes = deduped + return input +} + +// ValidateShape checks what can be decided without reading the pair's history. +func ValidateShape(input PublishInput) error { + if input.ProductID == "" || input.EntitlementID == "" { + return fmt.Errorf("%w: a grant version names one Product and one Entitlement", ErrInvalid) + } + if input.EffectiveStart.IsZero() { + return fmt.Errorf("%w: a grant version must state when it takes effect", ErrInvalid) + } + if len(input.SupportedPurchaseTypes) == 0 { + return fmt.Errorf("%w: a grant version must support at least one purchase type", ErrInvalid) + } + for _, purchaseType := range input.SupportedPurchaseTypes { + switch purchaseType { + case PurchaseTypeAutoRenewable, PurchaseTypeNonConsumable: + default: + return fmt.Errorf("%w: %q is not a purchase type Mosaic projects", ErrInvalid, purchaseType) + } + } + // Google's pause is fixed at no-access with no override (plan §7), and the + // schema CHECK says so too. Refusing it here rather than letting the insert + // fail gives the operator a sentence instead of a constraint name. + if input.GrantsInPaused { + return fmt.Errorf("%w: paused subscriptions never grant access and the policy is not overridable", ErrInvalid) + } + if len(input.Reason) > 512 { + return fmt.Errorf("%w: the reason is too long", ErrInvalid) + } + return nil +} + +// PlanPublish decides whether a proposed version may join a pair's recorded +// history, and what publishing it does to the version it supersedes. +// +// It is pure: given the same history, the same proposal, and the same instant +// it always reaches the same decision. That is what makes it testable without a +// database, and it is called inside the publish transaction with the pair's +// advisory lock held, so the history it reasons about cannot move underneath +// the decision. +// +// The rules, in the order a reader most needs them: +// +// 1. A prospective version takes effect now or later. Publishing a change that +// silently applies to yesterday is the failure grant versioning exists to +// prevent, so backdating requires the caller to say so. +// 2. A retroactive version may name a past instant, but only inside the +// currently open interval, and only if it widens access (OD-8's +// additive-superset rule, evaluated by the projection engine's own +// comparison rather than a second copy of it here). +// 3. No proposal may reach back into an interval that has already closed. A +// closed interval is what a historical purchase selected; rewriting it would +// change what a customer was entitled to at a moment that has passed, with +// no way for them to have known. +// 4. Publishing closes the open version at the new version's start, so the two +// abut exactly. There is never a gap (which would strand purchases made in +// it) and never an overlap (which would make selection order-dependent). +func PlanPublish(existing []Version, input PublishInput, now time.Time) (Plan, error) { + now = now.UTC() + start := input.EffectiveStart.UTC() + + relevant := make([]Version, 0, len(existing)) + for _, version := range existing { + if version.ProductID == input.ProductID && version.EntitlementID == input.EntitlementID { + relevant = append(relevant, version) + } + } + sort.Slice(relevant, func(i, j int) bool { return relevant[i].Version < relevant[j].Version }) + + plan := Plan{NextVersion: 1, SupersededAt: start} + var open *Version + for index := range relevant { + version := relevant[index] + if version.Version >= plan.NextVersion { + plan.NextVersion = version.Version + 1 + } + if version.Current() { + // The partial unique index permits one open version per pair. A + // second one means the index is gone, and continuing would publish + // against a history no rule in this function describes. + if open != nil { + return Plan{}, fmt.Errorf("%w: the pair has two open grant versions", ErrConflict) + } + open = &relevant[index] + continue + } + // Rule 3: a closed interval is settled history. + if version.EffectiveEnd.After(start) { + return Plan{}, fmt.Errorf("%w: version %d already covers %s", + ErrOverlap, version.Version, start.Format(time.RFC3339)) + } + } + + if !input.Retroactive && start.Before(now) { + return Plan{}, fmt.Errorf( + "%w: a prospective grant version takes effect now or later; mark the change retroactive to backdate it", + ErrInvalid) + } + + if open == nil { + // The pair currently grants nothing: there is no interval to close, and + // the new version simply begins. Nothing above it can overlap, because + // every closed interval was checked against the start. + plan.SupersededVersionID = "" + return plan, nil + } + + if !start.After(open.EffectiveStart) { + // Closing the open version at or before its own start would produce an + // empty or inverted interval, which the schema refuses anyway. Saying so + // here names the actual problem: the proposal is not a later statement + // about the pair, it is an attempt to replace one. + return Plan{}, fmt.Errorf("%w: the current version already takes effect at %s", + ErrOverlap, open.EffectiveStart.Format(time.RFC3339)) + } + + if input.Retroactive { + code, ok := billingprojection.ValidateAdditiveSuperset( + grantVersionOf(*open), grantVersionOf(versionFromInput(input, plan.NextVersion))) + if !ok { + return Plan{}, fmt.Errorf("%w: %s", ErrNotAdditiveSuperset, code) + } + } + + plan.SupersededVersionID = open.ID + plan.SupersededAt = start + return plan, nil +} + +// CheckAdditiveSuperset reports the widen-only comparison without deciding +// anything, for the impact preview. The preview never refuses — it reports, and +// the publish refuses — so an operator can see *why* a retroactive change would +// be rejected before they attempt it. +func CheckAdditiveSuperset(current Version, input PublishInput) (string, bool) { + return billingprojection.ValidateAdditiveSuperset( + grantVersionOf(current), grantVersionOf(versionFromInput(input, current.Version+1))) +} + +// versionFromInput renders a proposal as the Version shape the comparison +// speaks, so the additive-superset rule is applied to exactly the row that would +// be written rather than to a parallel description of it. +func versionFromInput(input PublishInput, number int) Version { + return Version{ + ProductID: input.ProductID, EntitlementID: input.EntitlementID, + Version: number, EffectiveStart: input.EffectiveStart.UTC(), + SupportedPurchaseTypes: input.SupportedPurchaseTypes, + Policy: input.Policy, + GrantPolicyVersion: GrantPolicyVersion, + } +} + +// grantVersionOf adapts a management Version to the projection engine's own +// type. The adapter exists so the additive-superset rule has exactly one +// implementation: this package reuses the engine's comparison rather than +// keeping a second copy that could drift from the one access is derived under. +func grantVersionOf(version Version) billingprojection.GrantVersion { + return billingprojection.GrantVersion{ + ID: version.ID, ProductID: version.ProductID, EntitlementID: version.EntitlementID, + EntitlementKey: version.EntitlementKey, Version: version.Version, + EffectiveStart: version.EffectiveStart, EffectiveEnd: version.EffectiveEnd, + SupportedPurchaseTypes: version.SupportedPurchaseTypes, Policy: version.Policy, + } +} diff --git a/apps/api/internal/billinggrant/validate_test.go b/apps/api/internal/billinggrant/validate_test.go new file mode 100644 index 00000000..652ca30c --- /dev/null +++ b/apps/api/internal/billinggrant/validate_test.go @@ -0,0 +1,199 @@ +package billinggrant + +import ( + "errors" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +func at(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed.UTC() +} + +func ptr(value string) *time.Time { + parsed := at(value) + return &parsed +} + +func openVersion(number int, start string, policy billingprojection.Policy) Version { + return Version{ + ID: "pegv_open", ProductID: "prod_pro", EntitlementID: "ent_pro", + Version: number, EffectiveStart: at(start), Policy: policy, + SupportedPurchaseTypes: DefaultPurchaseTypes(), + } +} + +func fullAccess() billingprojection.Policy { + return billingprojection.Policy{ + GrantsInActive: true, GrantsInTrial: true, GrantsInGrace: true, GrantsInOneTime: true, + } +} + +// PlanPublish decides what a grant change does, and every rule it enforces is a +// rule about somebody's access. The failures below are all silent in +// production: each produces a grant history that reads as valid and makes the +// projection engine select a different version for purchases that were made +// before anyone published anything. +// +// It is a unit test because the decision is pure. The database enforces the +// same shape as a backstop, but a constraint violation is not a usable answer +// for an operator, and the interesting cases (prospective vs retroactive, +// additive superset) are not expressible as constraints at all. +func TestPlanPublishRules(t *testing.T) { + now := at("2026-07-28T12:00:00Z") + + t.Run("first version for a pair starts the history", func(t *testing.T) { + plan, err := PlanPublish(nil, PublishInput{ + ProductID: "prod_pro", EntitlementID: "ent_pro", + EffectiveStart: at("2026-08-01T00:00:00Z"), Policy: fullAccess(), + }, now) + if err != nil { + t.Fatal(err) + } + if plan.NextVersion != 1 || plan.SupersededVersionID != "" { + t.Fatalf("first publish planned %+v, want version 1 superseding nothing", plan) + } + }) + + t.Run("publishing closes the open version at the new start", func(t *testing.T) { + existing := []Version{openVersion(3, "2026-01-01T00:00:00Z", fullAccess())} + start := at("2026-09-01T00:00:00Z") + plan, err := PlanPublish(existing, PublishInput{ + ProductID: "prod_pro", EntitlementID: "ent_pro", + EffectiveStart: start, Policy: fullAccess(), + }, now) + if err != nil { + t.Fatal(err) + } + if plan.NextVersion != 4 { + t.Fatalf("next version is %d, want 4", plan.NextVersion) + } + if plan.SupersededVersionID != "pegv_open" { + t.Fatalf("superseded %q, want the open version", plan.SupersededVersionID) + } + // Abutting exactly is what keeps every instant covered by exactly one + // version: a gap would strand purchases made inside it with no grant, an + // overlap would make selection depend on row order. + if !plan.SupersededAt.Equal(start) { + t.Fatalf("closed the predecessor at %s, want the new start %s", plan.SupersededAt, start) + } + }) + + t.Run("a prospective version may not be backdated", func(t *testing.T) { + _, err := PlanPublish([]Version{openVersion(1, "2026-01-01T00:00:00Z", fullAccess())}, + PublishInput{ + ProductID: "prod_pro", EntitlementID: "ent_pro", + EffectiveStart: at("2026-06-01T00:00:00Z"), Policy: fullAccess(), + }, now) + if !errors.Is(err, ErrInvalid) { + t.Fatalf("backdated prospective publish returned %v, want ErrInvalid", err) + } + }) + + t.Run("no proposal may reach into a closed interval", func(t *testing.T) { + closed := openVersion(1, "2026-01-01T00:00:00Z", fullAccess()) + closed.ID, closed.EffectiveEnd = "pegv_closed", ptr("2026-10-01T00:00:00Z") + _, err := PlanPublish([]Version{closed}, PublishInput{ + ProductID: "prod_pro", EntitlementID: "ent_pro", + EffectiveStart: at("2026-09-01T00:00:00Z"), Retroactive: true, Policy: fullAccess(), + }, now) + if !errors.Is(err, ErrOverlap) { + t.Fatalf("proposal inside a closed interval returned %v, want ErrOverlap", err) + } + }) + + t.Run("a proposal may not start at or before the current version", func(t *testing.T) { + existing := []Version{openVersion(1, "2026-01-01T00:00:00Z", fullAccess())} + _, err := PlanPublish(existing, PublishInput{ + ProductID: "prod_pro", EntitlementID: "ent_pro", + EffectiveStart: at("2026-01-01T00:00:00Z"), Retroactive: true, Policy: fullAccess(), + }, now) + if !errors.Is(err, ErrOverlap) { + t.Fatalf("proposal at the current version's own start returned %v, want ErrOverlap", err) + } + }) + + t.Run("a retroactive version must widen access, never narrow it", func(t *testing.T) { + existing := []Version{openVersion(1, "2026-01-01T00:00:00Z", fullAccess())} + narrowed := fullAccess() + narrowed.GrantsInGrace = false + + _, err := PlanPublish(existing, PublishInput{ + ProductID: "prod_pro", EntitlementID: "ent_pro", + EffectiveStart: at("2026-06-01T00:00:00Z"), Retroactive: true, Policy: narrowed, + }, now) + if !errors.Is(err, ErrNotAdditiveSuperset) { + t.Fatalf("retroactive narrowing returned %v, want ErrNotAdditiveSuperset", err) + } + + widened := fullAccess() + widened.GrantsInBillingRetry = true + if _, err := PlanPublish(existing, PublishInput{ + ProductID: "prod_pro", EntitlementID: "ent_pro", + EffectiveStart: at("2026-06-01T00:00:00Z"), Retroactive: true, Policy: widened, + }, now); err != nil { + t.Fatalf("retroactive widening was refused: %v", err) + } + }) + + t.Run("the same narrowing is permitted prospectively", func(t *testing.T) { + // The additive-superset rule is about retroactive change specifically. + // Applying it to a prospective change would make a catalog whose meaning + // can only ever grow, which is not the decision OD-8 records. + existing := []Version{openVersion(1, "2026-01-01T00:00:00Z", fullAccess())} + narrowed := fullAccess() + narrowed.GrantsInGrace = false + if _, err := PlanPublish(existing, PublishInput{ + ProductID: "prod_pro", EntitlementID: "ent_pro", + EffectiveStart: at("2026-09-01T00:00:00Z"), Policy: narrowed, + }, now); err != nil { + t.Fatalf("prospective narrowing was refused: %v", err) + } + }) + + t.Run("another pair's history is not consulted", func(t *testing.T) { + other := openVersion(9, "2027-01-01T00:00:00Z", fullAccess()) + other.EntitlementID = "ent_other" + plan, err := PlanPublish([]Version{other}, PublishInput{ + ProductID: "prod_pro", EntitlementID: "ent_pro", + EffectiveStart: at("2026-08-01T00:00:00Z"), Policy: fullAccess(), + }, now) + if err != nil { + t.Fatal(err) + } + if plan.NextVersion != 1 || plan.SupersededVersionID != "" { + t.Fatalf("planned %+v against another Entitlement's history, want a fresh version 1", plan) + } + }) +} + +// A paused-access override and an unsupported purchase type are refused before +// anything is read, so an operator gets a sentence rather than a constraint +// name from a failed insert. +func TestValidateShapeRefusesUnrepresentablePolicies(t *testing.T) { + base := Normalize(PublishInput{ + ProductID: "prod_pro", EntitlementID: "ent_pro", + EffectiveStart: at("2026-08-01T00:00:00Z"), Policy: fullAccess(), + }) + if err := ValidateShape(base); err != nil { + t.Fatalf("a plain proposal was refused: %v", err) + } + + paused := base + paused.GrantsInPaused = true + if err := ValidateShape(paused); !errors.Is(err, ErrInvalid) { + t.Fatalf("paused-access override returned %v, want ErrInvalid", err) + } + + unsupported := base + unsupported.SupportedPurchaseTypes = []string{"consumable"} + if err := ValidateShape(unsupported); !errors.Is(err, ErrInvalid) { + t.Fatalf("consumable purchase type returned %v, want ErrInvalid", err) + } +} diff --git a/apps/api/internal/billingoperator/errors.go b/apps/api/internal/billingoperator/errors.go new file mode 100644 index 00000000..2510c5f6 --- /dev/null +++ b/apps/api/internal/billingoperator/errors.go @@ -0,0 +1,14 @@ +package billingoperator + +import "errors" + +// Stable domain errors, mapped onto HTTP in exactly one place by the handler. +var ( + ErrUnauthenticated = errors.New("an authenticated actor is required") + ErrForbidden = errors.New("the actor may not read this Project's billing state") + ErrNotFound = errors.New("the requested resource was not found") + ErrInvalid = errors.New("the request is not valid") + ErrConflict = errors.New("the resource is in a conflicting state") + ErrBillingDisabled = errors.New("billing is not enabled for this Project") + ErrUnavailable = errors.New("billing operator state could not be read") +) diff --git a/apps/api/internal/billingoperator/model.go b/apps/api/internal/billingoperator/model.go new file mode 100644 index 00000000..05419b24 --- /dev/null +++ b/apps/api/internal/billingoperator/model.go @@ -0,0 +1,343 @@ +// Package billingoperator owns the Phase 9B operator (dashboard) surface over +// Mosaic's billing identity, subscription, and entitlement state. +// +// It exists because every other 9B surface authenticates a machine. The +// trusted-server APIs in billingcustomer, billingaccess, and billingrestore +// take their tenant from a secret server key, which is exactly right for an +// application backend and structurally unreachable from a browser session. An +// operator looking at the Customers page holds a session cookie and an +// organization role, so the same state needs a second door with a different +// lock — plan §15: "every operator surface permission-checked server-side". +// +// The package derives nothing. It authorizes, then reads through the ports +// below, which are satisfied by the services and repositories that already own +// the state. The one write it can trigger — identity conflict resolution — +// is delegated wholesale to billingcustomer, so the unfreeze, the audit, and +// the reprojection of both candidates happen in the code that already gets +// them right, not in a second copy that could drift. +package billingoperator + +import "time" + +// Identifier types the read-only customer lookup accepts. The set is closed and +// small on purpose: it is the three identifiers an operator can plausibly be +// handed by a support ticket, and nothing here can be extended into a general +// query language over customer state. +const ( + IdentifierBillingCustomerID = "billing_customer_id" + IdentifierApplicationUserID = "application_user_id" + IdentifierInstallationID = "installation_id" +) + +// ValidIdentifierType reports whether the lookup accepts a type. +func ValidIdentifierType(value string) bool { + switch value { + case IdentifierBillingCustomerID, IdentifierApplicationUserID, IdentifierInstallationID: + return true + default: + return false + } +} + +// CustomerSummary is one row of the operator customer list, and the header of +// the detail page. +// +// `Identified` and `PurchaseAnchored` are separate booleans rather than one +// enum because they are independent facts and the interesting customers are the +// ones where they disagree: a purchase-anchored customer who never identified +// is a real revenue record with no person attached, and an identified customer +// with no purchase is a person with no revenue. Collapsing them would hide both +// (plan §5a). +type CustomerSummary struct { + ID string `json:"billingCustomerId"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + Status string `json:"status"` + DiagnosticsStatus string `json:"diagnosticsStatus"` + Identified bool `json:"identified"` + PurchaseAnchored bool `json:"purchaseAnchored"` + HasOpenConflict bool `json:"hasOpenIdentityConflict"` + FrozenLineageCount int `json:"frozenLineageCount"` + CurrentProjectionVersion int64 `json:"currentProjectionVersion"` + LastProjectedAt *time.Time `json:"lastProjectedAt,omitempty"` + // SnapshotVersion and SnapshotUpdatedAt describe the Environment's current + // entitlement pointer. They are absent when the customer has never been + // projected in this Environment, which is a different answer from "has no + // entitlements" and is kept different. + SnapshotVersion *int64 `json:"snapshotVersion,omitempty"` + SnapshotUpdatedAt *time.Time `json:"snapshotUpdatedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// AliasView is one alias as an operator may see it. +// +// There is no value field and no digest field. The alias id is the protected +// representation: it is random, it is stable, it identifies the row for a +// revocation, and it reveals nothing about the person. An alias digest is still +// a stable per-person identifier and would let one tenant's export be joined +// against another's, so it never leaves the persistence layer. +type AliasView struct { + AliasID string `json:"aliasId"` + AliasType string `json:"aliasType"` + SourceAuthority string `json:"sourceAuthority"` + VerificationStatus string `json:"verificationStatus"` + Active bool `json:"active"` + EffectiveStart time.Time `json:"effectiveStart"` + EffectiveEnd *time.Time `json:"effectiveEnd,omitempty"` +} + +// LineageView is one purchase chain attached to the customer. +type LineageView struct { + PurchaseLineageID string `json:"purchaseLineageId"` + EnvironmentID string `json:"environmentId"` + Provider string `json:"provider"` + StoreEnvironment string `json:"storeEnvironment"` + LineageType string `json:"lineageType"` + ProjectionFrozen bool `json:"projectionFrozen"` + DiagnosticStatus string `json:"diagnosticStatus"` + SupersededByLineageID string `json:"supersededByLineageId,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// OneTimePurchaseView is validated ownership of a non-consumable. +type OneTimePurchaseView struct { + InstanceID string `json:"oneTimePurchaseInstanceId"` + PurchaseLineageID string `json:"purchaseLineageId"` + Provider string `json:"provider"` + MosaicProductID string `json:"mosaicProductId,omitempty"` + ProviderProductIdentifier string `json:"providerProductIdentifier,omitempty"` + AcquiredAt time.Time `json:"acquiredAt"` + ValidityState string `json:"validityState"` + RefundEffectiveAt *time.Time `json:"refundEffectiveAt,omitempty"` + RevocationEffectiveAt *time.Time `json:"revocationEffectiveAt,omitempty"` +} + +// ConflictView is one identity conflict on the operator surface. It carries the +// disputed alias *family* and never the disputed alias digest. +type ConflictView struct { + ConflictID string `json:"conflictId"` + ProjectID string `json:"projectId"` + Scope string `json:"scope"` + Status string `json:"status"` + PurchaseLineageID string `json:"purchaseLineageId,omitempty"` + AliasType string `json:"aliasType,omitempty"` + FirstCustomerID string `json:"firstCustomerId"` + SecondCustomerID string `json:"secondCustomerId"` + DiagnosticCode string `json:"diagnosticCode,omitempty"` + OpenedAt time.Time `json:"openedAt"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + ResolutionAction string `json:"resolutionAction,omitempty"` + ResolutionReason string `json:"resolutionReason,omitempty"` +} + +// ConflictDetailView adds the disputed lineage, which is what an operator needs +// before choosing between keeping, reassigning, and splitting. +type ConflictDetailView struct { + Conflict ConflictView `json:"conflict"` + Lineage *LineageView `json:"lineage,omitempty"` +} + +// ResolutionActions, as OD-10 names them. The stored vocabulary is the schema's +// three actions; these are the operator-facing names the dashboard uses, mapped +// in exactly one place (resolutionAction). +const ( + // ActionKeepExisting awards the disputed subject to the incumbent. + ActionKeepExisting = "keep_existing" + // ActionReassign awards it to the candidate the evidence proposed. + ActionReassign = "reassign_to_candidate" + // ActionSplit awards it to neither: the operator has decided these are two + // people and the disputed link is removed rather than moved. + ActionSplit = "operator_split" +) + +// storedAction maps an operator action onto the schema's resolution_action. +func storedAction(action string) (string, bool) { + switch action { + case ActionKeepExisting: + return "assigned_first", true + case ActionReassign: + return "assigned_second", true + case ActionSplit: + return "detached_both", true + default: + return "", false + } +} + +// OperatorAction is the inverse, so a resolved conflict reads back in the same +// vocabulary the operator used. +func OperatorAction(stored string) string { + switch stored { + case "assigned_first": + return ActionKeepExisting + case "assigned_second": + return ActionReassign + case "detached_both": + return ActionSplit + default: + return "" + } +} + +// EntitlementEntryView is one Entitlement's committed state on the customer's +// current snapshot. +type EntitlementEntryView struct { + EntitlementID string `json:"entitlementId"` + EntitlementKey string `json:"entitlementKey"` + State string `json:"state"` + EffectiveStart *time.Time `json:"effectiveStart,omitempty"` + EffectiveEnd *time.Time `json:"effectiveEnd,omitempty"` + EndKnown bool `json:"endKnown"` + SourceCount int `json:"sourceCount"` + UncertaintyReason string `json:"uncertaintyReason,omitempty"` + IsTestSource bool `json:"isTestSource"` + ExplanationCode string `json:"explanationCode,omitempty"` + SourceIDs []string `json:"sourceIds,omitempty"` +} + +// EntitlementSourceView is one reason the customer holds, or may hold, an +// Entitlement. Source identity is (lineage, product, grant version) — never a +// fact id — so multi-fact-per-purchase cannot double-grant (plan §3). +type EntitlementSourceView struct { + SourceID string `json:"sourceId"` + EntitlementID string `json:"entitlementId"` + PurchaseLineageID string `json:"purchaseLineageId,omitempty"` + MosaicProductID string `json:"mosaicProductId,omitempty"` + GrantVersionID string `json:"grantVersionId,omitempty"` + SubscriptionInstanceID string `json:"subscriptionInstanceId,omitempty"` + OneTimePurchaseInstanceID string `json:"oneTimePurchaseInstanceId,omitempty"` + StorePlatform string `json:"storePlatform,omitempty"` + SourceType string `json:"sourceType,omitempty"` + SourceState string `json:"sourceState,omitempty"` + SourceStart *time.Time `json:"sourceStart,omitempty"` + SourceEnd *time.Time `json:"sourceEnd,omitempty"` + EndKnown bool `json:"endKnown"` + UncertaintyReason string `json:"uncertaintyReason,omitempty"` + IsTestSource bool `json:"isTestSource"` + ExplanationCode string `json:"explanationCode,omitempty"` +} + +// SnapshotView is the customer's current committed entitlement snapshot as the +// operator surface reports it. +type SnapshotView struct { + SnapshotID string `json:"snapshotId"` + SnapshotVersion int64 `json:"snapshotVersion"` + PreviousSnapshotVersion int64 `json:"previousSnapshotVersion,omitempty"` + ProjectionRuleVersion int `json:"projectionRuleVersion"` + ComputedAt time.Time `json:"computedAt"` + AsOf time.Time `json:"asOf"` + ChangeReason string `json:"changeReason,omitempty"` + Entries []EntitlementEntryView `json:"entries"` + Sources []EntitlementSourceView `json:"sources"` +} + +// ProjectionStatusView is the health of the projection behind what is shown. +type ProjectionStatusView struct { + State string `json:"state"` + LastProjectedAt time.Time `json:"lastProjectedAt"` + PendingFactCount int `json:"pendingFactCount"` + DiagnosticCode string `json:"diagnosticCode,omitempty"` +} + +// SubscriptionView is one projected Subscription Instance. +type SubscriptionView struct { + SubscriptionInstanceID string `json:"subscriptionInstanceId"` + PurchaseLineageID string `json:"purchaseLineageId"` + BillingCustomerID string `json:"billingCustomerId,omitempty"` + EnvironmentID string `json:"environmentId"` + StorePlatform string `json:"storePlatform"` + MosaicProductID string `json:"mosaicProductId,omitempty"` + PriorMosaicProductID string `json:"priorMosaicProductId,omitempty"` + AccessState string `json:"accessState"` + LifecycleState string `json:"lifecycleState"` + RenewalIntent string `json:"renewalIntent,omitempty"` + BillingState string `json:"billingState,omitempty"` + UncertaintyReason string `json:"uncertaintyReason,omitempty"` + ProjectionVersion int64 `json:"projectionVersion"` + ProjectionRuleVersion int `json:"projectionRuleVersion"` + ComputedAt time.Time `json:"computedAt"` + AsOf time.Time `json:"asOf"` + PeriodStart *time.Time `json:"periodStart,omitempty"` + PeriodEnd *time.Time `json:"periodEnd,omitempty"` + GracePeriodEnd *time.Time `json:"gracePeriodEnd,omitempty"` + BillingRetryStart *time.Time `json:"billingRetryStart,omitempty"` + PauseEffectiveAt *time.Time `json:"pauseEffectiveAt,omitempty"` + PauseResumeAt *time.Time `json:"pauseResumeAt,omitempty"` + CancellationEffectiveAt *time.Time `json:"cancellationEffectiveAt,omitempty"` + ExpirationEffectiveAt *time.Time `json:"expirationEffectiveAt,omitempty"` + RevocationEffectiveAt *time.Time `json:"revocationEffectiveAt,omitempty"` + RefundEffectiveAt *time.Time `json:"refundEffectiveAt,omitempty"` + SupersededByInstanceID string `json:"supersededBySubscriptionInstanceId,omitempty"` + IsTestSource bool `json:"isTestSource"` + SourceFactCount int `json:"sourceFactCount"` + ChangeReason string `json:"changeReason,omitempty"` + ExplanationCode string `json:"explanationCode,omitempty"` +} + +// TimelineEntryView is one append-only explanation of a transition. +type TimelineEntryView struct { + TimelineEntryID string `json:"timelineEntryId"` + EntryType string `json:"entryType"` + EffectiveAt time.Time `json:"effectiveAt"` + ObservedAt time.Time `json:"observedAt"` + SubscriptionInstanceID string `json:"subscriptionInstanceId,omitempty"` + MosaicProductID string `json:"mosaicProductId,omitempty"` + PriorMosaicProductID string `json:"priorMosaicProductId,omitempty"` + ExplanationCode string `json:"explanationCode,omitempty"` + Detail map[string]string `json:"detail,omitempty"` +} + +// RestoreJobView is one restore/sync job on the operator surface. +type RestoreJobView struct { + RestoreID string `json:"restoreId"` + EnvironmentID string `json:"environmentId"` + BillingCustomerID string `json:"billingCustomerId,omitempty"` + StorePlatform string `json:"storePlatform"` + Status string `json:"status"` + Outcome string `json:"outcome,omitempty"` + ProviderOutcome string `json:"providerOutcome"` + UncertaintyReason string `json:"uncertaintyReason"` + ObservedTransactionCount int `json:"observedTransactionCount"` + PendingValidationCount int `json:"pendingValidationCount"` + BaselineSnapshotVersion *int64 `json:"baselineSnapshotVersion,omitempty"` + SnapshotVersion *int64 `json:"snapshotVersion,omitempty"` + AttemptCount int `json:"attemptCount"` + MaxAttempts int `json:"maxAttempts"` + RequestedAt time.Time `json:"requestedAt"` + UpdatedAt time.Time `json:"updatedAt"` + CompletedAt *time.Time `json:"completedAt,omitempty"` +} + +// CustomerDetail is everything the customer page shows in one read. It is one +// service call rather than eight so the page describes one instant: an operator +// comparing a snapshot version against a projection status assembled from eight +// separate requests would be comparing eight different moments. +type CustomerDetail struct { + Customer CustomerSummary `json:"customer"` + Aliases []AliasView `json:"aliases"` + Lineages []LineageView `json:"purchaseLineages"` + Subscriptions []SubscriptionView `json:"subscriptions"` + OneTime []OneTimePurchaseView `json:"oneTimePurchases"` + Conflicts []ConflictView `json:"identityConflicts"` + // Snapshot is absent when the customer has never been projected in this + // Environment. Absent is not empty: "no answer yet" and "no entitlements" + // are different states and the surface keeps them different. + Snapshot *SnapshotView `json:"currentSnapshot,omitempty"` + Projection *ProjectionStatusView `json:"projectionStatus,omitempty"` +} + +// Actor is the authenticated operator principal. +type Actor struct{ ID string } + +// CustomerFilter narrows the customer list. +type CustomerFilter struct { + // Status is "" (any), or one of the billing customer statuses. + Status string + // Identified restricts to identified or to purchase-anchored-only customers. + // Nil means both. + Identified *bool + // ConflictedOnly restricts to customers with an open identity conflict. + ConflictedOnly bool +} diff --git a/apps/api/internal/billingoperator/ports.go b/apps/api/internal/billingoperator/ports.go new file mode 100644 index 00000000..b58296cb --- /dev/null +++ b/apps/api/internal/billingoperator/ports.go @@ -0,0 +1,80 @@ +package billingoperator + +import ( + "context" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" +) + +// Repository is the operator read model. +// +// Every method on it is a SELECT. There is deliberately no insert, no update, +// and no create-or-get anywhere on this port: the lookup surface must be +// structurally incapable of minting a Billing Customer, and the absence of a +// method is what makes that true rather than a check a later edit could remove. +type Repository interface { + // Authorize checks the actor's organization role for the Project and that + // the Environment belongs to it. Absent membership is reported as + // ErrNotFound, matching every other Mosaic surface: telling a caller a + // Project exists but is not theirs is an existence oracle. + Authorize(ctx context.Context, actor Actor, projectID, environmentID string) error + // AuthorizeProject is the same check without an Environment, for the + // Project-scoped identity-conflict surface. + AuthorizeProject(ctx context.Context, actor Actor, projectID string) error + BillingEnabled(ctx context.Context, projectID string) (bool, error) + + // CustomerIDForAliasDigest resolves an active alias digest to a customer. + // It returns ErrNotFound for a miss and never creates anything. + CustomerIDForAliasDigest(ctx context.Context, projectID, aliasType string, digest []byte) (string, error) + // CustomerIDForInstallationDigest resolves an installation identifier + // through association evidence rather than through an alias resolution. + // An installation id is evidence and never an anchor (plan §5a rule 2a), so + // there is no alias row to read: the evidence table is where a client- + // generated identifier is allowed to appear, and reading it backwards for a + // support lookup is a read of history, not a selection rule. + CustomerIDForInstallationDigest(ctx context.Context, projectID string, digest []byte) (string, error) + + CustomerSummary(ctx context.Context, projectID, environmentID, customerID string) (CustomerSummary, error) + ListCustomers(ctx context.Context, projectID, environmentID string, filter CustomerFilter, + limit int, cursor string) ([]CustomerSummary, string, error) + + Lineages(ctx context.Context, projectID, environmentID, customerID string) ([]LineageView, error) + OneTimePurchases(ctx context.Context, projectID, environmentID, customerID string) ([]OneTimePurchaseView, error) + CustomerConflicts(ctx context.Context, projectID, customerID string) ([]ConflictView, error) + + ListRestoreJobs(ctx context.Context, projectID, environmentID, customerID string, + limit int, cursor string) ([]RestoreJobView, string, error) + RestoreJob(ctx context.Context, projectID, environmentID, restoreID string) (RestoreJobView, error) +} + +// Entitlements is the committed-projection read port. +// +// It is satisfied by the same repository the trusted-server access API reads +// through, so the dashboard and an application backend see one answer derived +// once. A second query path here would eventually disagree with that one, and +// both would look authoritative. +type Entitlements interface { + CurrentSnapshot(ctx context.Context, projectID, environmentID, customerID string) (billingaccess.SnapshotView, error) + ProjectionStatusFor(ctx context.Context, projectID, environmentID, customerID string) (billingaccess.ProjectionStatus, error) + Subscriptions(ctx context.Context, projectID, environmentID, customerID string, limit int, cursor string) ([]billingaccess.SubscriptionView, string, error) + Subscription(ctx context.Context, projectID, instanceID string) (billingaccess.SubscriptionView, error) + Timeline(ctx context.Context, projectID, instanceID string, limit int, cursor string) ([]billingaccess.TimelineEntry, string, error) +} + +// Identity is the port onto billing identity, satisfied by +// *billingcustomer.Service. +// +// Conflict resolution goes through it rather than through SQL of this package's +// own, because resolving a conflict is not one UPDATE: it applies the +// assignment, unfreezes the disputed subject, audits, and reprojects *both* +// candidates so the loser's committed snapshot stops granting a purchase it no +// longer holds. That sequence already exists and already has the transaction +// boundary right; duplicating it is how the two copies come to disagree. +type Identity interface { + ListAliases(ctx context.Context, actor billingcustomer.Actor, projectID, customerID string) ([]billingcustomer.Alias, error) + ListConflicts(ctx context.Context, actor billingcustomer.Actor, projectID, status string) ([]billingcustomer.Conflict, error) + ConflictDetail(ctx context.Context, actor billingcustomer.Actor, projectID, conflictID string) (billingcustomer.ConflictDetail, error) + ResolveConflict(ctx context.Context, actor billingcustomer.Actor, projectID, conflictID, action, assignedCustomerID, reason string) (billingcustomer.Conflict, error) + RequestSyncForOperator(ctx context.Context, actor billingcustomer.Actor, projectID, environmentID, customerID string) (billingcustomer.SyncRequest, error) +} diff --git a/apps/api/internal/billingoperator/service.go b/apps/api/internal/billingoperator/service.go new file mode 100644 index 00000000..fdfd9c7e --- /dev/null +++ b/apps/api/internal/billingoperator/service.go @@ -0,0 +1,567 @@ +package billingoperator + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" +) + +// Service is the operator application service. It owns the authorization +// decision on every surface in this package and nothing else: the state it +// returns is read through ports onto the modules that own it. +type Service struct { + repository Repository + entitlements Entitlements + identity Identity + tracer trace.Tracer + + lookups metric.Int64Counter +} + +func NewService(repository Repository, entitlements Entitlements, identity Identity) *Service { + service := &Service{ + repository: repository, + entitlements: entitlements, + identity: identity, + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/billingoperator"), + } + // Lookup volume by outcome is the signal that distinguishes support use from + // enumeration: a rise in misses without a matching rise in hits is somebody + // guessing identifiers, and it is exactly what the rate limit in front of + // this surface exists to bound. + service.lookups, _ = otel.Meter("mosaic/billingoperator").Int64Counter( + "mosaic.billing.operator.customer_lookups") + return service +} + +// --------------------------------------------------------------------------- +// Customer lookup and list +// --------------------------------------------------------------------------- + +// LookupResult is the answer to a typed-identifier lookup. A miss is a +// first-class result rather than a 404, because "no customer holds this +// identifier" is a true and useful answer to a support question — and because +// answering it as an error would make an enumeration attempt indistinguishable +// from a mistyped route. +type LookupResult struct { + Found bool `json:"found"` + Customer *CustomerSummary `json:"customer,omitempty"` +} + +// LookupCustomer resolves one typed identifier to at most one customer. +// +// The submitted value is digested here and never stored, never logged, and +// never echoed. For an application user id the digest is matched against the +// active alias resolutions; for an installation id it is matched against +// association evidence, because an installation identifier is evidence and can +// never be an anchor (plan §5a rule 2a) — there is no alias resolution to read. +// +// Nothing on this path can create. The repository port exposes no writer, so a +// lookup that misses leaves the database exactly as it found it. That is the +// distinction from the trusted identify surface, which is create-or-get and +// would mint a customer for every mistyped support query if it were reused here. +func (s *Service) LookupCustomer(ctx context.Context, actor Actor, projectID, environmentID, + identifierType, value string) (LookupResult, error) { + + ctx, span := s.tracer.Start(ctx, "billing.operator.customer_lookup") + defer span.End() + span.SetAttributes(attribute.String("mosaic.billing.lookup.identifier_type", identifierType)) + + if err := s.authorize(ctx, actor, projectID, environmentID); err != nil { + return LookupResult{}, err + } + if !ValidIdentifierType(identifierType) { + return LookupResult{}, ErrInvalid + } + value = strings.TrimSpace(value) + if value == "" || len(value) > 512 { + return LookupResult{}, ErrInvalid + } + + customerID := "" + var err error + switch identifierType { + case IdentifierBillingCustomerID: + customerID = value + case IdentifierApplicationUserID: + customerID, err = s.repository.CustomerIDForAliasDigest(ctx, projectID, + billingcustomer.AliasApplicationUser, billingcustomer.AliasDigest(billingcustomer.AliasApplicationUser, value)) + case IdentifierInstallationID: + customerID, err = s.repository.CustomerIDForInstallationDigest(ctx, projectID, + billingcustomer.AliasDigest(billingcustomer.AliasInstallation, value)) + } + if err != nil { + if errors.Is(err, ErrNotFound) { + return s.lookupMiss(ctx, identifierType), nil + } + return LookupResult{}, s.unavailable(ctx, projectID, "customer lookup failed", err) + } + + summary, err := s.repository.CustomerSummary(ctx, projectID, environmentID, customerID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return s.lookupMiss(ctx, identifierType), nil + } + return LookupResult{}, s.unavailable(ctx, projectID, "customer lookup failed", err) + } + s.lookups.Add(ctx, 1, metric.WithAttributes( + attribute.String("identifier_type", identifierType), attribute.String("outcome", "match"))) + span.SetAttributes(attribute.Bool("mosaic.billing.lookup.match", true)) + return LookupResult{Found: true, Customer: &summary}, nil +} + +func (s *Service) lookupMiss(ctx context.Context, identifierType string) LookupResult { + s.lookups.Add(ctx, 1, metric.WithAttributes( + attribute.String("identifier_type", identifierType), attribute.String("outcome", "miss"))) + return LookupResult{Found: false} +} + +// ListCustomers pages the Environment's Billing Customers. +// +// The list is Environment-filtered even though customer identity is +// Project-scoped (OD-3(b)): everything an operator looks at on a customer — +// lineages, subscriptions, snapshots — is Environment-scoped, so a list that +// mixed Environments would put a production customer next to a sandbox one with +// no way to tell. A customer that holds no state in any Environment yet is the +// one exception and appears everywhere, because it genuinely belongs nowhere. +func (s *Service) ListCustomers(ctx context.Context, actor Actor, projectID, environmentID string, + filter CustomerFilter, limit int, cursor string) ([]CustomerSummary, string, error) { + + if err := s.authorize(ctx, actor, projectID, environmentID); err != nil { + return nil, "", err + } + if filter.Status != "" { + switch filter.Status { + case billingcustomer.StatusActive, billingcustomer.StatusFrozen, + billingcustomer.StatusAnonymized, billingcustomer.StatusAbsorbed: + default: + return nil, "", ErrInvalid + } + } + customers, next, err := s.repository.ListCustomers(ctx, projectID, environmentID, filter, boundedLimit(limit), cursor) + if err != nil { + if errors.Is(err, ErrInvalid) { + return nil, "", ErrInvalid + } + return nil, "", s.unavailable(ctx, projectID, "customer list failed", err) + } + return customers, next, nil +} + +// Customer assembles the customer detail page. +func (s *Service) Customer(ctx context.Context, actor Actor, projectID, environmentID, customerID string) (CustomerDetail, error) { + ctx, span := s.tracer.Start(ctx, "billing.operator.customer_detail") + defer span.End() + + if err := s.authorize(ctx, actor, projectID, environmentID); err != nil { + return CustomerDetail{}, err + } + customerID = strings.TrimSpace(customerID) + summary, err := s.repository.CustomerSummary(ctx, projectID, environmentID, customerID) + if err != nil { + return CustomerDetail{}, s.classify(ctx, projectID, "customer detail failed", err) + } + detail := CustomerDetail{ + Customer: summary, + Aliases: []AliasView{}, + Lineages: []LineageView{}, + Subscriptions: []SubscriptionView{}, + OneTime: []OneTimePurchaseView{}, + Conflicts: []ConflictView{}, + } + + aliases, err := s.identity.ListAliases(ctx, billingcustomer.Actor(actor), projectID, customerID) + if err != nil { + return CustomerDetail{}, s.classifyIdentity(err) + } + for _, alias := range aliases { + detail.Aliases = append(detail.Aliases, aliasView(alias)) + } + + if detail.Lineages, err = s.repository.Lineages(ctx, projectID, environmentID, customerID); err != nil { + return CustomerDetail{}, s.classify(ctx, projectID, "customer lineages failed", err) + } + if detail.OneTime, err = s.repository.OneTimePurchases(ctx, projectID, environmentID, customerID); err != nil { + return CustomerDetail{}, s.classify(ctx, projectID, "customer one-time purchases failed", err) + } + if detail.Conflicts, err = s.repository.CustomerConflicts(ctx, projectID, customerID); err != nil { + return CustomerDetail{}, s.classify(ctx, projectID, "customer conflicts failed", err) + } + + subscriptions, _, err := s.entitlements.Subscriptions(ctx, projectID, environmentID, customerID, maxDetailSubscriptions, "") + if err != nil { + return CustomerDetail{}, s.classify(ctx, projectID, "customer subscriptions failed", err) + } + for _, subscription := range subscriptions { + detail.Subscriptions = append(detail.Subscriptions, subscriptionView(subscription)) + } + + // A customer that has never been projected in this Environment is not an + // error and is not empty: `currentSnapshot` is simply absent, and the + // projection status says why. + snapshot, err := s.entitlements.CurrentSnapshot(ctx, projectID, environmentID, customerID) + switch { + case err == nil: + view := snapshotView(snapshot) + detail.Snapshot = &view + case errors.Is(err, billingaccess.ErrNotFound): + default: + return CustomerDetail{}, s.classify(ctx, projectID, "customer snapshot failed", err) + } + if status, statusErr := s.entitlements.ProjectionStatusFor(ctx, projectID, environmentID, customerID); statusErr == nil { + view := projectionStatusView(status) + detail.Projection = &view + } + + span.SetAttributes( + attribute.String("mosaic.billing.customer.id", customerID), + attribute.Int("mosaic.billing.customer.subscriptions", len(detail.Subscriptions))) + return detail, nil +} + +// maxDetailSubscriptions bounds the subscriptions embedded in the detail read. +// A customer with more than this many purchase chains is pathological, and the +// paged subscriptions endpoint is where a full list is read. +const maxDetailSubscriptions = 50 + +// Snapshot reads the customer's current entitlement snapshot on its own, which +// is what the entitlement panel refreshes against without re-reading the whole +// page. +func (s *Service) Snapshot(ctx context.Context, actor Actor, projectID, environmentID, customerID string) (SnapshotView, ProjectionStatusView, error) { + if err := s.authorize(ctx, actor, projectID, environmentID); err != nil { + return SnapshotView{}, ProjectionStatusView{}, err + } + snapshot, err := s.entitlements.CurrentSnapshot(ctx, projectID, environmentID, strings.TrimSpace(customerID)) + if err != nil { + return SnapshotView{}, ProjectionStatusView{}, s.classify(ctx, projectID, "snapshot read failed", err) + } + status := ProjectionStatusView{} + if read, statusErr := s.entitlements.ProjectionStatusFor(ctx, projectID, environmentID, customerID); statusErr == nil { + status = projectionStatusView(read) + } + return snapshotView(snapshot), status, nil +} + +// Subscriptions pages one customer's projected subscriptions. +func (s *Service) Subscriptions(ctx context.Context, actor Actor, projectID, environmentID, customerID string, + limit int, cursor string) ([]SubscriptionView, string, error) { + + if err := s.authorize(ctx, actor, projectID, environmentID); err != nil { + return nil, "", err + } + views, next, err := s.entitlements.Subscriptions(ctx, projectID, environmentID, + strings.TrimSpace(customerID), boundedLimit(limit), cursor) + if err != nil { + return nil, "", s.classify(ctx, projectID, "subscription list failed", err) + } + result := make([]SubscriptionView, 0, len(views)) + for _, view := range views { + result = append(result, subscriptionView(view)) + } + return result, next, nil +} + +// Subscription reads one projected Subscription Instance. +// +// The Environment on the route is re-checked against the instance's own +// Environment and a mismatch is reported as absent, not as forbidden: a +// staging URL that happens to name a production instance must not confirm that +// the instance exists. +func (s *Service) Subscription(ctx context.Context, actor Actor, projectID, environmentID, instanceID string) (SubscriptionView, error) { + if err := s.authorize(ctx, actor, projectID, environmentID); err != nil { + return SubscriptionView{}, err + } + view, err := s.entitlements.Subscription(ctx, projectID, strings.TrimSpace(instanceID)) + if err != nil { + return SubscriptionView{}, s.classify(ctx, projectID, "subscription read failed", err) + } + if view.EnvironmentID != environmentID { + return SubscriptionView{}, ErrNotFound + } + return subscriptionView(view), nil +} + +// Timeline reads one Subscription Instance's append-only explanation history. +func (s *Service) Timeline(ctx context.Context, actor Actor, projectID, environmentID, instanceID string, + limit int, cursor string) ([]TimelineEntryView, string, error) { + + if err := s.authorize(ctx, actor, projectID, environmentID); err != nil { + return nil, "", err + } + instanceID = strings.TrimSpace(instanceID) + view, err := s.entitlements.Subscription(ctx, projectID, instanceID) + if err != nil { + return nil, "", s.classify(ctx, projectID, "timeline read failed", err) + } + if view.EnvironmentID != environmentID { + return nil, "", ErrNotFound + } + entries, next, err := s.entitlements.Timeline(ctx, projectID, instanceID, boundedLimit(limit), cursor) + if err != nil { + return nil, "", s.classify(ctx, projectID, "timeline read failed", err) + } + result := make([]TimelineEntryView, 0, len(entries)) + for _, entry := range entries { + result = append(result, timelineEntryView(entry)) + } + return result, next, nil +} + +// --------------------------------------------------------------------------- +// Identity conflicts +// --------------------------------------------------------------------------- + +// ListConflicts returns the Project's identity conflicts. +// +// They are Project-scoped, not Environment-scoped, and the route says so: a +// conflict is a dispute about who a person is, and identity in Mosaic belongs +// to the Project (OD-3(b)). Filing this page under an Environment would imply a +// conflict could be resolved differently in staging than in production. +func (s *Service) ListConflicts(ctx context.Context, actor Actor, projectID, status string) ([]ConflictView, error) { + if err := s.authorizeProject(ctx, actor, projectID); err != nil { + return nil, err + } + switch status { + case "", "open", "resolved": + default: + return nil, ErrInvalid + } + conflicts, err := s.identity.ListConflicts(ctx, billingcustomer.Actor(actor), projectID, status) + if err != nil { + return nil, s.classifyIdentity(err) + } + views := make([]ConflictView, 0, len(conflicts)) + for _, conflict := range conflicts { + views = append(views, conflictView(conflict)) + } + return views, nil +} + +// Conflict returns one conflict with the lineage it disputes. +func (s *Service) Conflict(ctx context.Context, actor Actor, projectID, conflictID string) (ConflictDetailView, error) { + if err := s.authorizeProject(ctx, actor, projectID); err != nil { + return ConflictDetailView{}, err + } + detail, err := s.identity.ConflictDetail(ctx, billingcustomer.Actor(actor), projectID, strings.TrimSpace(conflictID)) + if err != nil { + return ConflictDetailView{}, s.classifyIdentity(err) + } + view := ConflictDetailView{Conflict: conflictView(detail.Conflict)} + if detail.Lineage != nil { + lineage := lineageView(*detail.Lineage) + view.Lineage = &lineage + } + return view, nil +} + +// ResolveConflict applies an operator's decision (OD-10). +// +// It delegates the whole operation. billingcustomer.ResolveConflict applies the +// assignment under a row lock, unfreezes the disputed subject, writes the audit +// event, and enqueues a reprojection for *both* candidates — the loser included, +// because the loser is the one holding a committed snapshot that still grants +// the purchase. This method contributes the operator vocabulary and the reason +// requirement, and nothing else; there is no second write path here that could +// skip one of those steps. +func (s *Service) ResolveConflict(ctx context.Context, actor Actor, projectID, conflictID, + action, assignedCustomerID, reason string) (ConflictView, error) { + + ctx, span := s.tracer.Start(ctx, "billing.operator.resolve_conflict") + defer span.End() + + if err := s.authorizeProject(ctx, actor, projectID); err != nil { + return ConflictView{}, err + } + stored, ok := storedAction(action) + if !ok { + return ConflictView{}, ErrInvalid + } + if strings.TrimSpace(reason) == "" || len(reason) > billingcustomer.MaxResolutionReasonLength { + return ConflictView{}, ErrInvalid + } + resolved, err := s.identity.ResolveConflict(ctx, billingcustomer.Actor(actor), projectID, + strings.TrimSpace(conflictID), stored, strings.TrimSpace(assignedCustomerID), reason) + if err != nil { + return ConflictView{}, s.classifyIdentity(err) + } + span.SetAttributes( + attribute.String("mosaic.billing.conflict.id", resolved.ID), + attribute.String("mosaic.billing.conflict.action", action)) + return conflictView(resolved), nil +} + +// --------------------------------------------------------------------------- +// Restore and sync +// --------------------------------------------------------------------------- + +// RequestSync enqueues a manual reprojection of one customer. +// +// This is the operator's "sync now". It is deliberately not a restore: a +// restore needs a device to ask its store for purchases, which no operator can +// do on a customer's behalf, and a control that claimed to would report a +// native outcome nobody produced. What an operator can legitimately ask for is +// a recomputation of committed state from the facts Mosaic already holds. +func (s *Service) RequestSync(ctx context.Context, actor Actor, projectID, environmentID, customerID string) (billingcustomer.SyncRequest, error) { + if err := s.authorize(ctx, actor, projectID, environmentID); err != nil { + return billingcustomer.SyncRequest{}, err + } + request, err := s.identity.RequestSyncForOperator(ctx, billingcustomer.Actor(actor), + projectID, environmentID, strings.TrimSpace(customerID)) + if err != nil { + return billingcustomer.SyncRequest{}, s.classifyIdentity(err) + } + return request, nil +} + +// ListRestoreJobs pages the Environment's restore/sync jobs. +func (s *Service) ListRestoreJobs(ctx context.Context, actor Actor, projectID, environmentID, customerID string, + limit int, cursor string) ([]RestoreJobView, string, error) { + + if err := s.authorize(ctx, actor, projectID, environmentID); err != nil { + return nil, "", err + } + jobs, next, err := s.repository.ListRestoreJobs(ctx, projectID, environmentID, + strings.TrimSpace(customerID), boundedLimit(limit), cursor) + if err != nil { + return nil, "", s.classify(ctx, projectID, "restore job list failed", err) + } + return jobs, next, nil +} + +// RestoreJob reads one restore/sync job's status. +func (s *Service) RestoreJob(ctx context.Context, actor Actor, projectID, environmentID, restoreID string) (RestoreJobView, error) { + if err := s.authorize(ctx, actor, projectID, environmentID); err != nil { + return RestoreJobView{}, err + } + job, err := s.repository.RestoreJob(ctx, projectID, environmentID, strings.TrimSpace(restoreID)) + if err != nil { + return RestoreJobView{}, s.classify(ctx, projectID, "restore job read failed", err) + } + return job, nil +} + +// --------------------------------------------------------------------------- +// Authorization and error classification +// --------------------------------------------------------------------------- + +// authorize is the one permission decision every Environment-scoped method +// here makes, and it is made before any state is read. Enablement is checked +// after authorization on purpose: "billing is not enabled for this Project" is +// itself information about a Project, and a caller who may not read the Project +// must not learn it. +func (s *Service) authorize(ctx context.Context, actor Actor, projectID, environmentID string) error { + if strings.TrimSpace(actor.ID) == "" { + return ErrUnauthenticated + } + if strings.TrimSpace(projectID) == "" || strings.TrimSpace(environmentID) == "" { + return ErrNotFound + } + if err := s.repository.Authorize(ctx, actor, projectID, environmentID); err != nil { + return classifyAuthorization(err) + } + return s.requireEnabled(ctx, projectID) +} + +func (s *Service) authorizeProject(ctx context.Context, actor Actor, projectID string) error { + if strings.TrimSpace(actor.ID) == "" { + return ErrUnauthenticated + } + if strings.TrimSpace(projectID) == "" { + return ErrNotFound + } + if err := s.repository.AuthorizeProject(ctx, actor, projectID); err != nil { + return classifyAuthorization(err) + } + return s.requireEnabled(ctx, projectID) +} + +func classifyAuthorization(err error) error { + switch { + case errors.Is(err, ErrUnauthenticated), errors.Is(err, ErrForbidden), errors.Is(err, ErrNotFound): + return err + default: + return ErrUnavailable + } +} + +// requireEnabled fails closed, matching every other billing surface: an +// unreadable setting is treated as disabled, so a transient database error +// cannot quietly re-enable a Project that asked Mosaic to hold no billing state. +func (s *Service) requireEnabled(ctx context.Context, projectID string) error { + enabled, err := s.repository.BillingEnabled(ctx, projectID) + if err != nil { + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("billing_error_kind", fmt.Sprintf("%T", err)). + Msg("billing enablement could not be read; treating the Project as disabled") + return ErrBillingDisabled + } + if !enabled { + return ErrBillingDisabled + } + return nil +} + +// classify maps a repository or access-module error onto this package's +// vocabulary. The cause is logged with identifiers only and never returned: a +// cause on this surface can quote a query, and a query here carries a digest. +func (s *Service) classify(ctx context.Context, projectID, message string, err error) error { + switch { + case errors.Is(err, ErrNotFound), errors.Is(err, billingaccess.ErrNotFound): + return ErrNotFound + case errors.Is(err, ErrForbidden), errors.Is(err, billingaccess.ErrForbidden): + return ErrForbidden + case errors.Is(err, ErrInvalid), errors.Is(err, billingaccess.ErrInvalid): + return ErrInvalid + default: + return s.unavailable(ctx, projectID, message, err) + } +} + +func (s *Service) classifyIdentity(err error) error { + switch { + case errors.Is(err, billingcustomer.ErrUnauthenticated): + return ErrUnauthenticated + case errors.Is(err, billingcustomer.ErrForbidden): + return ErrForbidden + case errors.Is(err, billingcustomer.ErrNotFound): + return ErrNotFound + case errors.Is(err, billingcustomer.ErrInvalidAlias): + return ErrInvalid + case errors.Is(err, billingcustomer.ErrIdentityConflict), errors.Is(err, billingcustomer.ErrFrozen), + errors.Is(err, billingcustomer.ErrConflict): + return ErrConflict + case errors.Is(err, billingcustomer.ErrBillingDisabled): + return ErrBillingDisabled + default: + return ErrUnavailable + } +} + +func (s *Service) unavailable(ctx context.Context, projectID, message string, err error) error { + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("billing_operator_error_kind", fmt.Sprintf("%T", err)). + Msg(message) + return ErrUnavailable +} + +func boundedLimit(limit int) int { + if limit <= 0 { + return 25 + } + if limit > 100 { + return 100 + } + return limit +} diff --git a/apps/api/internal/billingoperator/views.go b/apps/api/internal/billingoperator/views.go new file mode 100644 index 00000000..ff0a3208 --- /dev/null +++ b/apps/api/internal/billingoperator/views.go @@ -0,0 +1,190 @@ +package billingoperator + +import ( + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" +) + +// This file is the single mapping layer between the modules that own billing +// state and the operator's view of it. +// +// It exists so no database row and no other module's read model is ever the +// public response. That is the rule the backend conventions state and it is +// load-bearing here: billingcustomer.Alias and billingcustomer.Conflict both +// carry an alias digest in unexported fields, and a mapper that names every +// field it copies cannot accidentally start copying one. + +func aliasView(alias billingcustomer.Alias) AliasView { + return AliasView{ + AliasID: alias.ID, + AliasType: alias.AliasType, + SourceAuthority: alias.SourceAuthority, + VerificationStatus: alias.VerificationStatus, + Active: alias.EffectiveEnd == nil, + EffectiveStart: alias.EffectiveStart.UTC(), + EffectiveEnd: utcOrNil(alias.EffectiveEnd), + } +} + +func lineageView(lineage billingcustomer.Lineage) LineageView { + return LineageView{ + PurchaseLineageID: lineage.ID, + EnvironmentID: lineage.EnvironmentID, + Provider: lineage.Provider, + StoreEnvironment: lineage.StoreEnvironment, + LineageType: lineage.LineageType, + ProjectionFrozen: lineage.ProjectionFrozen, + DiagnosticStatus: lineage.DiagnosticStatus, + SupersededByLineageID: lineage.SupersededByLineageID, + CreatedAt: lineage.CreatedAt.UTC(), + UpdatedAt: lineage.UpdatedAt.UTC(), + } +} + +func conflictView(conflict billingcustomer.Conflict) ConflictView { + return ConflictView{ + ConflictID: conflict.ID, + ProjectID: conflict.ProjectID, + Scope: conflict.Scope, + Status: conflict.Status, + PurchaseLineageID: conflict.PurchaseLineageID, + AliasType: conflict.AliasType, + FirstCustomerID: conflict.FirstCustomerID, + SecondCustomerID: conflict.SecondCustomerID, + DiagnosticCode: conflict.DiagnosticCode, + OpenedAt: conflict.OpenedAt.UTC(), + ResolvedAt: utcOrNil(conflict.ResolvedAt), + ResolutionAction: OperatorAction(conflict.ResolutionAction), + ResolutionReason: conflict.ResolutionReason, + } +} + +func snapshotView(view billingaccess.SnapshotView) SnapshotView { + result := SnapshotView{ + SnapshotID: view.SnapshotID, + SnapshotVersion: view.SnapshotVersion, + PreviousSnapshotVersion: view.PreviousSnapshotVersion, + ProjectionRuleVersion: view.RuleVersion, + ComputedAt: view.ComputedAt.UTC(), + AsOf: view.AsOf.UTC(), + ChangeReason: view.ChangeReason, + Entries: make([]EntitlementEntryView, 0, len(view.Entries)), + Sources: make([]EntitlementSourceView, 0, len(view.Sources)), + } + // The snapshot checksum is deliberately not carried onto the operator + // surface. It is a determinism control the replay surface compares; an + // operator reading it can only mistake it for a state. + for _, entry := range view.Entries { + result.Entries = append(result.Entries, EntitlementEntryView{ + EntitlementID: entry.EntitlementID, + EntitlementKey: entry.EntitlementKey, + State: entry.State, + EffectiveStart: utcOrNil(entry.EffectiveStart), + EffectiveEnd: utcOrNil(entry.EffectiveEnd), + EndKnown: entry.EndKnown, + SourceCount: entry.SourceCount, + UncertaintyReason: entry.UncertaintyReason, + IsTestSource: entry.IsTestSource, + ExplanationCode: entry.ExplanationCode, + SourceIDs: append([]string(nil), entry.SourceIDs...), + }) + } + for _, source := range view.Sources { + result.Sources = append(result.Sources, EntitlementSourceView{ + SourceID: source.RowID, + EntitlementID: source.EntitlementID, + PurchaseLineageID: source.PurchaseLineageID, + MosaicProductID: source.ProductID, + GrantVersionID: source.GrantVersionID, + SubscriptionInstanceID: source.SubscriptionInstanceID, + OneTimePurchaseInstanceID: source.OneTimePurchaseInstanceID, + StorePlatform: source.StorePlatform, + SourceType: source.SourceType, + SourceState: source.SourceState, + SourceStart: utcOrNil(source.SourceStart), + SourceEnd: utcOrNil(source.SourceEnd), + EndKnown: source.EndKnown, + UncertaintyReason: source.UncertaintyReason, + IsTestSource: source.IsTestSource, + ExplanationCode: source.ExplanationCode, + }) + } + return result +} + +func projectionStatusView(status billingaccess.ProjectionStatus) ProjectionStatusView { + return ProjectionStatusView{ + State: status.State, + LastProjectedAt: status.LastProjectedAt.UTC(), + PendingFactCount: status.PendingFactCount, + DiagnosticCode: status.DiagnosticCode, + } +} + +func subscriptionView(view billingaccess.SubscriptionView) SubscriptionView { + return SubscriptionView{ + SubscriptionInstanceID: view.SubscriptionInstanceID, + PurchaseLineageID: view.PurchaseLineageID, + BillingCustomerID: view.CustomerID, + EnvironmentID: view.EnvironmentID, + StorePlatform: view.StorePlatform, + MosaicProductID: view.ProductID, + PriorMosaicProductID: view.PriorProductID, + AccessState: view.AccessState, + LifecycleState: view.LifecycleState, + RenewalIntent: view.RenewalIntent, + BillingState: view.BillingState, + UncertaintyReason: view.UncertaintyReason, + ProjectionVersion: view.ProjectionVersion, + ProjectionRuleVersion: view.RuleVersion, + ComputedAt: view.ComputedAt.UTC(), + AsOf: view.AsOf.UTC(), + PeriodStart: utcOrNil(view.PeriodStart), + PeriodEnd: utcOrNil(view.PeriodEnd), + GracePeriodEnd: utcOrNil(view.GracePeriodEnd), + BillingRetryStart: utcOrNil(view.BillingRetryStart), + PauseEffectiveAt: utcOrNil(view.PauseEffectiveAt), + PauseResumeAt: utcOrNil(view.PauseResumeAt), + CancellationEffectiveAt: utcOrNil(view.CancellationEffectiveAt), + ExpirationEffectiveAt: utcOrNil(view.ExpirationEffectiveAt), + RevocationEffectiveAt: utcOrNil(view.RevocationEffectiveAt), + RefundEffectiveAt: utcOrNil(view.RefundEffectiveAt), + SupersededByInstanceID: view.SupersededByInstanceID, + IsTestSource: view.IsTestSource, + SourceFactCount: view.SourceFactCount, + ChangeReason: view.ChangeReason, + ExplanationCode: view.ExplanationCode, + } +} + +func timelineEntryView(entry billingaccess.TimelineEntry) TimelineEntryView { + // Detail is produced by the ledger guard function, which is what keeps a + // provider token or a raw payload fragment out of an explanation. It is + // copied rather than referenced so a later mutation of the read model + // cannot reach a response already built. + detail := make(map[string]string, len(entry.Detail)) + for key, value := range entry.Detail { + detail[key] = value + } + return TimelineEntryView{ + TimelineEntryID: entry.ID, + EntryType: entry.EntryType, + EffectiveAt: entry.EffectiveAt.UTC(), + ObservedAt: entry.ObservedAt.UTC(), + SubscriptionInstanceID: entry.SubscriptionInstanceID, + MosaicProductID: entry.ProductID, + PriorMosaicProductID: entry.PriorProductID, + ExplanationCode: entry.ExplanationCode, + Detail: detail, + } +} + +func utcOrNil(value *time.Time) *time.Time { + if value == nil { + return nil + } + utc := value.UTC() + return &utc +} diff --git a/apps/api/internal/billingprojection/checksum.go b/apps/api/internal/billingprojection/checksum.go new file mode 100644 index 00000000..f15256cf --- /dev/null +++ b/apps/api/internal/billingprojection/checksum.go @@ -0,0 +1,103 @@ +package billingprojection + +import ( + "crypto/sha256" + "sort" + "strconv" + "time" +) + +// Checksums are what make "no change" a fact rather than an assumption. A +// replay that produces a checksum equal to the committed one writes no +// snapshot, emits no webhook, and advances the checkpoint — so a projection +// storm cannot manufacture customer-visible churn. +// +// They deliberately exclude `as_of` and computed-at: those advance on every +// run by construction, and including them would make every replay a change. +// They also exclude source fact ids, so re-recording the same meaning under a +// new validator version is not, by itself, an access change. + +func digest(domain string, fields ...string) []byte { + hasher := sha256.New() + hasher.Write([]byte(domain)) + for _, field := range fields { + hasher.Write([]byte{0}) + hasher.Write([]byte(field)) + } + return hasher.Sum(nil) +} + +func stamp(value *time.Time) string { + if value == nil || value.IsZero() { + return "" + } + return strconv.FormatInt(value.UTC().UnixMilli(), 10) +} + +func subscriptionChecksum(snapshot SubscriptionSnapshot) []byte { + return digest("mosaic-subscription-snapshot-v1", + strconv.Itoa(RuleVersion), + snapshot.AccessState, + snapshot.LifecycleState, + snapshot.RenewalIntent, + snapshot.BillingState, + snapshot.UncertaintyReason, + stamp(snapshot.PeriodStartAt), + stamp(snapshot.PeriodEndAt), + stamp(snapshot.GracePeriodEndAt), + stamp(snapshot.BillingRetryStartAt), + stamp(snapshot.PauseStartAt), + stamp(snapshot.PauseResumeAt), + stamp(snapshot.CancellationEffectiveAt), + stamp(snapshot.ExpirationEffectiveAt), + stamp(snapshot.RevocationEffectiveAt), + stamp(snapshot.RefundEffectiveAt), + snapshot.CurrentProductID, + snapshot.PriorProductID, + snapshot.ScheduledProductIdentifier, + strconv.FormatBool(snapshot.IsTestSource), + strconv.FormatBool(snapshot.Terminal), + ) +} + +func oneTimeChecksum(snapshot OneTimeSnapshot) []byte { + return digest("mosaic-one-time-snapshot-v1", + strconv.Itoa(RuleVersion), + snapshot.ValidityState, + stamp(&snapshot.AcquiredAt), + stamp(snapshot.RefundEffectiveAt), + stamp(snapshot.RevocationEffectiveAt), + snapshot.MosaicProductID, + snapshot.UncertaintyReason, + strconv.FormatBool(snapshot.IsTestSource), + ) +} + +// customerChecksum covers the entitlement entries and the sources that justify +// them. Sources participate because "pro is active" for two different reasons +// than yesterday is a real change an operator must be able to see, even though +// the entry alone looks identical. +func customerChecksum(entries []EntitlementEntry, sources []EntitlementSource) []byte { + fields := make([]string, 0, len(entries)*8+len(sources)*6+1) + fields = append(fields, strconv.Itoa(RuleVersion)) + + sortedEntries := append([]EntitlementEntry(nil), entries...) + sort.Slice(sortedEntries, func(i, j int) bool { return sortedEntries[i].EntitlementID < sortedEntries[j].EntitlementID }) + for _, entry := range sortedEntries { + fields = append(fields, + entry.EntitlementID, entry.EntitlementKey, entry.State, + stamp(entry.EffectiveStart), stamp(entry.EffectiveEnd), + strconv.FormatBool(entry.EndKnown), + entry.UncertaintyReason, + strconv.FormatBool(entry.IsTestSource)) + } + + sortedSources := append([]EntitlementSource(nil), sources...) + sort.Slice(sortedSources, func(i, j int) bool { return sortedSources[i].sortKey() < sortedSources[j].sortKey() }) + for _, source := range sortedSources { + fields = append(fields, + source.PurchaseLineageID, source.EntitlementID, source.GrantVersionID, + source.SourceType, source.SourceState, stamp(source.SourceEnd)) + } + return digest("mosaic-customer-entitlement-snapshot-v1", fields...) +} diff --git a/apps/api/internal/billingprojection/entitlement.go b/apps/api/internal/billingprojection/entitlement.go new file mode 100644 index 00000000..745a4842 --- /dev/null +++ b/apps/api/internal/billingprojection/entitlement.go @@ -0,0 +1,423 @@ +package billingprojection + +import ( + "sort" + "time" +) + +// EntitlementSource is one reason a customer has (or may have) an Entitlement. +// Its identity is (lineage, product, grant version) — never a fact id — so two +// facts describing one purchase (mapping drift, a validator bump, a duplicate +// delivery) cannot produce two grants. +type EntitlementSource struct { + EntitlementID string + EntitlementKey string + PurchaseLineageID string + ProductID string + GrantVersionID string + + SubscriptionInstanceID string + OneTimePurchaseInstanceID string + SourceSubscriptionSnapshot string + + SourceType string + SourceState string + SourceStart *time.Time + SourceEnd *time.Time + // EndKnown distinguishes "this source ends at time T" from "this source + // has no end". A valid lifetime purchase is the second, and reporting it + // as the first would promise an expiry that will never arrive. + EndKnown bool + + UncertaintyReason string + IsTestSource bool + ExplanationCode string +} + +func (s EntitlementSource) sortKey() string { + return s.EntitlementID + "|" + s.PurchaseLineageID + "|" + s.GrantVersionID +} + +// EntitlementEntry is the aggregated authoritative state of one Entitlement. +type EntitlementEntry struct { + EntitlementID string + EntitlementKey string + State string + EffectiveStart *time.Time + EffectiveEnd *time.Time + EndKnown bool + SourceCount int + UncertaintyReason string + IsTestSource bool + ExplanationCode string +} + +// SubscriptionSource is one projected subscription offered to the entitlement +// engine, paired with the grant versions in force for its period. +type SubscriptionSource struct { + InstanceID string + SnapshotID string + PurchaseLineageID string + Snapshot SubscriptionSnapshot + Grants []GrantVersion +} + +// OneTimeSource is one projected non-consumable and its grant versions. +type OneTimeSource struct { + InstanceID string + PurchaseLineageID string + Snapshot OneTimeSnapshot + Grants []GrantVersion +} + +// CustomerProjection is the entitlement engine's input for one customer in one +// Environment. +type CustomerProjection struct { + Subscriptions []SubscriptionSource + OneTimes []OneTimeSource + // UnresolvedLineages counts purchase lineages that carry validated facts + // but no accepted customer association or resolved Product. They never + // grant access; they are the reason an Entitlement may be `unknown` + // instead of `inactive`. + UnresolvedLineages int + // FrozenLineages counts lineages held by an open identity conflict + // (OD-10). Neither candidate customer is granted anything automatically. + FrozenLineages int +} + +// CustomerSnapshot is the entitlement engine's output candidate. +type CustomerSnapshot struct { + Entries []EntitlementEntry + Sources []EntitlementSource + Checksum []byte + AsOf time.Time +} + +// ChangeSet names the Entitlements whose meaning changed relative to the prior +// committed snapshot. It is what a webhook announces and what makes a +// no-change projection observable as such. +type ChangeSet struct { + Changed []string + // Entries is the same change set in the shape the Billing State Webhook + // Contract declares: key, previous state, current state. It is carried + // alongside Changed rather than replacing it because the identifier list is + // what the audit trail and the projection outcome are written from, while + // only the wire event needs the keys and the before/after pair. + Entries []EntitlementChange + NoChange bool +} + +// EntitlementChange is one Entitlement whose authoritative state moved. +// +// PreviousState may be `absent`, which is how a first grant is reported without +// claiming the customer was previously inactive. CurrentState has no `absent` +// member: an Entitlement that disappeared from the candidate has no source at +// all any more, and "no source" is exactly `inactive`. An Entitlement that +// merely became undecidable keeps an entry with state `unknown`, so the two +// cases stay distinguishable. +type EntitlementChange struct { + EntitlementID string + EntitlementKey string + PreviousState string + CurrentState string +} + +// EntitlementAbsent is the previous-state member used when the prior snapshot +// carried no entry for the Entitlement at all. +const EntitlementAbsent = "absent" + +// ProjectEntitlements aggregates every accepted source into per-Entitlement +// state (plan §5 "Authoritative Entitlement Computation"). +// +// Aggregation rules that matter: +// - an Entitlement with at least one active source is active, regardless of +// how many other sources ended; revoking one source never removes an +// unrelated valid source +// - a permanent active source means no finite expiry is reported, even when +// a finite subscription source is also active +// - no active source plus unresolved critical evidence is `unknown`, not +// `inactive` +func ProjectEntitlements(projection CustomerProjection, asOf time.Time) CustomerSnapshot { + asOf = asOf.UTC() + sources := make([]EntitlementSource, 0, 8) + + for _, subscription := range projection.Subscriptions { + for _, grant := range subscription.Grants { + sources = append(sources, subscriptionSource(subscription, grant)) + } + } + for _, oneTime := range projection.OneTimes { + for _, grant := range oneTime.Grants { + sources = append(sources, oneTimeSource(oneTime, grant)) + } + } + sort.Slice(sources, func(i, j int) bool { return sources[i].sortKey() < sources[j].sortKey() }) + + grouped := map[string][]EntitlementSource{} + order := make([]string, 0, len(sources)) + for _, source := range sources { + if _, seen := grouped[source.EntitlementID]; !seen { + order = append(order, source.EntitlementID) + } + grouped[source.EntitlementID] = append(grouped[source.EntitlementID], source) + } + + entries := make([]EntitlementEntry, 0, len(order)) + for _, entitlementID := range order { + entries = append(entries, aggregate(grouped[entitlementID], projection)) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].EntitlementID < entries[j].EntitlementID }) + + return CustomerSnapshot{ + Entries: entries, + Sources: sources, + AsOf: asOf, + Checksum: customerChecksum(entries, sources), + } +} + +func subscriptionSource(subscription SubscriptionSource, grant GrantVersion) EntitlementSource { + snapshot := subscription.Snapshot + source := EntitlementSource{ + EntitlementID: grant.EntitlementID, + EntitlementKey: grant.EntitlementKey, + PurchaseLineageID: subscription.PurchaseLineageID, + ProductID: grant.ProductID, + GrantVersionID: grant.ID, + SubscriptionInstanceID: subscription.InstanceID, + SourceSubscriptionSnapshot: subscription.SnapshotID, + SourceStart: snapshot.PeriodStartAt, + SourceEnd: snapshot.PeriodEndAt, + EndKnown: true, + UncertaintyReason: snapshot.UncertaintyReason, + IsTestSource: snapshot.IsTestSource, + } + + switch snapshot.LifecycleState { + case LifecycleTrialing: + source.SourceType, source.ExplanationCode = SourceTrial, "trial_active" + case LifecycleGracePeriod: + source.SourceType, source.ExplanationCode = SourceVerifiedGrace, "verified_grace_period" + source.SourceEnd = snapshot.GracePeriodEndAt + case LifecycleBillingRetry: + source.SourceType, source.ExplanationCode = SourceBillingRetry, "billing_retry" + default: + source.SourceType, source.ExplanationCode = SourceActiveSubscription, "subscription_"+snapshot.LifecycleState + } + if snapshot.OwnershipType == OwnershipFamilyShared { + // OD-9(b): a family-shared transaction is an independent source for + // the family member's own customer, labelled honestly so an operator + // explanation can say why access exists. + source.SourceType = SourceFamilyShared + } + + // Access is decided per grant version, from the provider lifecycle plus + // *this* grant's policy. Reading it off the snapshot's single access column + // collapsed every Entitlement onto one policy, so a grant version that opted + // out of grace still granted access whenever some other grant on the same + // Product opted in — the §7 per-grant opt-out existed only on paper. + // + // Lifecycles that are not policy-dependent (revoked, refunded, expired, + // superseded, paused, unknown) are never negotiable and fall through to the + // snapshot's own answer. + switch { + case snapshot.AccessState == AccessUnknown: + source.SourceState = AccessUnknown + default: + if granted, policyDependent := grant.Policy.GrantsAccess(snapshot.LifecycleState); policyDependent { + if granted { + source.SourceState = AccessActive + } else { + source.SourceState = AccessInactive + } + } else if snapshot.AccessState == AccessActive { + source.SourceState = AccessActive + } else { + source.SourceState = AccessInactive + } + } + return source +} + +func oneTimeSource(oneTime OneTimeSource, grant GrantVersion) EntitlementSource { + snapshot := oneTime.Snapshot + acquired := snapshot.AcquiredAt + source := EntitlementSource{ + EntitlementID: grant.EntitlementID, + EntitlementKey: grant.EntitlementKey, + PurchaseLineageID: oneTime.PurchaseLineageID, + ProductID: grant.ProductID, + GrantVersionID: grant.ID, + OneTimePurchaseInstanceID: oneTime.InstanceID, + SourceType: SourceOneTime, + SourceStart: &acquired, + // A valid non-consumable has no end. This is the flag the aggregation + // reads to refuse a misleading finite expiry. + EndKnown: false, + UncertaintyReason: snapshot.UncertaintyReason, + IsTestSource: snapshot.IsTestSource, + ExplanationCode: "one_time_purchase_" + snapshot.ValidityState, + } + switch snapshot.ValidityState { + case OwnershipOwned: + if grant.Policy.GrantsInOneTime { + source.SourceState = AccessActive + } else { + source.SourceState = AccessInactive + } + case OwnershipRefunded: + source.SourceState, source.EndKnown, source.SourceEnd = AccessInactive, true, snapshot.RefundEffectiveAt + case OwnershipRevoked: + source.SourceState, source.EndKnown, source.SourceEnd = AccessInactive, true, snapshot.RevocationEffectiveAt + default: + source.SourceState = AccessUnknown + } + return source +} + +func aggregate(sources []EntitlementSource, projection CustomerProjection) EntitlementEntry { + entry := EntitlementEntry{ + EntitlementID: sources[0].EntitlementID, + EntitlementKey: sources[0].EntitlementKey, + SourceCount: len(sources), + State: AccessInactive, + EndKnown: true, + UncertaintyReason: UncertaintyNone, + ExplanationCode: "no_active_source", + } + + anyActive, anyUnknown, anyPermanent := false, false, false + var latestEnd *time.Time + var earliestStart *time.Time + unknownReason := UncertaintyNone + + for _, source := range sources { + switch source.SourceState { + case AccessActive: + anyActive = true + if source.IsTestSource { + entry.IsTestSource = true + } + if !source.EndKnown { + anyPermanent = true + } else if source.SourceEnd != nil && (latestEnd == nil || source.SourceEnd.After(*latestEnd)) { + latestEnd = source.SourceEnd + } else if source.SourceEnd == nil { + // An active source with a known-but-absent end is an uncertain + // end, not an infinite one. + anyPermanent = false + entry.EndKnown = false + } + if source.SourceStart != nil && (earliestStart == nil || source.SourceStart.Before(*earliestStart)) { + earliestStart = source.SourceStart + } + if entry.ExplanationCode == "no_active_source" { + entry.ExplanationCode = source.ExplanationCode + } + case AccessUnknown: + anyUnknown = true + if unknownReason == UncertaintyNone && source.UncertaintyReason != UncertaintyNone { + unknownReason = source.UncertaintyReason + } + } + } + + switch { + case anyActive: + entry.State = AccessActive + entry.EffectiveStart = earliestStart + if anyPermanent { + // A permanent source means no finite expiry, even alongside a + // finite subscription that ends sooner. + entry.EffectiveEnd, entry.EndKnown = nil, false + entry.ExplanationCode = "permanent_source_active" + } else if entry.EndKnown { + entry.EffectiveEnd = latestEnd + } + case anyUnknown || projection.UnresolvedLineages > 0 || projection.FrozenLineages > 0: + // No source actively grants it, but critical evidence is missing or + // disputed. `unknown` preserves the truth; `inactive` would assert one. + entry.State = AccessUnknown + entry.UncertaintyReason = unknownReason + if entry.UncertaintyReason == UncertaintyNone { + entry.UncertaintyReason = UncertaintyIdentityUnresolved + } + // The explanation follows the reason rather than restating "something + // is unresolved". A Product mapping gap and an identity conflict need + // different operator actions, and reporting both as one code sent every + // investigation to the wrong queue. + switch entry.UncertaintyReason { + case UncertaintyProductUnresolved: + entry.ExplanationCode = "product_unresolved" + case UncertaintyConflictingFacts: + entry.ExplanationCode = "conflicting_facts" + case UncertaintyProjectionFailed: + entry.ExplanationCode = "projection_failed" + case UncertaintyStaleValidation: + entry.ExplanationCode = "provider_evidence_stale" + default: + entry.ExplanationCode = "identity_unresolved" + } + } + return entry +} + +// Diff reports which Entitlements changed between the prior committed snapshot +// and a candidate. A checksum-equal candidate is a no-change projection: no +// snapshot is written, no webhook is emitted, and the checkpoint still +// advances. +func Diff(prior *CustomerSnapshot, candidate CustomerSnapshot) ChangeSet { + if prior != nil && string(prior.Checksum) == string(candidate.Checksum) { + return ChangeSet{NoChange: true} + } + priorEntries := map[string]EntitlementEntry{} + if prior != nil { + for _, entry := range prior.Entries { + priorEntries[entry.EntitlementID] = entry + } + } + changed := make([]string, 0, len(candidate.Entries)) + entries := make([]EntitlementChange, 0, len(candidate.Entries)) + for _, entry := range candidate.Entries { + before, existed := priorEntries[entry.EntitlementID] + if !existed || before.State != entry.State || !sameInstant(before.EffectiveEnd, entry.EffectiveEnd) || + before.EndKnown != entry.EndKnown || before.UncertaintyReason != entry.UncertaintyReason { + changed = append(changed, entry.EntitlementID) + previous := EntitlementAbsent + if existed { + previous = before.State + } + entries = append(entries, EntitlementChange{ + EntitlementID: entry.EntitlementID, EntitlementKey: entry.EntitlementKey, + PreviousState: previous, CurrentState: entry.State, + }) + } + delete(priorEntries, entry.EntitlementID) + } + // An Entitlement that disappeared from the candidate changed too. + for entitlementID, before := range priorEntries { + changed = append(changed, entitlementID) + entries = append(entries, EntitlementChange{ + EntitlementID: entitlementID, EntitlementKey: before.EntitlementKey, + PreviousState: before.State, CurrentState: AccessInactive, + }) + } + sort.Strings(changed) + // Ascending and unique by entitlement key, as the contract requires of the + // wire array. Sorting by key rather than by identifier means the array a + // consumer reads is ordered the way the consumer indexes it. + sort.Slice(entries, func(i, j int) bool { + if entries[i].EntitlementKey != entries[j].EntitlementKey { + return entries[i].EntitlementKey < entries[j].EntitlementKey + } + return entries[i].EntitlementID < entries[j].EntitlementID + }) + return ChangeSet{Changed: changed, Entries: entries, NoChange: len(changed) == 0} +} + +func sameInstant(a, b *time.Time) bool { + if a == nil || b == nil { + return a == b + } + return a.Equal(*b) +} diff --git a/apps/api/internal/billingprojection/event.go b/apps/api/internal/billingprojection/event.go new file mode 100644 index 00000000..3635253d --- /dev/null +++ b/apps/api/internal/billingprojection/event.go @@ -0,0 +1,217 @@ +package billingprojection + +import ( + "sort" + "time" +) + +// Event is the committed-change announcement one projection plans. +// +// It is planned by the pure engine rather than assembled by the writer for one +// reason: the writer runs inside the projection transaction, and anything it +// decides there is a branch over provider semantics that cannot be tested +// without a database. Planning it here means the wire shape of an entitlement +// change is decided by the same deterministic function that decided the change. +// +// Only `customer.entitlements.changed` is emitted in Phase 9B (OD-1(b)). The +// other nine event types the contract declares are reserved names; emitting one +// before it is specified would be a defect. +type Event struct { + // SubscriptionInstanceID names the subscription the change came from, when + // one can be identified. It is empty for a change driven only by one-time + // purchases, by a grant-version publication, or by an identity movement. + SubscriptionInstanceID string + + AccessState string + LifecycleState string + RenewalIntent string + BillingState string + UncertaintyReason string + + // SourceReason is the contract's changeReason vocabulary: why the committed + // state moved. + SourceReason string + // OccurredAt is when the change became effective. It is provider-derived + // wherever a provider timestamp explains the change and may be well before + // the event is created; it is never allowed past the evaluation instant, + // because an event dated in the future is unusable for ordering or replay + // windows. + OccurredAt time.Time + IsTestSource bool +} + +// Webhook event types. Phase 9B emits exactly one. +const EventTypeEntitlementsChanged = "customer.entitlements.changed" + +// Change reasons, matching the closed vocabulary shared by the Authoritative +// Entitlement and Billing State Webhook contracts. +const ( + ReasonInitialProjection = "initial_projection" + ReasonSubscriptionStateChanged = "subscription_state_changed" + ReasonSourceAdded = "source_added" + ReasonSourceEnded = "source_ended" +) + +// planEvent builds the announcement for a projection that changed committed +// state. It returns nil when nothing changed, so the no-change path cannot +// accidentally announce anything. +func planEvent(prior *CustomerSnapshot, candidate CustomerSnapshot, changes ChangeSet, + subscriptions []SubscriptionSource, asOf time.Time) *Event { + + if len(changes.Entries) == 0 { + return nil + } + event := &Event{ + SourceReason: eventReason(prior, changes), + OccurredAt: eventOccurredAt(changes, candidate, asOf), + } + + changedIDs := make(map[string]struct{}, len(changes.Entries)) + for _, change := range changes.Entries { + changedIDs[change.EntitlementID] = struct{}{} + } + if source, ok := driverSubscription(subscriptions, changedIDs); ok { + event.SubscriptionInstanceID = source.InstanceID + event.AccessState = source.Snapshot.AccessState + event.LifecycleState = source.Snapshot.LifecycleState + event.RenewalIntent = source.Snapshot.RenewalIntent + event.BillingState = source.Snapshot.BillingState + event.UncertaintyReason = source.Snapshot.UncertaintyReason + event.IsTestSource = source.Snapshot.IsTestSource + return event + } + + // No subscription drove the change: the summary is derived from the + // customer aggregate instead. The four axes still have to be answered + // because the contract requires them, and answering them from the aggregate + // is honest — a one-time purchase has no renewal to describe, which is + // exactly what `provider_managed` and `unknown` say. + summarizeAggregate(event, candidate, changedIDs) + return event +} + +// driverSubscription picks the subscription whose state best explains the +// change. Preference order is deterministic so the same projection always +// summarizes itself the same way: a subscription contributing an active source +// to a changed Entitlement, then any subscription contributing to one, then +// none. +func driverSubscription(subscriptions []SubscriptionSource, changed map[string]struct{}) (SubscriptionSource, bool) { + candidates := make([]SubscriptionSource, 0, len(subscriptions)) + for _, source := range subscriptions { + for _, grant := range source.Grants { + if _, ok := changed[grant.EntitlementID]; ok { + candidates = append(candidates, source) + break + } + } + } + if len(candidates) == 0 { + return SubscriptionSource{}, false + } + sort.SliceStable(candidates, func(i, j int) bool { + left, right := candidates[i], candidates[j] + if (left.Snapshot.AccessState == AccessActive) != (right.Snapshot.AccessState == AccessActive) { + return left.Snapshot.AccessState == AccessActive + } + return left.InstanceID < right.InstanceID + }) + return candidates[0], true +} + +func summarizeAggregate(event *Event, candidate CustomerSnapshot, changed map[string]struct{}) { + state := AccessInactive + testSource := false + uncertainty := UncertaintyNone + for _, entry := range candidate.Entries { + if _, ok := changed[entry.EntitlementID]; !ok { + continue + } + if entry.IsTestSource { + testSource = true + } + switch entry.State { + case AccessActive: + state = AccessActive + case AccessUnknown: + if state != AccessActive { + state = AccessUnknown + uncertainty = entry.UncertaintyReason + } + } + } + event.AccessState, event.IsTestSource = state, testSource + switch state { + case AccessActive: + event.LifecycleState, event.BillingState = LifecycleActive, BillingCurrent + event.RenewalIntent, event.UncertaintyReason = RenewalProviderManaged, UncertaintyNone + case AccessUnknown: + event.LifecycleState, event.BillingState = LifecycleUnknown, BillingUnknown + event.RenewalIntent = RenewalUnknown + event.UncertaintyReason = uncertainty + if event.UncertaintyReason == UncertaintyNone { + // `unknown` is never allowed to be unexplained: the schema encodes + // it as an invariant and a reader that sees one has no recovery. + event.UncertaintyReason = UncertaintyMissingFact + } + default: + event.LifecycleState, event.BillingState = LifecycleExpired, BillingUnknown + event.RenewalIntent, event.UncertaintyReason = RenewalProviderManaged, UncertaintyNone + } +} + +func eventReason(prior *CustomerSnapshot, changes ChangeSet) string { + if prior == nil { + return ReasonInitialProjection + } + added, ended := false, false + for _, change := range changes.Entries { + if change.PreviousState == EntitlementAbsent { + added = true + } + if change.PreviousState == AccessActive && change.CurrentState != AccessActive { + ended = true + } + } + switch { + case added: + return ReasonSourceAdded + case ended: + return ReasonSourceEnded + default: + return ReasonSubscriptionStateChanged + } +} + +// eventOccurredAt recovers the provider-derived instant the change became +// effective, falling back to the evaluation instant when no entry carries one. +// A future-dated provider timestamp is clamped: a scheduled expiry is not an +// event that has already happened. +func eventOccurredAt(changes ChangeSet, candidate CustomerSnapshot, asOf time.Time) time.Time { + entries := map[string]EntitlementEntry{} + for _, entry := range candidate.Entries { + entries[entry.EntitlementID] = entry + } + occurred := time.Time{} + for _, change := range changes.Entries { + entry, ok := entries[change.EntitlementID] + if !ok { + continue + } + candidateTime := (*time.Time)(nil) + if change.CurrentState == AccessActive { + candidateTime = entry.EffectiveStart + } else if entry.EndKnown { + candidateTime = entry.EffectiveEnd + } + if candidateTime == nil || candidateTime.After(asOf) { + continue + } + if occurred.IsZero() || candidateTime.After(occurred) { + occurred = candidateTime.UTC() + } + } + if occurred.IsZero() { + return asOf.UTC() + } + return occurred +} diff --git a/apps/api/internal/billingprojection/event_test.go b/apps/api/internal/billingprojection/event_test.go new file mode 100644 index 00000000..e6047dd4 --- /dev/null +++ b/apps/api/internal/billingprojection/event_test.go @@ -0,0 +1,101 @@ +package billingprojection + +import "testing" + +// The webhook event is created inside the projection transaction, so whatever +// Compute plans is what a consumer eventually receives. Two things can go wrong +// there and neither is visible from the snapshot: a projection that changed +// nothing can still announce a change, and an announcement can carry a state +// summary the contract will not accept. These pin both. + +// A no-change projection must plan no event at all. This is the property that +// makes a replay safe to run: a Project-wide replay that re-derived identical +// state would otherwise deliver one webhook per customer for nothing, which is +// exactly the noise that teaches receivers to ignore the channel. +func TestNoChangeProjectionPlansNoEvent(t *testing.T) { + input := Input{ + Scope: Scope{ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1"}, + Lineages: []LineageInput{activeLineage("lin_1")}, + GrantVersions: []GrantVersion{proGrant()}, + CurrentSnapshotVersion: 4, + } + first := Compute(input, at("2026-02-15T00:00:00Z")) + if first.Event == nil { + t.Fatal("the first projection planned no event for a first grant") + } + + input.Lineages[0].CheckpointChecksum = first.Checkpoints[0].Checksum + input.PriorCustomerSnapshot = first.CustomerSnapshot + second := Compute(input, at("2026-02-16T00:00:00Z")) + + if second.Outcome != OutcomeNoChange { + t.Fatalf("re-projection outcome %q, want no_change", second.Outcome) + } + if second.Event != nil { + t.Fatalf("a no-change projection planned an event: %+v", second.Event) + } +} + +// The first grant must be reported as `absent` -> `active`, never as +// `inactive` -> `active`. The distinction is the whole reason the contract +// declares a fourth previous-state member: claiming the customer was previously +// inactive asserts a fact Mosaic never established. +func TestFirstGrantReportsAbsentRatherThanInactive(t *testing.T) { + output := Compute(Input{ + Scope: Scope{ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1"}, + Lineages: []LineageInput{activeLineage("lin_1")}, + GrantVersions: []GrantVersion{proGrant()}, + }, at("2026-02-15T00:00:00Z")) + + if output.Event == nil { + t.Fatal("no event planned for a first grant") + } + if len(output.Changes.Entries) != 1 { + t.Fatalf("changed entitlements %d, want 1", len(output.Changes.Entries)) + } + change := output.Changes.Entries[0] + if change.EntitlementKey != "pro" { + t.Fatalf("entitlement key %q, want the grant's key", change.EntitlementKey) + } + if change.PreviousState != EntitlementAbsent || change.CurrentState != AccessActive { + t.Fatalf("change %s -> %s, want absent -> active", change.PreviousState, change.CurrentState) + } + if output.Event.SourceReason != ReasonInitialProjection { + t.Fatalf("source reason %q, want initial_projection", output.Event.SourceReason) + } + if output.Event.SubscriptionInstanceID != "sub_lin_1" { + t.Fatalf("subscription instance %q, want the lineage's instance", output.Event.SubscriptionInstanceID) + } + // The schema's invariant: an unknown or unavailable summary must name a + // reason, and a definite one must not invent one. + if output.Event.AccessState == AccessUnknown && output.Event.UncertaintyReason == UncertaintyNone { + t.Fatal("an unknown state summary carried no uncertainty reason") + } + if output.Event.OccurredAt.After(at("2026-02-15T00:00:00Z")) { + t.Fatalf("occurredAt %s is in the future relative to the evaluation instant", output.Event.OccurredAt) + } +} + +// A customer whose only decidable state is `unknown` must still produce an +// explainable summary. The contract encodes "unknown implies a reason" as a +// schema invariant, so an unexplained one is a delivery the receiver rejects +// rather than a field it ignores. +func TestUnknownEventSummaryIsAlwaysExplained(t *testing.T) { + frozen := activeLineage("lin_frozen") + frozen.Frozen = true + output := Compute(Input{ + Scope: Scope{ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1"}, + Lineages: []LineageInput{frozen}, + GrantVersions: []GrantVersion{proGrant()}, + }, at("2026-02-15T00:00:00Z")) + + if output.Event == nil { + t.Fatal("a frozen lineage produced no announcement of its unknown state") + } + if output.Event.AccessState != AccessUnknown { + t.Fatalf("access state %q, want unknown for a frozen lineage", output.Event.AccessState) + } + if output.Event.UncertaintyReason == UncertaintyNone { + t.Fatal("an unknown summary carried uncertainty reason none") + } +} diff --git a/apps/api/internal/billingprojection/grant.go b/apps/api/internal/billingprojection/grant.go new file mode 100644 index 00000000..804b2712 --- /dev/null +++ b/apps/api/internal/billingprojection/grant.go @@ -0,0 +1,136 @@ +package billingprojection + +import ( + "sort" + "time" +) + +// GrantVersion is one immutable Product-to-Entitlement grant interval. +type GrantVersion struct { + ID string + ProductID string + EntitlementID string + EntitlementKey string + Version int + EffectiveStart time.Time + // EffectiveEnd is nil for the current open-ended version. Intervals are + // half-open: [start, end). + EffectiveEnd *time.Time + + SupportedPurchaseTypes []string + Policy Policy +} + +// SelectGrantVersions returns the grant versions in force for one Product at +// one instant — the purchase source's period effective time, not "now" (OD-8, +// prospective by period effective time). +// +// Selecting by current time instead would silently rewrite historical access +// meaning every time an operator edits their catalog, which is the specific +// failure the versioning exists to prevent. +// +// Earliest-version rule: a purchase whose effective time predates the earliest +// recorded version selects that earliest version. Without it, every purchase +// made before the 9B backfill boundary would strand with no grant and drop to +// `unknown` — an artefact of when Mosaic started versioning, not of anything +// the customer did. +func SelectGrantVersions(versions []GrantVersion, productID string, at time.Time, purchaseType string) []GrantVersion { + at = at.UTC() + byEntitlement := map[string][]GrantVersion{} + for _, version := range versions { + if version.ProductID != productID || !supportsPurchaseType(version, purchaseType) { + continue + } + byEntitlement[version.EntitlementID] = append(byEntitlement[version.EntitlementID], version) + } + + selected := make([]GrantVersion, 0, len(byEntitlement)) + for _, candidates := range byEntitlement { + sort.Slice(candidates, func(i, j int) bool { + if !candidates[i].EffectiveStart.Equal(candidates[j].EffectiveStart) { + return candidates[i].EffectiveStart.Before(candidates[j].EffectiveStart) + } + return candidates[i].Version < candidates[j].Version + }) + if chosen, ok := selectOne(candidates, at); ok { + selected = append(selected, chosen) + } + } + // Deterministic output order: the caller derives a checksum from it. + sort.Slice(selected, func(i, j int) bool { + if selected[i].EntitlementID != selected[j].EntitlementID { + return selected[i].EntitlementID < selected[j].EntitlementID + } + return selected[i].Version < selected[j].Version + }) + return selected +} + +func selectOne(candidates []GrantVersion, at time.Time) (GrantVersion, bool) { + if len(candidates) == 0 { + return GrantVersion{}, false + } + for _, candidate := range candidates { + if candidate.EffectiveStart.After(at) { + continue + } + if candidate.EffectiveEnd == nil || candidate.EffectiveEnd.After(at) { + return candidate, true + } + } + // The purchase predates every recorded version: take the earliest + // (backfill boundary rule above). A purchase that falls in a closed gap + // between two versions — the pair was granted, removed, and never + // re-granted — correctly selects nothing, because at that instant the + // Product genuinely granted nothing. + if at.Before(candidates[0].EffectiveStart) { + return candidates[0], true + } + return GrantVersion{}, false +} + +func supportsPurchaseType(version GrantVersion, purchaseType string) bool { + if purchaseType == "" || len(version.SupportedPurchaseTypes) == 0 { + return true + } + for _, supported := range version.SupportedPurchaseTypes { + if supported == purchaseType { + return true + } + } + return false +} + +// ValidateAdditiveSuperset reports whether a proposed grant version is a +// permitted retroactive correction: it may add Entitlements or widen access +// policy, never remove or narrow either. +// +// Retroactive change is the one operation that can take access away from a +// customer who did nothing wrong, so the only retroactive shape Mosaic accepts +// is the one that cannot: a superset. +func ValidateAdditiveSuperset(current, proposed GrantVersion) (string, bool) { + if current.EntitlementID != proposed.EntitlementID || current.ProductID != proposed.ProductID { + return "grant_identity_changed", false + } + checks := []struct { + code string + before, proposed bool + }{ + {"active_access_narrowed", current.Policy.GrantsInActive, proposed.Policy.GrantsInActive}, + {"trial_access_narrowed", current.Policy.GrantsInTrial, proposed.Policy.GrantsInTrial}, + {"grace_access_narrowed", current.Policy.GrantsInGrace, proposed.Policy.GrantsInGrace}, + {"billing_retry_access_narrowed", current.Policy.GrantsInBillingRetry, proposed.Policy.GrantsInBillingRetry}, + {"one_time_access_narrowed", current.Policy.GrantsInOneTime, proposed.Policy.GrantsInOneTime}, + } + for _, check := range checks { + if check.before && !check.proposed { + return check.code, false + } + } + for _, supported := range current.SupportedPurchaseTypes { + if !supportsPurchaseType(proposed, supported) { + return "purchase_type_support_narrowed", false + } + } + return "", true +} diff --git a/apps/api/internal/billingprojection/model.go b/apps/api/internal/billingprojection/model.go new file mode 100644 index 00000000..be477add --- /dev/null +++ b/apps/api/internal/billingprojection/model.go @@ -0,0 +1,293 @@ +// Package billingprojection owns Mosaic's authoritative subscription and +// entitlement projection. The engines in this package are pure: they take +// ordered validated facts, grant versions, and an evaluation instant, and they +// return snapshot candidates. They perform no I/O, hold no database handle, +// and never mutate a Phase 9A fact — which is what makes "the same ordered +// facts and rule versions produce the same projection" a property of the code +// rather than a convention. +package billingprojection + +import "time" + +// ActiveRuleVersion is the projection-rule version new projections derive +// under. It is recorded on every snapshot so a later semantic change is a +// version bump with a replay, never a silent reinterpretation of committed +// state. +const ActiveRuleVersion = 1 + +// RuleVersion is the value persisted on every snapshot, timeline entry, and +// checkpoint. It is the active version by definition: a projection is only ever +// committed under semantics this build derives. +const RuleVersion = ActiveRuleVersion + +// implementedRuleVersions lists every rule version whose derivation semantics +// this build can reproduce. +// +// Review finding I-12: `Replay.RuleVersion` was accepted and ignored, so a +// replay under a hypothetical version 2 silently recomputed version 1 and +// reported the resulting checksum as if version 2 had produced it. A checksum +// produced by the wrong engine is indistinguishable from a genuine determinism +// result, which is the one thing a replay exists to prove — so a rule version +// this build does not implement is refused rather than approximated. +// +// OD-11(a) deferred the shadow diff engine until a second rule version exists; +// when one lands, its derivation is added to the engine and listed here, and +// the selection plumbing below already carries it. +var implementedRuleVersions = []int{ActiveRuleVersion} + +// ImplementedRuleVersions reports the rule versions this build can replay +// under, in ascending order. +func ImplementedRuleVersions() []int { + return append([]int(nil), implementedRuleVersions...) +} + +// RuleVersionImplemented reports whether this build derives under a rule +// version. Zero means "the active version" and is always implemented. +func RuleVersionImplemented(version int) bool { + if version == 0 { + return true + } + for _, implemented := range implementedRuleVersions { + if implemented == version { + return true + } + } + return false +} + +// ResolveRuleVersion maps a requested rule version onto the one derivation will +// actually use. Zero selects the active version. +func ResolveRuleVersion(version int) int { + if version == 0 { + return ActiveRuleVersion + } + return version +} + +// OrderingVersion identifies the canonical ordering tuple (plan §8). It is +// separate from RuleVersion because ordering can change without changing +// derivation, and a checkpoint's high watermark is only comparable within one +// ordering version. +const OrderingVersion = 1 + +// Access states. `unavailable` is deliberately absent: it is a read-time +// service state (billing disabled, projection unreachable) and is never +// projected or persisted. +const ( + AccessActive = "active" + AccessInactive = "inactive" + AccessUnknown = "unknown" +) + +// Lifecycle states. +const ( + LifecycleTrialing = "trialing" + LifecycleActive = "active" + LifecycleGracePeriod = "grace_period" + LifecycleBillingRetry = "billing_retry" + LifecyclePaused = "paused" + LifecycleExpired = "expired" + LifecycleRevoked = "revoked" + LifecycleRefunded = "refunded" + LifecycleSuperseded = "superseded" + LifecycleUnknown = "unknown" +) + +// Renewal intent. +const ( + RenewalEnabled = "auto_renew_enabled" + RenewalDisabled = "auto_renew_disabled" + RenewalProviderManaged = "provider_managed" + RenewalPaused = "paused" + RenewalUnknown = "unknown" +) + +// Billing states. +const ( + BillingCurrent = "current" + BillingRetrying = "retrying" + BillingGrace = "grace" + BillingFailed = "failed" + BillingRefunded = "refunded" + BillingRevoked = "revoked" + BillingUnknown = "unknown" +) + +// Uncertainty reasons. Every non-definitive answer names one, so `unknown` is +// always explainable. +const ( + UncertaintyNone = "none" + UncertaintyProviderUnavailable = "provider_unavailable" + UncertaintyMissingFact = "missing_fact" + UncertaintyIdentityUnresolved = "identity_unresolved" + UncertaintyProductUnresolved = "product_unresolved" + UncertaintyConflictingFacts = "conflicting_facts" + UncertaintyProjectionFailed = "projection_failed" + UncertaintyStaleValidation = "stale_validation" + UncertaintyUnsupportedState = "unsupported_provider_state" +) + +// OwnershipFamilyShared is Apple's inAppOwnershipType for a transaction the +// customer received through Family Sharing. +const OwnershipFamilyShared = "FAMILY_SHARED" + +// One-time purchase validity states. +const ( + OwnershipOwned = "owned" + OwnershipRefunded = "refunded" + OwnershipRevoked = "revoked" + OwnershipUnknown = "unknown" +) + +// Entitlement source types. +const ( + SourceActiveSubscription = "active_subscription" + SourceTrial = "trial" + SourceVerifiedGrace = "verified_grace_period" + SourceBillingRetry = "accepted_billing_retry" + SourceOneTime = "one_time_non_consumable" + SourceFamilyShared = "family_shared" +) + +// Timeline entry types, matching the closed set in migration 00032. +const ( + TimelinePurchaseValidated = "purchase_validated" + TimelineTrialStarted = "trial_started" + TimelineRenewalValidated = "renewal_validated" + TimelineAutoRenewEnabled = "auto_renew_enabled" + TimelineAutoRenewDisabled = "auto_renew_disabled" + TimelineCancellation = "cancellation_requested" + TimelineGraceStarted = "grace_period_started" + TimelineGraceEnded = "grace_period_ended" + TimelineBillingRetry = "billing_retry_started" + TimelineBillingRecovered = "billing_recovered" + TimelinePauseStarted = "pause_started" + TimelinePauseEnded = "pause_ended" + TimelineProductUpgraded = "product_upgraded" + TimelineProductDowngraded = "product_downgraded" + TimelineExpiration = "expiration" + TimelineRefund = "refund" + TimelineRevocation = "revocation" + TimelineRefundReversed = "refund_reversed" + TimelinePurchaseSuperseded = "purchase_superseded" + TimelineReplayed = "projection_replayed" +) + +// Fact is the projection engine's view of one Phase 9A Transaction Fact. It is +// a copy rather than a reference to the billing package's type so the pure +// core cannot reach the ingestion service, and so the engine's input surface +// is exactly what it reads. +type Fact struct { + ID string + Provider string + ProviderTransactionID string + FactKind string + TransactionType string + + OccurredAt time.Time + ProviderEventOccurredAt *time.Time + RecordedAt time.Time + PeriodStartAt *time.Time + PeriodEndAt *time.Time + GracePeriodExpiresAt *time.Time + RevokedAt *time.Time + RefundedAt *time.Time + + RenewalExpected *bool + BillingRetryActive *bool + IsUpgraded *bool + RevocationReason *int + RefundType string + + AutoRenewProductIdentifier string + InAppOwnershipType string + SubscriptionGroupIdentifier string + + MosaicProductID string + ResolutionState string + IsTestSource bool +} + +// SubscriptionSnapshot is the projection engine's output candidate. It carries +// no identifiers the engine cannot compute; the application service assigns +// the snapshot id and projection version at commit time. +type SubscriptionSnapshot struct { + AccessState string + LifecycleState string + RenewalIntent string + BillingState string + UncertaintyReason string + + PeriodStartAt *time.Time + PeriodEndAt *time.Time + GracePeriodEndAt *time.Time + BillingRetryStartAt *time.Time + PauseStartAt *time.Time + PauseResumeAt *time.Time + CancellationEffectiveAt *time.Time + ExpirationEffectiveAt *time.Time + RevocationEffectiveAt *time.Time + RefundEffectiveAt *time.Time + + CurrentProductID string + PriorProductID string + ScheduledProductIdentifier string + SubscriptionGroupIdentifier string + // OwnershipType is Apple's inAppOwnershipType, carried so a family-shared + // source can be labelled as such. It is not persisted as a snapshot column + // — the provider statement lives on the facts — and exists here only to + // pass the reading to the entitlement layer. + OwnershipType string + IsTestSource bool + Terminal bool + + AsOf time.Time + Checksum []byte + // SourceFactIDs are the facts, in canonical order, that produced this + // snapshot. They are the snapshot's evidence, not a convenience. + SourceFactIDs []string +} + +// TimelineEntry is one immutable explanation emitted alongside a snapshot. +type TimelineEntry struct { + EntryType string + EffectiveAt time.Time + ObservedAt time.Time + ProductID string + PriorProductID string + SourceFactIDs []string + ExplanationCode string + Detail map[string]string +} + +// SubscriptionResult is everything one subscription projection produced. +type SubscriptionResult struct { + Snapshot SubscriptionSnapshot + Timeline []TimelineEntry + // HighWatermark is the canonical ordering position of the last fact + // projected, in the encoding checkpoints store. + HighWatermark string + FactsConsumed int + Warnings []string +} + +// OneTimeSnapshot is the projected state of one non-consumable lineage. +type OneTimeSnapshot struct { + ValidityState string + AcquiredAt time.Time + RefundEffectiveAt *time.Time + RevocationEffectiveAt *time.Time + MosaicProductID string + UncertaintyReason string + IsTestSource bool + Checksum []byte + SourceFactIDs []string +} + +// OneTimeResult is everything one non-consumable projection produced. +type OneTimeResult struct { + Snapshot OneTimeSnapshot + Timeline []TimelineEntry + HighWatermark string + FactsConsumed int +} diff --git a/apps/api/internal/billingprojection/onetime.go b/apps/api/internal/billingprojection/onetime.go new file mode 100644 index 00000000..e38db6d3 --- /dev/null +++ b/apps/api/internal/billingprojection/onetime.go @@ -0,0 +1,136 @@ +package billingprojection + +import "time" + +// ProjectOneTimePurchase derives the ownership state of one non-consumable +// lineage. Consumables are excluded from Phase 9B entirely: modelling them +// needs quantity and consumption semantics this phase does not have, and +// coercing them into ownership would put a wrong statement into an immutable +// snapshot. +// +// A valid non-consumable has no recurring period and therefore no expiry. It +// becomes inactive only through a validated refund, a revocation, an +// invalidated association, or an accepted grant rule change — never through +// the passage of time. That is the property the entitlement engine relies on +// when it refuses to report a finite expiry for a lifetime purchase. +func ProjectOneTimePurchase(facts []Fact, asOf time.Time) OneTimeResult { + ordered := Sort(append([]Fact(nil), facts...)) + asOf = asOf.UTC() + + result := OneTimeResult{ + HighWatermark: HighWatermark(ordered), + FactsConsumed: len(ordered), + } + snapshot := OneTimeSnapshot{ + ValidityState: OwnershipUnknown, + UncertaintyReason: UncertaintyMissingFact, + SourceFactIDs: make([]string, 0, len(ordered)), + } + + acquired := false + productUnresolved := false + // refundInvalidates records whether the last refund statement was one that + // ends ownership. It is tracked separately from RefundEffectiveAt because a + // partial refund is still worth reporting on the snapshot — the money came + // back — while not being a reason to stop granting access. + refundInvalidates := false + for _, fact := range ordered { + snapshot.SourceFactIDs = append(snapshot.SourceFactIDs, fact.ID) + if fact.IsTestSource { + snapshot.IsTestSource = true + } + // Resolution is a per-fact provider statement and the latest one wins, + // exactly as it does for subscriptions. Review finding I-4: the previous + // rule only reported `unresolved` while the lineage had never resolved a + // Product at all, so a refund fact that Mosaic could not map — the Google + // void whose SKU cannot be recovered — left the lineage reading `owned` + // from its original purchase fact and kept granting a refunded purchase. + if fact.ResolutionState == "unresolved" { + productUnresolved = true + } + if fact.MosaicProductID != "" { + snapshot.MosaicProductID = fact.MosaicProductID + productUnresolved = false + } + switch fact.FactKind { + case "one_time_purchase", "initial_purchase": + // A duplicate acquisition fact is not a second purchase: ownership + // is a boolean, and the earliest validated acquisition is the one + // that dates it. + if !acquired { + snapshot.AcquiredAt = EffectiveAt(fact) + acquired = true + } + snapshot.ValidityState = OwnershipOwned + snapshot.UncertaintyReason = UncertaintyNone + // A re-purchase after a refund restores ownership; the history of + // the refund stays in the timeline. + snapshot.RefundEffectiveAt, snapshot.RevocationEffectiveAt = nil, nil + refundInvalidates = false + case "refund": + when := effectiveRefund(fact) + snapshot.RefundEffectiveAt = when + // Only a refund of the whole purchase ends ownership. A partial + // refund does not: Apple states it as `prorated` (REFUND_PRORATED), + // Google as `quantity_partial` on a voided purchase, and neither is + // the provider saying the customer no longer owns the item. + // + // This branch previously tested `!= "prorated"` alone, so a Google + // quantity-partial void — a partial money-back on a multi-quantity + // order — revoked a non-consumable outright. The subscription engine + // already read both shapes as non-invalidating (review finding + // I-14.1); the one-time engine now agrees, so the same provider + // statement cannot mean two different things depending on which + // purchase type received it. A genuine full void still arrives as a + // `full`/unspecified refund or a `revocation` fact. + refundInvalidates = !partialRefund(fact.RefundType) + if refundInvalidates { + snapshot.ValidityState = OwnershipRefunded + snapshot.UncertaintyReason = UncertaintyNone + } + case "revocation": + when := fact.RevokedAt + if when == nil { + at := EffectiveAt(fact) + when = &at + } + snapshot.RevocationEffectiveAt = when + snapshot.ValidityState = OwnershipRevoked + snapshot.UncertaintyReason = UncertaintyNone + } + } + + // An effective time in the future has not happened yet. Ownership survives + // until the provider's own effective instant, not until Mosaic hears about + // it. + if snapshot.ValidityState == OwnershipRefunded && !effective(snapshot.RefundEffectiveAt, asOf) { + snapshot.ValidityState = OwnershipOwned + } + if snapshot.ValidityState == OwnershipRevoked && !effective(snapshot.RevocationEffectiveAt, asOf) { + // A revocation scheduled for the future must not resurrect a refund + // that has already taken effect: the purchase is still refunded, it is + // simply not yet revoked. A *partial* refund is not such a refund, which + // is why the invalidating flag is consulted rather than the timestamp + // alone — otherwise a quantity-partial void followed by a future-dated + // revocation would report the purchase as refunded today. + if refundInvalidates && effective(snapshot.RefundEffectiveAt, asOf) { + snapshot.ValidityState = OwnershipRefunded + } else { + snapshot.ValidityState = OwnershipOwned + } + } + + // An unresolved Product outranks every ownership reading, and it is applied + // after the future-effective adjustments above so a refund Mosaic cannot map + // cannot be quietly restored to `owned`. Real revenue with unknown meaning is + // reported as unknown; guessing an Entitlement would be worse. + if productUnresolved { + snapshot.ValidityState = OwnershipUnknown + snapshot.UncertaintyReason = UncertaintyProductUnresolved + } + + snapshot.Checksum = oneTimeChecksum(snapshot) + result.Snapshot = snapshot + result.Timeline = timelineFor(ordered) + return result +} diff --git a/apps/api/internal/billingprojection/ordering.go b/apps/api/internal/billingprojection/ordering.go new file mode 100644 index 00000000..1df0e321 --- /dev/null +++ b/apps/api/internal/billingprojection/ordering.go @@ -0,0 +1,262 @@ +package billingprojection + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Canonical ordering, version 1 (plan §8). There is exactly one +// implementation, because two orderings that agree today would eventually +// disagree, and a projection that disagrees with its own replay is +// indistinguishable from data loss. +// +// The tuple is: +// +// (effective_at, fact_kind_precedence, provider_transaction_id, +// occurred_at, recorded_at, fact_id) +// +// `effective_at` is the provider-stated instant at which the fact takes +// effect, which is not always `occurred_at`: a refund is effective at the +// provider's refund time, an expiration at the period end. Received and +// recorded times are late tie-breakers only — sorting by them would make +// arrival order the truth, which is exactly what the failure model forbids. +// +// Google's compensation: every fact in a Google lineage shares +// occurred_at = startTime, so `occurred_at` alone ties for the whole lineage. +// The recovered provider event time (fact-shape v2) is used as the effective +// time wherever the fact kind has no more specific one, which is what breaks +// the tie with a provider statement rather than with arrival order. + +// factKindPrecedence orders facts that share an effective instant. +// +// The ranking answers one question: if two provider statements take effect at +// the same instant, which one describes the later state? Terminal statements +// (revocation, refund, expiration) rank last so a purchase and its revocation +// stamped with the same timestamp do not project as active. Intent-only +// changes rank before state changes because they never move access on their +// own. +func factKindPrecedence(kind string) int { + switch kind { + case "initial_purchase": + return 10 + case "offer_redeemed": + return 20 + case "renewal": + return 30 + case "one_time_purchase": + return 35 + case "plan_change": + return 40 + case "auto_renew_enabled": + return 50 + case "auto_renew_disabled": + return 55 + case "cancellation_scheduled": + return 60 + case "resumed": + return 65 + case "paused": + return 70 + case "grace_period_start": + return 75 + case "billing_retry_start": + return 80 + case "purchase_superseded": + return 85 + case "expiration": + return 90 + case "refund": + return 95 + case "revocation": + return 100 + default: + // An unrecognized kind sorts last within its instant rather than + // first: an unknown statement must not be able to precede — and so + // mask — a known terminal one. + return 110 + } +} + +// EffectiveAt is the instant a fact takes effect (plan §8). It is a total +// function: every fact has an effective time, because a fact that could not be +// placed on the timeline could not be ordered deterministically. +func EffectiveAt(fact Fact) time.Time { + switch fact.FactKind { + case "revocation": + if fact.RevokedAt != nil { + return fact.RevokedAt.UTC() + } + case "refund": + if fact.RefundedAt != nil { + return fact.RefundedAt.UTC() + } + if fact.RevokedAt != nil { + return fact.RevokedAt.UTC() + } + case "expiration": + // An expiration takes effect when the period ended, not when the + // provider got around to saying so. + if fact.PeriodEndAt != nil { + return fact.PeriodEndAt.UTC() + } + case "grace_period_start": + if fact.PeriodEndAt != nil { + // Grace begins where the paid period ended. + return fact.PeriodEndAt.UTC() + } + case "renewal", "initial_purchase", "one_time_purchase", "offer_redeemed": + if fact.PeriodStartAt != nil { + return fact.PeriodStartAt.UTC() + } + case "cancellation_scheduled", "auto_renew_disabled", "auto_renew_enabled", "plan_change": + // Intent changes take effect when the provider observed them, which + // for Google is the RTDN event time rather than the lineage-constant + // startTime. + if fact.ProviderEventOccurredAt != nil { + return fact.ProviderEventOccurredAt.UTC() + } + case "paused", "resumed", "billing_retry_start", "purchase_superseded": + if fact.ProviderEventOccurredAt != nil { + return fact.ProviderEventOccurredAt.UTC() + } + } + if fact.ProviderEventOccurredAt != nil && fact.Provider == "google_play" { + // Google facts share occurred_at across a lineage; the recovered event + // time is the only provider statement that distinguishes them. + return fact.ProviderEventOccurredAt.UTC() + } + return fact.OccurredAt.UTC() +} + +// Sort orders facts canonically in place and returns the same slice. Sorting +// is stable under the full tuple, so equal inputs order identically on every +// run and on every machine. +func Sort(facts []Fact) []Fact { + sort.SliceStable(facts, func(i, j int) bool { + return less(facts[i], facts[j]) + }) + return facts +} + +func less(a, b Fact) bool { + if effectiveA, effectiveB := EffectiveAt(a), EffectiveAt(b); !effectiveA.Equal(effectiveB) { + return effectiveA.Before(effectiveB) + } + if precedenceA, precedenceB := factKindPrecedence(a.FactKind), factKindPrecedence(b.FactKind); precedenceA != precedenceB { + return precedenceA < precedenceB + } + if a.ProviderTransactionID != b.ProviderTransactionID { + return a.ProviderTransactionID < b.ProviderTransactionID + } + if !a.OccurredAt.Equal(b.OccurredAt) { + return a.OccurredAt.Before(b.OccurredAt) + } + if !a.RecordedAt.Equal(b.RecordedAt) { + return a.RecordedAt.Before(b.RecordedAt) + } + return a.ID < b.ID +} + +// Position is the canonical ordering position of one fact, encoded so it can +// be stored in a checkpoint and compared later without reloading the fact. +// +// The ordering version is part of the encoding: a checkpoint written under one +// ordering is not comparable with a position computed under another, and +// silently comparing them would make an ordering change look like an +// out-of-order fact for every lineage at once. +// The provider transaction id is variable width, so the delimiter that follows +// it must sort below every character the field can contain — otherwise the +// encoded position disagrees with the comparison the sort actually uses. With +// the ordinary "|" delimiter, "abc|…" compared greater than "abcd|…" while +// less() puts "abc" first, so a resumed projection could skip or replay a fact. +// "!" (0x21) is below every character admissible in a provider transaction id +// (digits, hex, "token:"), which makes prefix ordering agree with field +// ordering. +func Position(fact Fact) string { + return fmt.Sprintf("v%d|%020d|%03d|%s!%020d|%s", + OrderingVersion, + EffectiveAt(fact).UnixMilli(), + factKindPrecedence(fact.FactKind), + fact.ProviderTransactionID, + fact.OccurredAt.UnixMilli(), + fact.ID) +} + +// PrefixIntact reports whether a checkpoint still describes a prefix of the +// canonically ordered timeline. +// +// The question is not "does any fact sort at or before the watermark" — every +// already-projected fact does, which made that check permanently true after the +// first checkpoint and rendered the invalidated flag meaningless. The question +// is whether the fact now sitting at the checkpoint's own depth is still the +// fact the checkpoint recorded. A fact inserted earlier in the timeline shifts +// that position and is exactly the out-of-order arrival the checkpoint rules +// require a full reprojection for. +func PrefixIntact(ordered []Fact, watermark string, factsProjected int64) bool { + if watermark == "" { + return true + } + if !strings.HasPrefix(watermark, fmt.Sprintf("v%d|", OrderingVersion)) { + // A watermark from another ordering version is not comparable at all. + return false + } + if factsProjected <= 0 || factsProjected > int64(len(ordered)) { + // Facts disappeared, or the checkpoint predates the count being + // recorded. Reprojecting is the conservative answer either way. + return false + } + return Position(ordered[factsProjected-1]) == watermark +} + +// HighWatermark is the position of the last fact in a canonically ordered +// slice. An empty slice has an empty watermark, which sorts before every real +// position and so makes a first projection indistinguishable from a full +// replay — the property that lets replay ignore checkpoints safely. +func HighWatermark(ordered []Fact) string { + if len(ordered) == 0 { + return "" + } + return Position(ordered[len(ordered)-1]) +} + +// OutOfOrder reports whether any fact in the (canonically ordered) slice +// belongs at or before the checkpoint's high watermark. Such a fact means the +// checkpoint no longer describes a prefix of the timeline, so it must be +// invalidated and the lineage reprojected from zero rather than resumed. +// +// It returns the earliest offending position so the caller can name the fact +// in a diagnostic instead of reporting only that something was late. +func OutOfOrder(ordered []Fact, watermark string) (string, bool) { + if watermark == "" { + return "", false + } + if !strings.HasPrefix(watermark, fmt.Sprintf("v%d|", OrderingVersion)) { + // A watermark from another ordering version cannot be compared. It is + // reported as out-of-order so the lineage reprojects from zero, which + // is the conservative and correct response to an ordering change. + return watermark, true + } + for _, fact := range ordered { + if position := Position(fact); position <= watermark { + return position, true + } + } + return "", false +} + +// After returns the facts strictly after the watermark, preserving canonical +// order. Callers that have detected out-of-order arrival must not use it: the +// checkpoint is invalid and the whole lineage is reprojected instead. +func After(ordered []Fact, watermark string) []Fact { + if watermark == "" { + return ordered + } + for index, fact := range ordered { + if Position(fact) > watermark { + return ordered[index:] + } + } + return nil +} diff --git a/apps/api/internal/billingprojection/projection_test.go b/apps/api/internal/billingprojection/projection_test.go new file mode 100644 index 00000000..a843407a --- /dev/null +++ b/apps/api/internal/billingprojection/projection_test.go @@ -0,0 +1,835 @@ +package billingprojection + +import ( + "testing" + "time" +) + +// The projection engine decides whether a paying customer has access. These +// tests pin the properties from plan §17 whose failure is invisible until a +// customer is wrongly cut off (or wrongly kept): determinism, the transitions +// where providers and intuition disagree, and the aggregation rules that stop +// one ended source from removing an unrelated valid one. + +func at(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed.UTC() +} + +func ptr(value string) *time.Time { + parsed := at(value) + return &parsed +} + +func boolPtr(value bool) *bool { return &value } + +func purchase(id string, start, end string) Fact { + return Fact{ + ID: id, Provider: "app_store", ProviderTransactionID: id, + FactKind: "initial_purchase", TransactionType: "auto_renewable_subscription", + OccurredAt: at(start), RecordedAt: at(start), + PeriodStartAt: ptr(start), PeriodEndAt: ptr(end), + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + RenewalExpected: boolPtr(true), + } +} + +func renewal(id string, start, end string) Fact { + fact := purchase(id, start, end) + fact.FactKind = "renewal" + return fact +} + +// --- determinism ----------------------------------------------------------- + +// The same facts must produce the same checksum regardless of the order they +// are handed to the engine. Without this, a reprojection triggered by an +// out-of-order delivery would look like a state change and emit a spurious +// access-change webhook to every customer it touched. +func TestProjectionIsOrderIndependent(t *testing.T) { + facts := []Fact{ + purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + renewal("t2", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"), + renewal("t3", "2026-03-01T00:00:00Z", "2026-04-01T00:00:00Z"), + } + reversed := []Fact{facts[2], facts[1], facts[0]} + + forward := ProjectSubscription(facts, at("2026-03-15T00:00:00Z"), DefaultPolicy(), false) + backward := ProjectSubscription(reversed, at("2026-03-15T00:00:00Z"), DefaultPolicy(), false) + + if string(forward.Snapshot.Checksum) != string(backward.Snapshot.Checksum) { + t.Fatal("reverse-order facts produced a different projection") + } + if forward.HighWatermark != backward.HighWatermark { + t.Fatalf("watermarks differ: %q vs %q", forward.HighWatermark, backward.HighWatermark) + } +} + +// A duplicate delivery of the same fact must not change the projection. The +// 9A fact-identity constraint deduplicates most of these, but reconciliation +// and replay can still present the same logical fact twice. +func TestDuplicateFactsDoNotChangeProjection(t *testing.T) { + facts := []Fact{ + purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + renewal("t2", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"), + } + withDuplicate := append(append([]Fact(nil), facts...), facts[1]) + + single := ProjectSubscription(facts, at("2026-02-15T00:00:00Z"), DefaultPolicy(), false) + doubled := ProjectSubscription(withDuplicate, at("2026-02-15T00:00:00Z"), DefaultPolicy(), false) + + if string(single.Snapshot.Checksum) != string(doubled.Snapshot.Checksum) { + t.Fatal("a duplicate fact changed the projection") + } +} + +// Projecting from a checkpoint must equal projecting the full history. If it +// does not, the checkpoint has become a second, divergent copy of state — +// which the checkpoint rules explicitly forbid. +func TestCheckpointResumeEqualsFullReplay(t *testing.T) { + early := []Fact{ + purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + renewal("t2", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"), + } + late := renewal("t3", "2026-03-01T00:00:00Z", "2026-04-01T00:00:00Z") + full := append(append([]Fact(nil), early...), late) + + checkpoint := ProjectSubscription(early, at("2026-02-15T00:00:00Z"), DefaultPolicy(), false) + ordered := Sort(append([]Fact(nil), full...)) + if _, out := OutOfOrder([]Fact{late}, checkpoint.HighWatermark); out { + t.Fatal("a strictly later fact was reported as out-of-order") + } + remaining := After(ordered, checkpoint.HighWatermark) + if len(remaining) != 1 || remaining[0].ID != "t3" { + t.Fatalf("resume selected %d facts, want just t3", len(remaining)) + } + + fullReplay := ProjectSubscription(full, at("2026-03-15T00:00:00Z"), DefaultPolicy(), false) + // The engine is a fold over the whole timeline, so resuming means + // reprojecting the lineage; the check that matters is that the watermark + // arithmetic selects exactly the unprojected suffix and that the resulting + // state is the full-replay state. + resumed := ProjectSubscription(full, at("2026-03-15T00:00:00Z"), DefaultPolicy(), false) + if string(fullReplay.Snapshot.Checksum) != string(resumed.Snapshot.Checksum) { + t.Fatal("checkpoint resume diverged from full replay") + } +} + +// An out-of-order fact must invalidate the checkpoint rather than be appended +// after it, otherwise a late-arriving refund would be projected as if it +// happened after the renewal that followed it. +func TestOutOfOrderFactInvalidatesCheckpoint(t *testing.T) { + early := []Fact{purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z")} + checkpoint := ProjectSubscription(early, at("2026-01-15T00:00:00Z"), DefaultPolicy(), false) + + late := purchase("t0", "2025-12-01T00:00:00Z", "2026-01-01T00:00:00Z") + position, out := OutOfOrder([]Fact{late}, checkpoint.HighWatermark) + if !out { + t.Fatal("a fact effective before the watermark was not detected as out-of-order") + } + if position == "" { + t.Fatal("out-of-order detection did not identify the offending position") + } +} + +// --- state transitions ----------------------------------------------------- + +// The single most damaging wrong behaviour a subscription system can have: +// treating cancellation as immediate loss of access. Cancellation is renewal +// intent only; access runs to the validated period end. +func TestCancellationKeepsAccessUntilPeriodEnd(t *testing.T) { + facts := []Fact{ + purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + { + ID: "t2", Provider: "app_store", ProviderTransactionID: "t1", + FactKind: "cancellation_scheduled", OccurredAt: at("2026-01-10T00:00:00Z"), + RecordedAt: at("2026-01-10T00:00:00Z"), ProviderEventOccurredAt: ptr("2026-01-10T00:00:00Z"), + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + RenewalExpected: boolPtr(false), + }, + } + + during := ProjectSubscription(facts, at("2026-01-20T00:00:00Z"), DefaultPolicy(), false) + if during.Snapshot.AccessState != AccessActive { + t.Fatalf("access %q after cancellation but before period end, want active", during.Snapshot.AccessState) + } + if during.Snapshot.RenewalIntent != RenewalDisabled { + t.Fatalf("renewal intent %q, want auto_renew_disabled", during.Snapshot.RenewalIntent) + } + + after := ProjectSubscription(facts, at("2026-02-02T00:00:00Z"), DefaultPolicy(), false) + if after.Snapshot.AccessState != AccessInactive || after.Snapshot.LifecycleState != LifecycleExpired { + t.Fatalf("after period end got %q/%q, want inactive/expired", + after.Snapshot.AccessState, after.Snapshot.LifecycleState) + } +} + +// Grace grants access on both providers per their documentation; billing retry +// and pause do not. Getting any of the three wrong either cuts off a paying +// customer or gives away months of free access. +// +// The grace facts here are pipeline-shaped, which is the point. Apple has no +// grace notification: a grace period arrives as DID_FAIL_TO_RENEW — fact kind +// `billing_retry_start` — carrying `gracePeriodExpiresDate` on the renewal +// payload. An earlier version of this test hand-built an Apple +// `grace_period_start` fact that the validator cannot emit, so it passed for +// sixteen days of every Apple grace window during which real customers were +// projected inactive. Google's grace arrives as `grace_period_start` with the +// extended expiryTime as the grace end. +func TestGraceRetryAndPauseAccessPolicy(t *testing.T) { + base := purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z") + + // Apple: DID_FAIL_TO_RENEW with a provider-stated grace end. + appleGrace := Fact{ + ID: "t2", Provider: "app_store", ProviderTransactionID: "t1", + FactKind: "billing_retry_start", OccurredAt: at("2026-02-01T00:00:00Z"), + RecordedAt: at("2026-02-01T00:00:00Z"), ProviderEventOccurredAt: ptr("2026-02-01T00:00:00Z"), + PeriodEndAt: ptr("2026-02-01T00:00:00Z"), GracePeriodExpiresAt: ptr("2026-02-16T00:00:00Z"), + BillingRetryActive: boolPtr(true), + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + } + grace := ProjectSubscription([]Fact{base, appleGrace}, at("2026-02-05T00:00:00Z"), DefaultPolicy(), false) + if grace.Snapshot.AccessState != AccessActive || grace.Snapshot.LifecycleState != LifecycleGracePeriod { + t.Fatalf("Apple grace got %q/%q, want active/grace_period", + grace.Snapshot.AccessState, grace.Snapshot.LifecycleState) + } + // Once the provider's grace end passes, the same facts are billing retry. + afterGrace := ProjectSubscription([]Fact{base, appleGrace}, at("2026-02-20T00:00:00Z"), DefaultPolicy(), false) + if afterGrace.Snapshot.LifecycleState != LifecycleBillingRetry || afterGrace.Snapshot.AccessState != AccessInactive { + t.Fatalf("after Apple grace got %q/%q, want inactive/billing_retry", + afterGrace.Snapshot.AccessState, afterGrace.Snapshot.LifecycleState) + } + + // Google: SUBSCRIPTION_STATE_IN_GRACE_PERIOD, whose extended expiryTime is + // both the period end and the grace end. Reading only the period would + // report this as plainly active and leave grants_in_grace unenforceable. + googleGrace := ProjectSubscription([]Fact{base, { + ID: "t2g", Provider: "google_play", ProviderTransactionID: "t1", + FactKind: "grace_period_start", OccurredAt: at("2026-02-01T00:00:00Z"), + RecordedAt: at("2026-02-01T00:00:00Z"), ProviderEventOccurredAt: ptr("2026-02-01T00:00:00Z"), + PeriodEndAt: ptr("2026-02-16T00:00:00Z"), GracePeriodExpiresAt: ptr("2026-02-16T00:00:00Z"), + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + }}, at("2026-02-05T00:00:00Z"), DefaultPolicy(), false) + if googleGrace.Snapshot.LifecycleState != LifecycleGracePeriod { + t.Fatalf("Google grace got lifecycle %q, want grace_period", googleGrace.Snapshot.LifecycleState) + } + strict := DefaultPolicy() + strict.GrantsInGrace = false + googleStrict := ProjectSubscription([]Fact{base, { + ID: "t2g", Provider: "google_play", ProviderTransactionID: "t1", + FactKind: "grace_period_start", OccurredAt: at("2026-02-01T00:00:00Z"), + RecordedAt: at("2026-02-01T00:00:00Z"), ProviderEventOccurredAt: ptr("2026-02-01T00:00:00Z"), + PeriodEndAt: ptr("2026-02-16T00:00:00Z"), GracePeriodExpiresAt: ptr("2026-02-16T00:00:00Z"), + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + }}, at("2026-02-05T00:00:00Z"), strict, false) + if googleStrict.Snapshot.AccessState != AccessInactive { + t.Fatalf("grants_in_grace=false on Google got %q, want inactive", googleStrict.Snapshot.AccessState) + } + + // Billing retry without a provider grace end grants nothing. + retry := ProjectSubscription([]Fact{base, { + ID: "t3", Provider: "google_play", ProviderTransactionID: "t1", + FactKind: "billing_retry_start", OccurredAt: at("2026-02-01T00:00:00Z"), + RecordedAt: at("2026-02-01T00:00:00Z"), ProviderEventOccurredAt: ptr("2026-02-01T00:00:00Z"), + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + }}, at("2026-02-05T00:00:00Z"), DefaultPolicy(), false) + if retry.Snapshot.AccessState != AccessInactive || retry.Snapshot.LifecycleState != LifecycleBillingRetry { + t.Fatalf("billing retry got %q/%q, want inactive/billing_retry", + retry.Snapshot.AccessState, retry.Snapshot.LifecycleState) + } + + // A pause that is effective now is inactive; the same fact evaluated + // before its effective instant keeps access, because a scheduled pause has + // not started. + paused := Fact{ + ID: "t4", Provider: "google_play", ProviderTransactionID: "t1", + FactKind: "paused", OccurredAt: at("2026-01-05T00:00:00Z"), + RecordedAt: at("2026-01-05T00:00:00Z"), ProviderEventOccurredAt: ptr("2026-01-20T00:00:00Z"), + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + } + scheduled := ProjectSubscription([]Fact{base, paused}, at("2026-01-10T00:00:00Z"), DefaultPolicy(), false) + if scheduled.Snapshot.AccessState != AccessActive { + t.Fatalf("scheduled pause got %q, want active until effective", scheduled.Snapshot.AccessState) + } + effectivePause := ProjectSubscription([]Fact{base, paused}, at("2026-01-25T00:00:00Z"), DefaultPolicy(), false) + if effectivePause.Snapshot.AccessState != AccessInactive || effectivePause.Snapshot.LifecycleState != LifecyclePaused { + t.Fatalf("effective pause got %q/%q, want inactive/paused", + effectivePause.Snapshot.AccessState, effectivePause.Snapshot.LifecycleState) + } +} + +// OD-18(a): a prorated Apple refund does not revoke the remaining period. A +// full refund carrying a revocation date does. +func TestRefundScopeRespectsProration(t *testing.T) { + base := purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z") + refundFact := func(refundType string, revoked *time.Time) Fact { + return Fact{ + ID: "t2", Provider: "app_store", ProviderTransactionID: "t1", + FactKind: "refund", OccurredAt: at("2026-01-10T00:00:00Z"), + RecordedAt: at("2026-01-10T00:00:00Z"), RefundedAt: ptr("2026-01-10T00:00:00Z"), + RevokedAt: revoked, RefundType: refundType, + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + } + } + + prorated := ProjectSubscription([]Fact{base, refundFact("prorated", nil)}, + at("2026-01-20T00:00:00Z"), DefaultPolicy(), false) + if prorated.Snapshot.AccessState != AccessActive { + t.Fatalf("prorated refund got %q, want the remaining period preserved", prorated.Snapshot.AccessState) + } + + full := ProjectSubscription([]Fact{base, refundFact("full", ptr("2026-01-10T00:00:00Z"))}, + at("2026-01-20T00:00:00Z"), DefaultPolicy(), false) + if full.Snapshot.AccessState != AccessInactive || full.Snapshot.LifecycleState != LifecycleRefunded { + t.Fatalf("full refund got %q/%q, want inactive/refunded", + full.Snapshot.AccessState, full.Snapshot.LifecycleState) + } +} + +// Google's quantity-based partial refund must not end the subscription. +// +// The Google void path stamps `revoked_at` from its own event time for every +// void, partial included, so the previous rule ("not prorated and carries a +// revocation date invalidates") terminated the lineage on a partial refund. +// A customer refunded for one unit of a multi-quantity purchase, or given a +// partial goodwill refund, lost the rest of the period they had paid for — the +// same class of wrongful removal OD-18(a) rejects for Apple's prorated refund. +func TestGoogleQuantityPartialRefundKeepsSubscriptionActive(t *testing.T) { + base := purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z") + base.Provider = "google_play" + partial := Fact{ + ID: "t2", Provider: "google_play", ProviderTransactionID: "t1", + FactKind: "refund", OccurredAt: at("2026-01-10T00:00:00Z"), + RecordedAt: at("2026-01-10T00:00:00Z"), ProviderEventOccurredAt: ptr("2026-01-10T00:00:00Z"), + RefundedAt: ptr("2026-01-10T00:00:00Z"), RevokedAt: ptr("2026-01-10T00:00:00Z"), + RefundType: "quantity_partial", + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + } + + result := ProjectSubscription([]Fact{base, partial}, at("2026-01-20T00:00:00Z"), DefaultPolicy(), false) + if result.Snapshot.AccessState != AccessActive { + t.Fatalf("quantity_partial refund got %q/%q, want the remaining period preserved", + result.Snapshot.AccessState, result.Snapshot.LifecycleState) + } + if result.Snapshot.LifecycleState == LifecycleRefunded { + t.Fatal("a partial refund terminated the lineage as fully refunded") + } + if result.Snapshot.RefundEffectiveAt == nil { + t.Fatal("the partial refund was not recorded on the snapshot at all") + } + + // A full void still revokes: the correction must not have widened into + // "Google refunds never end access". + full := partial + full.ID, full.RefundType = "t3", "full" + revoked := ProjectSubscription([]Fact{base, full}, at("2026-01-20T00:00:00Z"), DefaultPolicy(), false) + if revoked.Snapshot.AccessState != AccessInactive || revoked.Snapshot.LifecycleState != LifecycleRefunded { + t.Fatalf("full Google void got %q/%q, want inactive/refunded", + revoked.Snapshot.AccessState, revoked.Snapshot.LifecycleState) + } +} + +// A refund fact Mosaic could not attribute to a Product must drive the purchase +// to `unknown`, never leave it owned. +// +// This is the projection half of the void-without-SKU correction: the worker now +// records a product-unresolved refund fact for a Google void whose order cannot +// be attributed. If the one-time engine kept reading the Product from the +// original purchase fact and ignored the later unresolved statement, the whole +// point of recording that fact — stopping a refunded purchase from granting — +// would be lost, and the customer would keep the Entitlement forever. +func TestProductUnresolvedRefundDrivesOwnershipToUnknown(t *testing.T) { + acquire := Fact{ + ID: "p1", Provider: "google_play", ProviderTransactionID: "p1", + FactKind: "one_time_purchase", TransactionType: "non_consumable", + OccurredAt: at("2026-01-01T00:00:00Z"), RecordedAt: at("2026-01-01T00:00:00Z"), + PeriodStartAt: ptr("2026-01-01T00:00:00Z"), + MosaicProductID: "prod_lifetime", ResolutionState: "active_mapping", + } + // The fact the void path records when orders.get cannot attribute a SKU: + // no Mosaic Product, resolution_state unresolved. + unattributedRefund := Fact{ + ID: "p2", Provider: "google_play", ProviderTransactionID: "order-1", + FactKind: "refund", TransactionType: "non_consumable", + OccurredAt: at("2026-02-01T00:00:00Z"), RecordedAt: at("2026-02-01T00:00:00Z"), + ProviderEventOccurredAt: ptr("2026-02-01T00:00:00Z"), + RefundedAt: ptr("2026-02-01T00:00:00Z"), RevokedAt: ptr("2026-02-01T00:00:00Z"), + RefundType: "full", + ResolutionState: "unresolved", + } + + result := ProjectOneTimePurchase([]Fact{acquire, unattributedRefund}, at("2026-03-01T00:00:00Z")) + if result.Snapshot.ValidityState != OwnershipUnknown { + t.Fatalf("unattributed refund got %q, want unknown", result.Snapshot.ValidityState) + } + if result.Snapshot.UncertaintyReason != UncertaintyProductUnresolved { + t.Fatalf("uncertainty reason %q, want product_unresolved", result.Snapshot.UncertaintyReason) + } + + // The Entitlement source built from it must not grant access. + snapshot := ProjectEntitlements(CustomerProjection{ + OneTimes: []OneTimeSource{{ + InstanceID: "one_1", PurchaseLineageID: "lin_1", Snapshot: result.Snapshot, + Grants: []GrantVersion{{ID: "v1", ProductID: "prod_lifetime", + EntitlementID: "ent_pro", EntitlementKey: "pro", Policy: DefaultPolicy()}}, + }}, + }, at("2026-03-01T00:00:00Z")) + if len(snapshot.Entries) != 1 { + t.Fatalf("got %d entries, want one", len(snapshot.Entries)) + } + if snapshot.Entries[0].State != AccessUnknown { + t.Fatalf("refunded-but-unattributed purchase produced %q, want unknown", + snapshot.Entries[0].State) + } + + // Re-attributing the refund clears the uncertainty rather than sticking. + attributed := unattributedRefund + attributed.ID, attributed.ResolutionState = "p3", "active_mapping" + attributed.MosaicProductID = "prod_lifetime" + repaired := ProjectOneTimePurchase([]Fact{acquire, attributed}, at("2026-03-01T00:00:00Z")) + if repaired.Snapshot.ValidityState != OwnershipRefunded { + t.Fatalf("re-attributed refund got %q, want refunded", repaired.Snapshot.ValidityState) + } +} + +// Apple REFUND_REVERSED reinstates access. A revocation that is later +// contradicted by a validated renewal must not leave the customer locked out. +func TestLateRenewalReinstatesRevokedLineage(t *testing.T) { + facts := []Fact{ + purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + { + ID: "t2", Provider: "app_store", ProviderTransactionID: "t1", + FactKind: "revocation", OccurredAt: at("2026-01-10T00:00:00Z"), + RecordedAt: at("2026-01-10T00:00:00Z"), RevokedAt: ptr("2026-01-10T00:00:00Z"), + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + }, + renewal("t3", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"), + } + result := ProjectSubscription(facts, at("2026-02-10T00:00:00Z"), DefaultPolicy(), false) + if result.Snapshot.AccessState != AccessActive { + t.Fatalf("reinstated lineage got %q, want active", result.Snapshot.AccessState) + } +} + +// Absent or unmappable evidence must never become `inactive` — principle 4. +func TestUnknownIsNotInactive(t *testing.T) { + empty := ProjectSubscription(nil, at("2026-01-01T00:00:00Z"), DefaultPolicy(), false) + if empty.Snapshot.AccessState != AccessUnknown || empty.Snapshot.UncertaintyReason == UncertaintyNone { + t.Fatalf("no facts got %q/%q, want unknown with a reason", + empty.Snapshot.AccessState, empty.Snapshot.UncertaintyReason) + } + + unresolved := purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z") + unresolved.MosaicProductID, unresolved.ResolutionState = "", "unresolved" + result := ProjectSubscription([]Fact{unresolved}, at("2026-01-15T00:00:00Z"), DefaultPolicy(), false) + if result.Snapshot.AccessState != AccessUnknown || + result.Snapshot.UncertaintyReason != UncertaintyProductUnresolved { + t.Fatalf("unresolved Product got %q/%q, want unknown/product_unresolved", + result.Snapshot.AccessState, result.Snapshot.UncertaintyReason) + } +} + +// --- one-time purchases ---------------------------------------------------- + +// A non-consumable never expires by time, and a refunded one stops granting. +// The first half of this is what keeps lifetime customers entitled; the second +// is 9A defect B2's customer-visible consequence. +func TestOneTimePurchaseOwnershipAndRefund(t *testing.T) { + acquire := Fact{ + ID: "p1", Provider: "google_play", ProviderTransactionID: "p1", + FactKind: "one_time_purchase", TransactionType: "non_consumable", + OccurredAt: at("2026-01-01T00:00:00Z"), RecordedAt: at("2026-01-01T00:00:00Z"), + PeriodStartAt: ptr("2026-01-01T00:00:00Z"), + MosaicProductID: "prod_lifetime", ResolutionState: "active_mapping", + } + owned := ProjectOneTimePurchase([]Fact{acquire}, at("2030-01-01T00:00:00Z")) + if owned.Snapshot.ValidityState != OwnershipOwned { + t.Fatalf("a non-consumable expired by time: %q", owned.Snapshot.ValidityState) + } + + refund := Fact{ + ID: "p2", Provider: "google_play", ProviderTransactionID: "p1", + FactKind: "refund", OccurredAt: at("2026-02-01T00:00:00Z"), + RecordedAt: at("2026-02-01T00:00:00Z"), RefundedAt: ptr("2026-02-01T00:00:00Z"), + RevokedAt: ptr("2026-02-01T00:00:00Z"), RefundType: "full", + MosaicProductID: "prod_lifetime", ResolutionState: "active_mapping", + } + refunded := ProjectOneTimePurchase([]Fact{acquire, refund}, at("2026-03-01T00:00:00Z")) + if refunded.Snapshot.ValidityState != OwnershipRefunded { + t.Fatalf("refunded non-consumable got %q, want refunded", refunded.Snapshot.ValidityState) + } +} + +// A Google `quantity_partial` void of a one-time purchase does not invalidate +// ownership; only a full void does (ratified decision, plan §7 / OD-18). +// +// The subscription engine already read both partial shapes as non-invalidating +// (review finding I-14.1) while the one-time engine tested `!= "prorated"` +// alone, so the same provider statement meant "keep the period" on a +// subscription and "you no longer own it" on a non-consumable. The customer- +// visible failure is permanent: a lifetime purchase revoked by a partial +// money-back has no expiry to recover from and no later fact to restore it. +func TestGoogleQuantityPartialRefundKeepsOneTimeOwnership(t *testing.T) { + acquire := Fact{ + ID: "p1", Provider: "google_play", ProviderTransactionID: "p1", + FactKind: "one_time_purchase", TransactionType: "non_consumable", + OccurredAt: at("2026-01-01T00:00:00Z"), RecordedAt: at("2026-01-01T00:00:00Z"), + PeriodStartAt: ptr("2026-01-01T00:00:00Z"), + MosaicProductID: "prod_lifetime", ResolutionState: "active_mapping", + } + // The Google void path stamps revoked_at from its own event time for every + // void, partial included, so the revocation date cannot distinguish them. + partial := Fact{ + ID: "p2", Provider: "google_play", ProviderTransactionID: "p1", + FactKind: "refund", OccurredAt: at("2026-02-01T00:00:00Z"), + RecordedAt: at("2026-02-01T00:00:00Z"), RefundedAt: ptr("2026-02-01T00:00:00Z"), + RevokedAt: ptr("2026-02-01T00:00:00Z"), RefundType: "quantity_partial", + MosaicProductID: "prod_lifetime", ResolutionState: "active_mapping", + } + + result := ProjectOneTimePurchase([]Fact{acquire, partial}, at("2026-03-01T00:00:00Z")) + if result.Snapshot.ValidityState != OwnershipOwned { + t.Fatalf("quantity_partial void got %q, want ownership preserved", + result.Snapshot.ValidityState) + } + if result.Snapshot.RefundEffectiveAt == nil { + t.Fatal("the partial refund was not recorded on the snapshot at all") + } + + // A future-dated revocation must not let the recorded partial refund stand + // in for one that invalidates. + pending := Fact{ + ID: "p3", Provider: "google_play", ProviderTransactionID: "p1", + FactKind: "revocation", OccurredAt: at("2026-02-15T00:00:00Z"), + RecordedAt: at("2026-02-15T00:00:00Z"), RevokedAt: ptr("2030-01-01T00:00:00Z"), + MosaicProductID: "prod_lifetime", ResolutionState: "active_mapping", + } + scheduled := ProjectOneTimePurchase([]Fact{acquire, partial, pending}, at("2026-03-01T00:00:00Z")) + if scheduled.Snapshot.ValidityState != OwnershipOwned { + t.Fatalf("a not-yet-effective revocation after a partial refund got %q, want owned", + scheduled.Snapshot.ValidityState) + } + + // The correction must not widen into "Google voids never end ownership". + full := partial + full.ID, full.RefundType = "p4", "full" + voided := ProjectOneTimePurchase([]Fact{acquire, full}, at("2026-03-01T00:00:00Z")) + if voided.Snapshot.ValidityState != OwnershipRefunded { + t.Fatalf("full Google void got %q, want refunded", voided.Snapshot.ValidityState) + } +} + +// --- grant selection ------------------------------------------------------- + +// Grants are selected by the purchase's own effective time, and a purchase +// predating the earliest recorded version selects that earliest version rather +// than stranding with none — the 9B backfill boundary rule. +func TestGrantSelectionUsesPeriodTimeAndBackfillBoundary(t *testing.T) { + versions := []GrantVersion{ + {ID: "v1", ProductID: "prod_pro", EntitlementID: "ent_pro", Version: 1, + EffectiveStart: at("2026-01-01T00:00:00Z"), EffectiveEnd: ptr("2026-06-01T00:00:00Z"), + Policy: DefaultPolicy()}, + {ID: "v2", ProductID: "prod_pro", EntitlementID: "ent_pro", Version: 2, + EffectiveStart: at("2026-06-01T00:00:00Z"), Policy: DefaultPolicy()}, + } + + historical := SelectGrantVersions(versions, "prod_pro", at("2026-03-01T00:00:00Z"), "auto_renewable_subscription") + if len(historical) != 1 || historical[0].ID != "v1" { + t.Fatalf("historical purchase selected %+v, want v1", historical) + } + current := SelectGrantVersions(versions, "prod_pro", at("2026-08-01T00:00:00Z"), "auto_renewable_subscription") + if len(current) != 1 || current[0].ID != "v2" { + t.Fatalf("current purchase selected %+v, want v2", current) + } + predating := SelectGrantVersions(versions, "prod_pro", at("2025-01-01T00:00:00Z"), "auto_renewable_subscription") + if len(predating) != 1 || predating[0].ID != "v1" { + t.Fatalf("purchase predating all versions selected %+v, want the earliest version", predating) + } +} + +// Retroactive change is only ever accepted as an additive superset, because +// any other shape can take access away from a customer who did nothing. +func TestRetroactiveGrantMustBeAdditiveSuperset(t *testing.T) { + current := GrantVersion{ProductID: "prod_pro", EntitlementID: "ent_pro", Policy: DefaultPolicy()} + widened := current + widened.Policy.GrantsInBillingRetry = true + if _, ok := ValidateAdditiveSuperset(current, widened); !ok { + t.Fatal("widening access was rejected; an additive superset must be accepted") + } + narrowed := current + narrowed.Policy.GrantsInGrace = false + if code, ok := ValidateAdditiveSuperset(current, narrowed); ok { + t.Fatal("narrowing grace access was accepted retroactively") + } else if code != "grace_access_narrowed" { + t.Fatalf("rejection code %q, want grace_access_narrowed", code) + } +} + +// --- entitlement aggregation ---------------------------------------------- + +// An Entitlement with several sources stays active while any one of them +// does, and a permanent source must never report a finite expiry — the two +// aggregation rules whose failure silently cuts off a lifetime customer when +// their unrelated monthly subscription lapses. +func TestAggregationKeepsPermanentSourceWithoutFalseExpiry(t *testing.T) { + grant := GrantVersion{ + ID: "v1", ProductID: "prod_pro", EntitlementID: "ent_pro", + EntitlementKey: "pro", Policy: DefaultPolicy(), + } + lifetimeGrant := grant + lifetimeGrant.ID, lifetimeGrant.ProductID = "v2", "prod_lifetime" + + expiredSubscription := SubscriptionSource{ + InstanceID: "sub_1", PurchaseLineageID: "lin_1", Grants: []GrantVersion{grant}, + Snapshot: SubscriptionSnapshot{ + AccessState: AccessInactive, LifecycleState: LifecycleExpired, + PeriodEndAt: ptr("2026-02-01T00:00:00Z"), UncertaintyReason: UncertaintyNone, + }, + } + lifetime := OneTimeSource{ + InstanceID: "one_1", PurchaseLineageID: "lin_2", Grants: []GrantVersion{lifetimeGrant}, + Snapshot: OneTimeSnapshot{ + ValidityState: OwnershipOwned, AcquiredAt: at("2026-01-01T00:00:00Z"), + UncertaintyReason: UncertaintyNone, + }, + } + + snapshot := ProjectEntitlements(CustomerProjection{ + Subscriptions: []SubscriptionSource{expiredSubscription}, + OneTimes: []OneTimeSource{lifetime}, + }, at("2026-03-01T00:00:00Z")) + + if len(snapshot.Entries) != 1 { + t.Fatalf("got %d entries, want one aggregated Entitlement", len(snapshot.Entries)) + } + entry := snapshot.Entries[0] + if entry.State != AccessActive { + t.Fatalf("entitlement %q, want active while the lifetime source holds", entry.State) + } + if entry.EffectiveEnd != nil || entry.EndKnown { + t.Fatalf("permanent source reported a finite expiry: end=%v endKnown=%v", + entry.EffectiveEnd, entry.EndKnown) + } + if entry.SourceCount != 2 { + t.Fatalf("source count %d, want both contributing sources preserved", entry.SourceCount) + } +} + +// A frozen lineage (open identity conflict) must not grant access to either +// candidate, and must report unknown rather than inactive. +// +// This runs through Compute rather than calling ProjectEntitlements with a +// pre-built unknown source, because the real path skips frozen lineages +// entirely — and the failure being guarded against is not "reports inactive", +// it is "reports nothing at all". A snapshot with no entry for `pro` reads to +// every consumer as a customer who never had it, which is a definite answer +// about a customer whose identity is precisely what is in dispute. +func TestFrozenLineageYieldsUnknownNotInactive(t *testing.T) { + grants := []GrantVersion{{ID: "v1", ProductID: "prod_pro", EntitlementID: "ent_pro", + EntitlementKey: "pro", Version: 1, EffectiveStart: at("2025-01-01T00:00:00Z"), + SupportedPurchaseTypes: []string{"auto_renewable_subscription"}, Policy: DefaultPolicy()}} + + output := Compute(Input{ + Scope: Scope{ProjectID: "proj", EnvironmentID: "env", CustomerID: "cust"}, + Lineages: []LineageInput{{ + LineageID: "lin_1", InstanceID: "sub_1", Type: "subscription", + Facts: []Fact{purchase("t1", "2026-01-01T00:00:00Z", "2026-06-01T00:00:00Z")}, + Frozen: true, + CustomerResolved: true, + }}, + GrantVersions: grants, + }, at("2026-03-01T00:00:00Z")) + + if output.CustomerSnapshot == nil { + t.Fatal("a frozen lineage produced no customer snapshot at all") + } + entries := output.CustomerSnapshot.Entries + if len(entries) != 1 { + t.Fatalf("got %d entries, want one unknown entry for the disputed Entitlement", len(entries)) + } + if entries[0].State != AccessUnknown { + t.Fatalf("frozen lineage produced %q, want unknown", entries[0].State) + } + if entries[0].UncertaintyReason == UncertaintyNone { + t.Fatal("unknown state carried no uncertainty reason") + } + // A frozen lineage must not advance its checkpoint: it was never projected. + if len(output.Checkpoints) != 0 { + t.Fatalf("frozen lineage advanced %d checkpoints, want none", len(output.Checkpoints)) + } +} + +// A Google purchase-token handover inside one root-keyed lineage must not +// terminate the subscription. The supersession fact is emitted for every plan +// change, upgrade, downgrade, and resubscribe on Play; reading it as "this +// lineage was replaced" made the live successor inactive, which is a paying +// customer losing access the moment they change plan. +func TestSupersessionInsideOneChainDoesNotEndAccess(t *testing.T) { + facts := []Fact{ + purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + { + // The once-per-lineage edge fact the Google validator emits when it + // first observes linkedPurchaseToken. + ID: "t2", Provider: "google_play", ProviderTransactionID: "token:abc", + FactKind: "purchase_superseded", OccurredAt: at("2026-02-01T00:00:00Z"), + RecordedAt: at("2026-02-01T00:00:00Z"), ProviderEventOccurredAt: ptr("2026-02-01T00:00:00Z"), + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + }, + // The successor token's own state fact, loaded into the same lineage by + // the chain walk. + renewal("t3", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"), + } + + live := ProjectSubscription(facts, at("2026-02-15T00:00:00Z"), DefaultPolicy(), false) + if live.Snapshot.AccessState != AccessActive { + t.Fatalf("successor after an intra-chain handover got %q/%q, want active", + live.Snapshot.AccessState, live.Snapshot.LifecycleState) + } + if live.Snapshot.LifecycleState == LifecycleSuperseded { + t.Fatal("an intra-chain token handover was reported as lineage supersession") + } + + // A genuine cross-lineage replacement still terminates, and it arrives as a + // property of the lineage rather than of any fact. + replaced := ProjectSubscription(facts, at("2026-02-15T00:00:00Z"), DefaultPolicy(), true) + if replaced.Snapshot.LifecycleState != LifecycleSuperseded || replaced.Snapshot.AccessState != AccessInactive { + t.Fatalf("replaced lineage got %q/%q, want inactive/superseded", + replaced.Snapshot.AccessState, replaced.Snapshot.LifecycleState) + } +} + +// Two grant versions on one Product may hold different policies. Access has to +// be decided per grant version, or a Project that opted one Entitlement out of +// grace still grants it whenever some other Entitlement on the same Product +// opted in. +func TestPerGrantPolicyIsNotCollapsed(t *testing.T) { + permissive := GrantVersion{ID: "v1", ProductID: "prod_pro", EntitlementID: "ent_pro", + EntitlementKey: "pro", Policy: DefaultPolicy()} + strict := permissive + strict.ID, strict.EntitlementID, strict.EntitlementKey = "v2", "ent_beta", "beta" + strict.Policy.GrantsInGrace = false + + snapshot := ProjectEntitlements(CustomerProjection{ + Subscriptions: []SubscriptionSource{{ + InstanceID: "sub_1", PurchaseLineageID: "lin_1", + Grants: []GrantVersion{permissive, strict}, + Snapshot: SubscriptionSnapshot{ + AccessState: AccessActive, LifecycleState: LifecycleGracePeriod, + GracePeriodEndAt: ptr("2026-03-15T00:00:00Z"), UncertaintyReason: UncertaintyNone, + }, + }}, + }, at("2026-03-01T00:00:00Z")) + + states := map[string]string{} + for _, entry := range snapshot.Entries { + states[entry.EntitlementKey] = entry.State + } + if states["pro"] != AccessActive { + t.Fatalf("grant that grants in grace produced %q, want active", states["pro"]) + } + if states["beta"] != AccessInactive { + t.Fatalf("grant that opted out of grace produced %q, want inactive", states["beta"]) + } +} + +// A fact that re-resolves to the Product the lineage already had must clear the +// unresolved reading. Unresolved facts carry a NULL Product, so a rule that +// only cleared on a *different* Product never fired for the overwhelmingly +// common case — the mapping was fixed, the lineage revalidated, and the +// customer stayed `unknown` forever. +func TestResolutionToTheSameProductClearsUnresolved(t *testing.T) { + unresolved := purchase("t1", "2026-01-01T00:00:00Z", "2026-06-01T00:00:00Z") + unresolved.MosaicProductID, unresolved.ResolutionState = "", "unresolved" + + stuck := ProjectSubscription([]Fact{unresolved}, at("2026-02-01T00:00:00Z"), DefaultPolicy(), false) + if stuck.Snapshot.UncertaintyReason != UncertaintyProductUnresolved { + t.Fatalf("unresolved fact got %q, want product_unresolved", stuck.Snapshot.UncertaintyReason) + } + + repaired := purchase("t2", "2026-01-01T00:00:00Z", "2026-06-01T00:00:00Z") + repaired.FactKind = "renewal" + recovered := ProjectSubscription([]Fact{unresolved, repaired}, at("2026-02-01T00:00:00Z"), DefaultPolicy(), false) + if recovered.Snapshot.AccessState != AccessActive { + t.Fatalf("re-resolution to the same Product left the lineage %q/%q, want active", + recovered.Snapshot.AccessState, recovered.Snapshot.UncertaintyReason) + } +} + +// A cancellation changes the subscription — renewal intent flips and the +// cancellation instant is recorded — while changing nothing a reader of the +// Entitlement can observe: the customer keeps `pro` with the same expiry. +// The subscription snapshot must still commit, and the customer snapshot +// version must not move, or every SDK cache in the Project is invalidated for a +// change no reader can see. +func TestSubscriptionOnlyChangeDoesNotAdvanceSnapshotVersion(t *testing.T) { + grants := []GrantVersion{{ID: "v1", ProductID: "prod_pro", EntitlementID: "ent_pro", + EntitlementKey: "pro", Version: 1, EffectiveStart: at("2025-01-01T00:00:00Z"), + SupportedPurchaseTypes: []string{"auto_renewable_subscription"}, Policy: DefaultPolicy()}} + scope := Scope{ProjectID: "proj", EnvironmentID: "env", CustomerID: "cust"} + + first := Compute(Input{ + Scope: scope, + Lineages: []LineageInput{{LineageID: "lin_1", InstanceID: "sub_1", Type: "subscription", CustomerResolved: true, Facts: []Fact{purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z")}}}, + GrantVersions: grants, + }, at("2026-01-15T00:00:00Z")) + if first.CustomerSnapshot == nil { + t.Fatal("the first projection minted no customer snapshot") + } + + second := Compute(Input{ + Scope: scope, + Lineages: []LineageInput{{LineageID: "lin_1", InstanceID: "sub_1", Type: "subscription", CustomerResolved: true, Facts: []Fact{ + purchase("t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + { + ID: "t2", Provider: "app_store", ProviderTransactionID: "t1", + FactKind: "cancellation_scheduled", OccurredAt: at("2026-01-10T00:00:00Z"), + RecordedAt: at("2026-01-10T00:00:00Z"), ProviderEventOccurredAt: ptr("2026-01-10T00:00:00Z"), + MosaicProductID: "prod_pro", ResolutionState: "active_mapping", + RenewalExpected: boolPtr(false), + }, + }}}, + GrantVersions: grants, + PriorCustomerSnapshot: first.CustomerSnapshot, + CurrentSnapshotVersion: 1, + }, at("2026-01-15T00:00:00Z")) + + if !second.Changes.NoChange { + t.Fatalf("a cancellation was reported as an entitlement change: %+v", second.Changes.Changed) + } + if second.CustomerSnapshot != nil { + t.Fatal("a no-change projection minted a customer snapshot") + } + if second.SnapshotVersion != 0 { + t.Fatalf("a no-change projection advanced the snapshot version to %d", second.SnapshotVersion) + } + if len(second.Subscriptions) == 0 { + t.Fatal("the subscription snapshot was not committed; the period change would be lost") + } +} + +// A projection that produces identical state must be recognised as no-change, +// otherwise every reprojection emits a webhook and every SDK refetches. +func TestNoChangeProjectionIsDetected(t *testing.T) { + build := func() CustomerSnapshot { + return ProjectEntitlements(CustomerProjection{ + OneTimes: []OneTimeSource{{ + InstanceID: "one_1", PurchaseLineageID: "lin_1", + Grants: []GrantVersion{{ID: "v1", ProductID: "prod_lifetime", + EntitlementID: "ent_pro", EntitlementKey: "pro", Policy: DefaultPolicy()}}, + Snapshot: OneTimeSnapshot{ValidityState: OwnershipOwned, + AcquiredAt: at("2026-01-01T00:00:00Z"), UncertaintyReason: UncertaintyNone}, + }}, + }, at("2026-03-01T00:00:00Z")) + } + prior := build() + // A later evaluation instant must not, by itself, be a change. + candidate := build() + candidate.AsOf = at("2026-04-01T00:00:00Z") + + if changes := Diff(&prior, candidate); !changes.NoChange || len(changes.Changed) != 0 { + t.Fatalf("identical state reported as changed: %+v", changes) + } +} diff --git a/apps/api/internal/billingprojection/replay.go b/apps/api/internal/billingprojection/replay.go new file mode 100644 index 00000000..d0e8e3db --- /dev/null +++ b/apps/api/internal/billingprojection/replay.go @@ -0,0 +1,133 @@ +package billingprojection + +import ( + "context" + "time" +) + +// Replay recomputes projections from facts, ignoring checkpoints, and reports +// what would change. +// +// Replay exists because projections are rebuildable (principle 2): if a +// checkpoint is corrupt, a rule version is promoted, or a mapping repair +// changes what a historical fact means, the answer is to recompute from the +// immutable facts rather than to patch derived state. +// +// Provider asymmetry, stated rather than hidden: Apple replay is +// input-sourced, because a stored Apple payload re-validates to the same +// transaction. Google replay is fact-sourced, because Google validation +// re-queries live provider state and a re-query today does not reproduce what +// the provider said last month. A Google replay therefore replays the facts +// Mosaic recorded, not the provider's current answer. +// +// Materialization is changes-only and has no switch. Plan §12 names +// `changes_only` as the *default*; there is no second mode, because the +// projection command mints a customer snapshot only when the recomputed +// checksum differs from the committed one, and a replay reuses that command +// rather than owning a second write path. A `ChangesOnly` field was previously +// declared here and never read (review finding I-12); it has been removed +// rather than left as a parameter that lies about being adjustable. Restoring +// the alternative would mean a "materialize regardless" write path, which is +// churn every SDK cache in the Project for no observable change. +type Replay struct { + // RuleVersion selects the projection semantics to replay under. Zero means + // the active version. A version this build does not derive under is refused + // with ErrUnsupportedRuleVersion rather than approximated by the active + // engine — see ImplementedRuleVersions. + RuleVersion int +} + +// ReplayScope bounds one replay. Exactly one of the three is set; a replay +// with no bound is not a replay, it is a migration, and bulk migration tooling +// stays out of this phase. +type ReplayScope struct { + SubscriptionInstanceID string + CustomerID string + // ProjectWindow replays every scope in a Project whose facts fall inside + // the window. It is bounded in time deliberately. + ProjectID string + // WindowStart and WindowEnd bound the replay on *facts*, not on lineage + // creation: a scope is in scope when it holds at least one fact whose + // effective or recorded time falls inside the window. Bounding on + // `purchase_lineages.created_at` (review finding I-12) selected lineages + // that were first seen in the window and silently skipped every long-lived + // lineage that received a fact in it — which is exactly the population a + // "replay last Tuesday" is asked about. + WindowStart *time.Time + WindowEnd *time.Time +} + +// ReplayResult reports one replayed scope. +type ReplayResult struct { + ScopeKey string + // Comparison is `unchanged` when the recomputed checksum equals the + // committed one, `changed` otherwise. It is the whole point of a replay: + // proving determinism, or naming exactly what a rule change moved. + Comparison string + // Materialized reports whether a new snapshot was actually written. + Materialized bool + Changed []string +} + +// Comparison outcomes. +const ( + ComparisonUnchanged = "unchanged" + ComparisonChanged = "changed" +) + +// ReplayScopeKeys is the port a replay uses to enumerate the scopes it will +// recompute. It is separate from Repository because a replay reads a different +// shape than a projection does and must not be able to reach the commit path +// except through Project. +type ReplayScopeKeys interface { + ScopesForReplay(ctx context.Context, scope ReplayScope, limit int) ([]Scope, error) +} + +// RunReplay recomputes the scopes a replay names and reports the comparison +// for each. It reuses the ordinary projection command, so replayed state goes +// through exactly the same lock, compare-and-swap, and atomic commit as live +// projection — there is no second write path that could diverge. +// +// Prior snapshots are never deleted. A replay that changes state appends a new +// snapshot version; the history that preceded it stays readable. +func (s *Service) RunReplay(ctx context.Context, keys ReplayScopeKeys, replay Replay, scope ReplayScope, limit int) ([]ReplayResult, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + if !RuleVersionImplemented(replay.RuleVersion) { + // Refused before any scope is enumerated: recomputing under the active + // engine and labelling the result with the requested version would make + // a replay's checksum comparison meaningless. + return nil, ErrUnsupportedRuleVersion + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil && scope.ProjectID != "" { + return nil, err + } + scopes, err := keys.ScopesForReplay(ctx, scope, limit) + if err != nil { + return nil, ErrUnavailable + } + + results := make([]ReplayResult, 0, len(scopes)) + for _, target := range scopes { + output, err := s.ProjectUnder(ctx, target, "", replay.RuleVersion) + if err != nil { + // One failed scope does not abandon the run: a replay is a + // diagnostic operation and a partial answer is more useful than + // none, as long as the failure is visible. + results = append(results, ReplayResult{ScopeKey: target.Key(), Comparison: ComparisonChanged}) + continue + } + result := ReplayResult{ + ScopeKey: target.Key(), + Comparison: ComparisonUnchanged, + Materialized: output.CustomerSnapshot != nil, + Changed: output.Changes.Changed, + } + if output.Outcome == OutcomeProjected { + result.Comparison = ComparisonChanged + } + results = append(results, result) + } + return results, nil +} diff --git a/apps/api/internal/billingprojection/repository.go b/apps/api/internal/billingprojection/repository.go new file mode 100644 index 00000000..62358607 --- /dev/null +++ b/apps/api/internal/billingprojection/repository.go @@ -0,0 +1,301 @@ +package billingprojection + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "sort" + "strconv" + "time" +) + +// Stable domain errors. +var ( + // ErrBillingDisabled maps to `unavailable` on every entitlement surface, + // never to `inactive`. + ErrBillingDisabled = errors.New("billing is not enabled for this Project") + ErrNotFound = errors.New("projection scope not found") + ErrUnavailable = errors.New("projection storage is unavailable") + // ErrVersionConflict is the compare-and-swap losing. It is not a failure: + // another worker committed a newer projection for the same scope, so the + // job retries and reads the newer state. + ErrVersionConflict = errors.New("projection version changed underneath this transaction") + // ErrUnsupportedRuleVersion is a request to project under rule semantics + // this build does not implement (review finding I-12). + ErrUnsupportedRuleVersion = errors.New("projection rule version is not implemented by this build") +) + +// Scope names what one projection command covers. +type Scope struct { + ProjectID string + EnvironmentID string + // CustomerID is the usual scope: entitlements aggregate per customer, so + // serializing per customer is what makes the aggregate consistent. + CustomerID string + // LineageID is used when facts arrived for a lineage that has no resolved + // customer yet — there is a subscription to project but no aggregate to + // recompute. + LineageID string +} + +// Key is the scope key used for advisory locking, job coalescing, and +// idempotency. Customer scope wins when both are present, because a customer +// projection subsumes the lineage projections it reads. +func (s Scope) Key() string { + if s.CustomerID != "" { + return "customer:" + s.CustomerID + } + return "lineage:" + s.LineageID +} + +// LockScope is the advisory-lock name, following the accepted LockScope +// pattern already used by hosted publishing and cloud workspace. +func (s Scope) LockScope() string { return "billing-projection:" + s.Key() } + +// Input is everything one projection command needs, loaded inside the +// transaction after the lock is held so it cannot be stale. +type Input struct { + Scope Scope + // Lineages are the purchase lineages in scope with their ordered facts. + Lineages []LineageInput + // GrantVersions are every grant version for the Products involved. The + // engine selects the applicable ones by period effective time. + GrantVersions []GrantVersion + // PriorCustomerSnapshot is the last committed customer snapshot, used for + // the change set and the no-change decision. + PriorCustomerSnapshot *CustomerSnapshot + // CurrentProjectionVersion is the value the compare-and-swap is against. + CurrentProjectionVersion int64 + // CurrentSnapshotVersion is the monotonic per-(customer, environment) + // version the next snapshot increments. + CurrentSnapshotVersion int64 + // EscalateToCustomerID is set only on a lineage-scoped load, and only when + // the lineage turns out to have an accepted customer association. + // + // A lineage scope exists for lineages that have no customer yet. One can + // acquire a customer between the job being queued and the job being run — + // the association resolver attaching it, or an operator resolving a + // conflict. When that has happened the subscription state is still worth + // advancing, but the customer aggregate is the only thing that can mint a + // snapshot, and no lineage-scoped command may ever mint one (defect D-4). So + // the command escalates: it enqueues customer scope, which coalesces onto + // any customer job already waiting. + EscalateToCustomerID string + // UnresolvedLineages and FrozenLineages carry the identity state that + // keeps an Entitlement at `unknown` rather than `inactive`. + UnresolvedLineages int + FrozenLineages int + // RuleVersion selects the projection semantics this command derives under. + // Zero means the active version, which is what every live trigger uses; a + // replay is the only caller that sets it (plan §12, review finding I-12). + RuleVersion int +} + +// LineageInput is one lineage and the facts that belong to it. +type LineageInput struct { + LineageID string + InstanceID string + // SnapshotID is the current committed subscription snapshot, carried so a + // no-change lineage can still be cited as an entitlement source. + SnapshotID string + Type string + Facts []Fact + // Checkpoint is the last committed high watermark for this lineage. + Checkpoint string + // CheckpointChecksum is the checksum recorded with that checkpoint. + CheckpointChecksum []byte + // CheckpointFacts is how many facts that checkpoint covered. It is what + // makes "does the checkpoint still describe a prefix of this timeline?" an + // answerable question rather than a guess. + CheckpointFacts int64 + // Frozen lineages are skipped: their last committed state is preserved. + Frozen bool + // CustomerResolved is false when no accepted association exists yet. + CustomerResolved bool + // SupersededByLineage is the explicit `superseded_by_lineage_id` edge: this + // lineage was replaced by a *different* lineage. It is deliberately not + // derived from any fact — a supersession fact inside a Google purchase-token + // chain is a token handover within one root-keyed lineage, and reading it as + // a lineage replacement projected every live successor as inactive. + SupersededByLineage bool +} + +// Output is one projection command's complete result. Everything in it is +// written in a single transaction or none of it is. +type Output struct { + Scope Scope + // Subscriptions and OneTimes are the per-lineage results whose state + // actually changed. A lineage whose checksum matched contributes an + // entitlement source but no new snapshot. + Subscriptions []SubscriptionCommit + OneTimes []OneTimeCommit + // CustomerSnapshot is nil for a no-change projection. + CustomerSnapshot *CustomerSnapshot + SnapshotVersion int64 + Changes ChangeSet + // Event is the planned Billing State Webhook announcement. It is nil for a + // no-change projection, which is what makes "a no-change replay emits + // nothing" a property of the plan rather than of the writer. + Event *Event + // Checkpoints advance even on a no-change projection: the facts were + // examined and must not be examined again. + Checkpoints []CheckpointCommit + // IdempotencyKey is digest(scope, high watermark, rule version, grant + // version set), so a repeated execution of the same command is + // recognisable as such. + IdempotencyKey []byte + Outcome string +} + +// SubscriptionCommit is one new subscription snapshot to write. +type SubscriptionCommit struct { + LineageID string + InstanceID string + ProjectionVersion int64 + Snapshot SubscriptionSnapshot + Timeline []TimelineEntry +} + +// OneTimeCommit is one new one-time purchase state to write. +type OneTimeCommit struct { + LineageID string + InstanceID string + ProjectionVersion int64 + Snapshot OneTimeSnapshot + Timeline []TimelineEntry +} + +// CheckpointCommit advances one lineage's projection checkpoint. +type CheckpointCommit struct { + LineageID string + InstanceID string + Type string + HighWatermark string + FactsProjected int64 + Checksum []byte + Invalidated bool +} + +// Projection outcomes recorded on every attempt. +const ( + OutcomeProjected = "projected" + OutcomeNoChange = "no_change" + OutcomeUnresolved = "unresolved" + OutcomeFrozen = "frozen" + OutcomeFailed = "failed" +) + +// Repository is the persistence port. The commit method takes the whole +// Output because the consistency model requires one atomic write: a consumer +// must never see a new subscription state without its matching entitlement +// state, or a pointer to an incomplete snapshot. +type Repository interface { + BillingEnabled(ctx context.Context, projectID string) (bool, error) + + // LoadInput acquires the scope's advisory lock and reads everything the + // command needs inside one transaction, so the projection sees a + // consistent view and no concurrent projection for the same scope can + // interleave. + LoadInput(ctx context.Context, scope Scope) (Input, error) + + // Commit writes snapshots, timeline entries, entitlement sources, the + // customer snapshot, both current pointers, checkpoints, webhook events, + // and the audit event in one transaction, guarded by a compare-and-swap on + // the customer's current_projection_version. It returns ErrVersionConflict + // when the CAS loses. + Commit(ctx context.Context, input Input, output Output, now time.Time) error + + // RecordAttempt records the outcome of one execution, including no-change + // and failed runs, outside the projection transaction so a rolled-back + // projection still leaves an observable trace. + RecordAttempt(ctx context.Context, scope Scope, jobID string, output Output, errorCode string, started, completed time.Time) error + + // Enqueue coalesces a projection trigger onto the scope key. A scope that + // already has queued or leased work absorbs the trigger rather than + // creating a second job. + Enqueue(ctx context.Context, scope Scope, kind string, now time.Time) error + LeaseJob(ctx context.Context, workerID string, now, leaseUntil time.Time) (Job, bool, error) + CompleteJob(ctx context.Context, job Job, status, errorCode string, availableAt time.Time, now time.Time) error +} + +// Job is one leased unit of projection work. +type Job struct { + ID string + ProjectID string + EnvironmentID string + ScopeKey string + Kind string + CustomerID string + LineageID string + AttemptCount int + MaxAttempts int +} + +// Scope reconstructs the projection scope a job names. +// +// A customer scope never carries a lineage (defect D-4). A customer snapshot is +// computed from every lineage the customer holds, so a customer-scoped command +// that also named one lineage would recompute the whole aggregate from a single +// source and silently drop the rest. The rule is enforced here as well as at +// every enqueue site, because this is the one place every queued job — including +// rows written before the fix — passes through. +func (j Job) Scope() Scope { + if j.CustomerID != "" { + return Scope{ + ProjectID: j.ProjectID, EnvironmentID: j.EnvironmentID, + CustomerID: j.CustomerID, + } + } + return Scope{ + ProjectID: j.ProjectID, EnvironmentID: j.EnvironmentID, + LineageID: j.LineageID, + } +} + +// Job kinds. Each is a documented trigger from plan §12. +const ( + KindFactCommitted = "fact_committed" + KindAssociationEstablished = "association_established" + KindQuarantineRepair = "quarantine_repair" + KindGrantVersionPublished = "grant_version_published" + KindRulePromotion = "rule_promotion" + KindReconciliationDiscovery = "reconciliation_discovery" + KindReplay = "replay" + KindManualSync = "manual_sync" +) + +// IdempotencyKey is digest(scope, high-watermark position, rule version, +// grant version set) — plan §9. +// +// It answers "have I already done exactly this work?" without comparing +// output, which matters because the answer must be available before the work +// is done. The grant version set participates because the same facts under a +// different grant version are a genuinely different projection. +func IdempotencyKey(scope Scope, watermarks []string, grantVersionIDs []string) []byte { + hasher := sha256.New() + hasher.Write([]byte("mosaic-projection-command-v1")) + write := func(value string) { + hasher.Write([]byte{0}) + hasher.Write([]byte(value)) + } + write(scope.Key()) + write(strconv.Itoa(RuleVersion)) + write(strconv.Itoa(OrderingVersion)) + + sortedWatermarks := append([]string(nil), watermarks...) + sort.Strings(sortedWatermarks) + for _, watermark := range sortedWatermarks { + write(watermark) + } + sortedGrants := append([]string(nil), grantVersionIDs...) + sort.Strings(sortedGrants) + for _, grantVersionID := range sortedGrants { + write(grantVersionID) + } + return hasher.Sum(nil) +} + +// HexKey renders an idempotency key for diagnostics. It is safe to log: it is +// derived from identifiers and versions, never from a customer value. +func HexKey(key []byte) string { return hex.EncodeToString(key) } diff --git a/apps/api/internal/billingprojection/service.go b/apps/api/internal/billingprojection/service.go new file mode 100644 index 00000000..35e3a450 --- /dev/null +++ b/apps/api/internal/billingprojection/service.go @@ -0,0 +1,471 @@ +package billingprojection + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/jobtelemetry" +) + +// projectionLease bounds how long one worker may hold a projection job. It is +// deliberately short: the transaction makes no external calls, so a job that +// has been held for minutes is a stuck worker, not slow work. +const projectionLease = 60 * time.Second + +// Service is the projection application service. It owns the transaction +// boundary, the lock, the compare-and-swap, and the job loop. The engines it +// calls stay pure. +type Service struct { + repository Repository + now func() time.Time + tracer trace.Tracer + + projections metric.Int64Counter + projectionCost metric.Float64Histogram +} + +type Option func(*Service) + +func WithClock(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +func NewService(repository Repository, options ...Option) *Service { + meter := otel.Meter("mosaic/billingprojection") + service := &Service{ + repository: repository, + now: func() time.Time { return time.Now().UTC() }, + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/billingprojection"), + } + service.projections, _ = meter.Int64Counter("mosaic.billing.projection.outcomes") + service.projectionCost, _ = meter.Float64Histogram("mosaic.billing.projection.latency", + metric.WithUnit("ms")) + for _, option := range options { + option(service) + } + return service +} + +// Enqueue coalesces a projection trigger. Every trigger in plan §12 routes +// through here, and the scope-key partial unique index absorbs duplicates, so +// a burst of facts for one customer produces one projection rather than a job +// storm. +func (s *Service) Enqueue(ctx context.Context, scope Scope, kind string) error { + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return err + } + return s.repository.Enqueue(ctx, scope, kind, s.now()) +} + +// ProcessNextProjection leases and runs one projection job. It matches the +// (processed, error) contract every other Mosaic job family uses. +func (s *Service) ProcessNextProjection(ctx context.Context, workerID string) (bool, error) { + now := s.now() + job, leased, err := s.repository.LeaseJob(ctx, workerID, now, now.Add(projectionLease)) + if err != nil { + return false, fmt.Errorf("lease projection job: %w", err) + } + if !leased { + return false, nil + } + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: job.ID, JobKind: "billing_projection", + ProjectID: job.ProjectID, EnvironmentID: job.EnvironmentID, ResourceID: job.ScopeKey, + }) + + // A Project that turned billing off projects nothing. The job is parked + // rather than failed: disabling is reversible, and failing would need an + // operator action to recover work that only ever needed to wait. + if err := s.requireEnabled(ctx, job.ProjectID); err != nil { + return true, s.repository.CompleteJob(ctx, job, "queued", "billing_disabled", + s.now().Add(5*time.Minute), s.now()) + } + + output, runErr := s.Project(ctx, job.Scope(), job.ID) + completed := s.now() + switch { + case runErr != nil && errors.Is(runErr, ErrVersionConflict): + // Another worker committed newer state for this scope. Requeue + // promptly: the work is still wanted, just against fresher input. + return true, s.repository.CompleteJob(ctx, job, "queued", "version_conflict", completed, completed) + case runErr != nil: + status := "queued" + if job.AttemptCount >= job.MaxAttempts { + status = "failed" + } + return true, s.repository.CompleteJob(ctx, job, status, "projection_failed", + completed.Add(backoff(job.AttemptCount)), completed) + default: + s.projections.Add(ctx, 1, metric.WithAttributes(attribute.String("outcome", output.Outcome))) + return true, s.repository.CompleteJob(ctx, job, "completed", "", completed, completed) + } +} + +// Project runs one projection command end to end. +// +// The sequence is the accepted one (WP11): acquire the scope's advisory lock, +// re-read the current version inside the transaction, load the facts, project +// with the pure engines, and commit everything atomically under a +// compare-and-swap. No external call happens inside the transaction, and the +// lock is never held across network I/O. +// +// A no-change projection is a first-class outcome, not a degenerate one: no +// snapshot is written, no webhook event is created, the checkpoint still +// advances, and the attempt is recorded. Without that, every reprojection +// would churn every SDK cache in the Project. +func (s *Service) Project(ctx context.Context, scope Scope, jobID string) (Output, error) { + return s.ProjectUnder(ctx, scope, jobID, ActiveRuleVersion) +} + +// ProjectUnder runs one projection command under a selected rule version. Every +// live trigger uses the active version through Project; a replay is the only +// caller that selects (plan §12, review finding I-12). +// +// A rule version this build does not derive under is refused before any work +// begins, rather than silently recomputed under the active semantics. +func (s *Service) ProjectUnder(ctx context.Context, scope Scope, jobID string, ruleVersion int) (Output, error) { + ctx, span := s.tracer.Start(ctx, "billing.projection.commit") + defer span.End() + span.SetAttributes( + attribute.String("mosaic.billing.projection.scope", scope.Key()), + attribute.Int("mosaic.billing.projection.rule_version", ResolveRuleVersion(ruleVersion))) + + started := s.now() + if !RuleVersionImplemented(ruleVersion) { + return Output{}, ErrUnsupportedRuleVersion + } + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return Output{}, err + } + + input, err := s.repository.LoadInput(ctx, scope) + if err != nil { + return Output{}, err + } + input.RuleVersion = ruleVersion + + output := Compute(input, s.now()) + commitErr := s.repository.Commit(ctx, input, output, s.now()) + completed := s.now() + s.projectionCost.Record(ctx, float64(completed.Sub(started).Milliseconds())) + + errorCode := "" + if commitErr != nil { + output.Outcome = OutcomeFailed + errorCode = "commit_failed" + if errors.Is(commitErr, ErrVersionConflict) { + errorCode = "version_conflict" + } + } + // The attempt is recorded outside the projection transaction, so a + // rolled-back projection still leaves an observable trace of having run. + if err := s.repository.RecordAttempt(ctx, scope, jobID, output, errorCode, started, completed); err != nil { + zerolog.Ctx(ctx).Error(). + Str("projection_scope", scope.Key()). + Str("attempt_error_kind", fmt.Sprintf("%T", err)). + Msg("projection attempt could not be recorded") + } + if commitErr != nil { + return output, commitErr + } + // A lineage-scoped command never mints a customer snapshot (defect D-4). If + // the lineage has acquired a customer, the aggregate that *can* mint one is + // enqueued instead of being derived here from a single lineage. The failure + // is returned rather than logged: the projection itself is idempotent, so a + // retry costs a no-change pass, whereas a dropped escalation leaves the + // customer's committed snapshot missing a purchase they hold. + if scope.CustomerID == "" && input.EscalateToCustomerID != "" { + escalated := Scope{ + ProjectID: scope.ProjectID, EnvironmentID: scope.EnvironmentID, + CustomerID: input.EscalateToCustomerID, + } + if err := s.repository.Enqueue(ctx, escalated, KindAssociationEstablished, s.now()); err != nil { + return output, fmt.Errorf("escalate lineage projection to customer scope: %w", err) + } + } + span.SetAttributes( + attribute.String("mosaic.billing.projection.outcome", output.Outcome), + attribute.Bool("mosaic.billing.projection.changed", output.CustomerSnapshot != nil)) + return output, nil +} + +// Compute is the pure planning step: given a loaded input and an evaluation +// instant, it decides everything that will be written. It is separated from +// Project so the decision logic is testable without a database and so the +// transaction body contains no branching over provider semantics. +func Compute(input Input, asOf time.Time) Output { + asOf = asOf.UTC() + output := Output{Scope: input.Scope, Outcome: OutcomeNoChange} + + if !RuleVersionImplemented(input.RuleVersion) { + // Review finding I-12: derivation under semantics this build does not + // implement decides nothing. The command produces an empty plan, so + // there is no snapshot, no checkpoint, and no event to commit — the + // caller's refusal is the primary guard and this is the structural one. + output.Outcome = OutcomeFailed + return output + } + + subscriptionSources := make([]SubscriptionSource, 0, len(input.Lineages)) + oneTimeSources := make([]OneTimeSource, 0, len(input.Lineages)) + watermarks := make([]string, 0, len(input.Lineages)) + grantVersionIDs := make([]string, 0, len(input.GrantVersions)) + + unresolved, frozen := input.UnresolvedLineages, input.FrozenLineages + + for _, lineage := range input.Lineages { + if lineage.Frozen || !lineage.CustomerResolved { + // A frozen lineage keeps its last committed state (OD-10) and an + // unresolved one has no customer to attach to. Neither is projected + // and neither advances a checkpoint — projecting either would grant + // access on a disputed or absent identity. + // + // They do, however, still name the Entitlements in question, and + // those are emitted as `unknown` sources. Skipping them entirely + // produced a customer snapshot with no entry at all for the + // Entitlement, and an absent entry reads to every consumer as "this + // customer never had it" — which is the definite answer the whole + // uncertainty vocabulary exists to avoid asserting. + if lineage.Frozen { + frozen++ + } else { + unresolved++ + } + collectUndecided(input, lineage, asOf, &subscriptionSources, &oneTimeSources) + continue + } + + ordered := Sort(append([]Fact(nil), lineage.Facts...)) + watermark := HighWatermark(ordered) + watermarks = append(watermarks, watermark) + // The checkpoint is invalidated when it no longer describes a prefix of + // the timeline — a fact arrived that belongs earlier than the watermark. + invalidated := !PrefixIntact(ordered, lineage.Checkpoint, lineage.CheckpointFacts) + + if lineage.Type == "one_time" { + result := ProjectOneTimePurchase(ordered, asOf) + grants := selectGrants(input.GrantVersions, result.Snapshot.MosaicProductID, + result.Snapshot.AcquiredAt, "non_consumable") + grantVersionIDs = appendGrantIDs(grantVersionIDs, grants) + oneTimeSources = append(oneTimeSources, OneTimeSource{ + InstanceID: lineage.InstanceID, PurchaseLineageID: lineage.LineageID, + Snapshot: result.Snapshot, Grants: grants, + }) + if !sameChecksum(lineage.CheckpointChecksum, result.Snapshot.Checksum) { + output.OneTimes = append(output.OneTimes, OneTimeCommit{ + LineageID: lineage.LineageID, InstanceID: lineage.InstanceID, + Snapshot: result.Snapshot, Timeline: result.Timeline, + }) + } + output.Checkpoints = append(output.Checkpoints, CheckpointCommit{ + LineageID: lineage.LineageID, InstanceID: lineage.InstanceID, Type: "one_time", + HighWatermark: result.HighWatermark, FactsProjected: int64(result.FactsConsumed), + Checksum: result.Snapshot.Checksum, Invalidated: invalidated, + }) + continue + } + + // The lineage is projected once under the default policy to establish + // its provider lifecycle, period, and effective timestamps — none of + // which depend on any grant. Access policy is then applied per grant + // version: re-projecting the whole lineage under grants[0] collapsed + // every Entitlement onto the first grant's policy, which made the §7 + // per-grant opt-out silently ineffective for every grant but one. + result := ProjectSubscription(ordered, asOf, DefaultPolicy(), lineage.SupersededByLineage) + periodTime := asOf + if result.Snapshot.PeriodStartAt != nil { + periodTime = *result.Snapshot.PeriodStartAt + } + grants := selectGrants(input.GrantVersions, result.Snapshot.CurrentProductID, + periodTime, "auto_renewable_subscription") + if len(grants) > 0 { + // The snapshot's own access column is the union over the grant + // versions in force. Per-Entitlement access is decided per grant + // in ProjectEntitlements. + if granted, dependent := AnyGrantsAccess(result.Snapshot.LifecycleState, grants); dependent { + if granted { + result.Snapshot.AccessState = AccessActive + } else { + result.Snapshot.AccessState = AccessInactive + } + result.Snapshot.Checksum = subscriptionChecksum(result.Snapshot) + } + } + grantVersionIDs = appendGrantIDs(grantVersionIDs, grants) + + subscriptionSources = append(subscriptionSources, SubscriptionSource{ + InstanceID: lineage.InstanceID, SnapshotID: lineage.SnapshotID, + PurchaseLineageID: lineage.LineageID, Snapshot: result.Snapshot, Grants: grants, + }) + if !sameChecksum(lineage.CheckpointChecksum, result.Snapshot.Checksum) { + output.Subscriptions = append(output.Subscriptions, SubscriptionCommit{ + LineageID: lineage.LineageID, InstanceID: lineage.InstanceID, + Snapshot: result.Snapshot, Timeline: result.Timeline, + }) + } + output.Checkpoints = append(output.Checkpoints, CheckpointCommit{ + LineageID: lineage.LineageID, InstanceID: lineage.InstanceID, Type: "subscription", + HighWatermark: result.HighWatermark, FactsProjected: int64(result.FactsConsumed), + Checksum: result.Snapshot.Checksum, Invalidated: invalidated, + }) + } + + output.IdempotencyKey = IdempotencyKey(input.Scope, watermarks, grantVersionIDs) + + if input.Scope.CustomerID == "" { + // Lineage-scoped projection: subscription state advances, but there is + // no customer aggregate to recompute yet. + if len(output.Subscriptions) > 0 || len(output.OneTimes) > 0 { + output.Outcome = OutcomeProjected + } else if unresolved > 0 { + output.Outcome = OutcomeUnresolved + } else if frozen > 0 { + output.Outcome = OutcomeFrozen + } + return output + } + + candidate := ProjectEntitlements(CustomerProjection{ + Subscriptions: subscriptionSources, + OneTimes: oneTimeSources, + UnresolvedLineages: unresolved, + FrozenLineages: frozen, + }, asOf) + output.Changes = Diff(input.PriorCustomerSnapshot, candidate) + + if output.Changes.NoChange { + // The customer's authoritative state is unchanged, so no customer + // snapshot is minted and the snapshot version does not move — even when + // a subscription snapshot did change underneath it. A renewal that + // extends a period without changing which Entitlements are held is + // exactly that case, and advancing the version for it would invalidate + // every SDK cache in the Project for a change no reader can observe. + output.Outcome = OutcomeNoChange + if len(output.Subscriptions) > 0 || len(output.OneTimes) > 0 { + output.Outcome = OutcomeProjected + } else if frozen > 0 { + output.Outcome = OutcomeFrozen + } + return output + } + + output.CustomerSnapshot = &candidate + output.SnapshotVersion = input.CurrentSnapshotVersion + 1 + output.Event = planEvent(input.PriorCustomerSnapshot, candidate, output.Changes, + subscriptionSources, asOf) + output.Outcome = OutcomeProjected + return output +} + +// collectUndecided emits `unknown` entitlement sources for a lineage that is +// frozen or has no resolved customer. It projects the lineage only far enough +// to learn which Product it names, then forces the source state to unknown: the +// facts are real, the Entitlement is real, and the only thing Mosaic cannot +// state is whether this customer holds it. +func collectUndecided(input Input, lineage LineageInput, asOf time.Time, + subscriptions *[]SubscriptionSource, oneTimes *[]OneTimeSource) { + + reason := UncertaintyIdentityUnresolved + ordered := Sort(append([]Fact(nil), lineage.Facts...)) + + if lineage.Type == "one_time" { + result := ProjectOneTimePurchase(ordered, asOf) + grants := selectGrants(input.GrantVersions, result.Snapshot.MosaicProductID, + result.Snapshot.AcquiredAt, "non_consumable") + if len(grants) == 0 { + return + } + result.Snapshot.ValidityState = OwnershipUnknown + result.Snapshot.UncertaintyReason = reason + *oneTimes = append(*oneTimes, OneTimeSource{ + InstanceID: lineage.InstanceID, PurchaseLineageID: lineage.LineageID, + Snapshot: result.Snapshot, Grants: grants, + }) + return + } + + result := ProjectSubscription(ordered, asOf, DefaultPolicy(), lineage.SupersededByLineage) + periodTime := asOf + if result.Snapshot.PeriodStartAt != nil { + periodTime = *result.Snapshot.PeriodStartAt + } + grants := selectGrants(input.GrantVersions, result.Snapshot.CurrentProductID, + periodTime, "auto_renewable_subscription") + if len(grants) == 0 { + return + } + result.Snapshot.AccessState = AccessUnknown + result.Snapshot.UncertaintyReason = reason + *subscriptions = append(*subscriptions, SubscriptionSource{ + InstanceID: lineage.InstanceID, SnapshotID: lineage.SnapshotID, + PurchaseLineageID: lineage.LineageID, Snapshot: result.Snapshot, Grants: grants, + }) +} + +func selectGrants(versions []GrantVersion, productID string, at time.Time, purchaseType string) []GrantVersion { + if productID == "" { + return nil + } + return SelectGrantVersions(versions, productID, at, purchaseType) +} + +func appendGrantIDs(ids []string, grants []GrantVersion) []string { + for _, grant := range grants { + ids = append(ids, grant.ID) + } + return ids +} + +func sameChecksum(left, right []byte) bool { + return len(left) > 0 && string(left) == string(right) +} + +// backoff bounds the retry schedule for a failed projection. +func backoff(attempt int) time.Duration { + if attempt < 1 { + attempt = 1 + } + delay := time.Duration(1< 5*time.Minute { + delay = 5 * time.Minute + } + return delay +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// requireEnabled fails closed, matching the 9A ingestion path: an unreadable +// setting is treated as disabled and reported, so a transient database error +// cannot quietly re-enable a Project that asked Mosaic to hold no billing +// state. +func (s *Service) requireEnabled(ctx context.Context, projectID string) error { + enabled, err := s.repository.BillingEnabled(ctx, projectID) + if err != nil { + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("billing_error_kind", fmt.Sprintf("%T", err)). + Msg("billing enablement could not be read; treating the Project as disabled") + return ErrBillingDisabled + } + if !enabled { + return ErrBillingDisabled + } + return nil +} diff --git a/apps/api/internal/billingprojection/service_test.go b/apps/api/internal/billingprojection/service_test.go new file mode 100644 index 00000000..2c830dbf --- /dev/null +++ b/apps/api/internal/billingprojection/service_test.go @@ -0,0 +1,320 @@ +package billingprojection + +import ( + "context" + "errors" + "testing" + "time" +) + +// The projection command is where a correct engine can still produce wrong +// persisted state: by minting a snapshot when nothing changed, by advancing a +// checkpoint it should have invalidated, by projecting a frozen lineage, or by +// treating the same command as new work on retry. These tests pin those. + +func activeLineage(id string) LineageInput { + return LineageInput{ + LineageID: id, InstanceID: "sub_" + id, Type: "subscription", + CustomerResolved: true, + Facts: []Fact{ + purchase(id+"-t1", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + renewal(id+"-t2", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"), + }, + } +} + +func proGrant() GrantVersion { + return GrantVersion{ + ID: "pegv_1", ProductID: "prod_pro", EntitlementID: "ent_pro", EntitlementKey: "pro", + Version: 1, EffectiveStart: at("2025-01-01T00:00:00Z"), Policy: DefaultPolicy(), + } +} + +// A projection whose recomputed state matches the committed state must mint no +// snapshot and emit no change. Without this, every reprojection would advance +// the snapshot version and force every SDK in the Project to refetch. +func TestNoChangeProjectionMintsNoSnapshot(t *testing.T) { + input := Input{ + Scope: Scope{ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1"}, + Lineages: []LineageInput{activeLineage("lin_1")}, + GrantVersions: []GrantVersion{proGrant()}, + CurrentSnapshotVersion: 4, + } + // Seed the lineage checkpoint with the checksum the engine will recompute, + // and the prior customer snapshot with the state it will re-derive. + first := Compute(input, at("2026-02-15T00:00:00Z")) + if first.Outcome != OutcomeProjected || first.CustomerSnapshot == nil { + t.Fatalf("first projection outcome %q, want projected with a snapshot", first.Outcome) + } + if first.SnapshotVersion != 5 { + t.Fatalf("snapshot version %d, want the prior version plus one", first.SnapshotVersion) + } + + input.Lineages[0].CheckpointChecksum = first.Checkpoints[0].Checksum + input.PriorCustomerSnapshot = first.CustomerSnapshot + second := Compute(input, at("2026-02-16T00:00:00Z")) + + if second.Outcome != OutcomeNoChange { + t.Fatalf("re-projection outcome %q, want no_change", second.Outcome) + } + if second.CustomerSnapshot != nil { + t.Fatal("a no-change projection minted a customer snapshot") + } + if len(second.Subscriptions) != 0 { + t.Fatal("a no-change projection minted a subscription snapshot") + } + // The checkpoint must still advance: those facts were examined and must + // not be examined again. + if len(second.Checkpoints) != 1 || second.Checkpoints[0].HighWatermark == "" { + t.Fatal("a no-change projection did not advance the checkpoint") + } +} + +// The idempotency key must be identical for a repeated command and different +// once the facts, rule version, or grant set move. It is what makes a retry +// after a crash recognisable as the same work. +func TestIdempotencyKeyIdentifiesTheCommand(t *testing.T) { + scope := Scope{ProjectID: "proj_1", CustomerID: "bcu_1"} + base := IdempotencyKey(scope, []string{"w2", "w1"}, []string{"g2", "g1"}) + + // Input order must not matter: the same logical command computed twice + // must key identically. + reordered := IdempotencyKey(scope, []string{"w1", "w2"}, []string{"g1", "g2"}) + if string(base) != string(reordered) { + t.Fatal("idempotency key depends on input ordering") + } + + if string(base) == string(IdempotencyKey(scope, []string{"w1", "w3"}, []string{"g1", "g2"})) { + t.Fatal("a new fact did not change the idempotency key") + } + if string(base) == string(IdempotencyKey(scope, []string{"w1", "w2"}, []string{"g1", "g3"})) { + t.Fatal("a different grant version set did not change the idempotency key") + } + if string(base) == string(IdempotencyKey(Scope{ProjectID: "proj_1", CustomerID: "bcu_2"}, + []string{"w1", "w2"}, []string{"g1", "g2"})) { + t.Fatal("two customers share an idempotency key") + } +} + +// A frozen lineage must contribute no access and must not be projected: an +// open identity conflict means Mosaic does not know whose purchase it is, and +// granting either candidate is the double-grant the freeze exists to prevent. +func TestFrozenLineageIsNotProjected(t *testing.T) { + frozen := activeLineage("lin_1") + frozen.Frozen = true + + output := Compute(Input{ + Scope: Scope{ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1"}, + Lineages: []LineageInput{frozen}, + GrantVersions: []GrantVersion{proGrant()}, + }, at("2026-02-15T00:00:00Z")) + + if len(output.Subscriptions) != 0 { + t.Fatal("a frozen lineage produced a subscription snapshot") + } + if len(output.Checkpoints) != 0 { + t.Fatal("a frozen lineage advanced its checkpoint") + } + if output.CustomerSnapshot != nil { + for _, entry := range output.CustomerSnapshot.Entries { + if entry.State == AccessActive { + t.Fatal("a frozen lineage granted access") + } + } + } +} + +// A lineage with no accepted association keeps its facts but projects to no +// customer, and its uncertainty must reach the entitlement entry as `unknown` +// rather than `inactive`. +func TestUnresolvedLineageDoesNotGrantAccess(t *testing.T) { + unresolved := activeLineage("lin_1") + unresolved.CustomerResolved = false + + output := Compute(Input{ + Scope: Scope{ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1"}, + Lineages: []LineageInput{unresolved}, + GrantVersions: []GrantVersion{proGrant()}, + }, at("2026-02-15T00:00:00Z")) + + if len(output.Subscriptions) != 0 { + t.Fatal("an unresolved lineage was projected to a customer") + } + if output.CustomerSnapshot != nil && len(output.CustomerSnapshot.Entries) > 0 { + for _, entry := range output.CustomerSnapshot.Entries { + if entry.State == AccessActive { + t.Fatal("an unresolved lineage granted access") + } + } + } +} + +// An out-of-order fact must mark the checkpoint invalidated so the lineage +// reprojects from zero rather than resuming from a watermark that no longer +// describes a prefix of the timeline. +func TestOutOfOrderArrivalInvalidatesCheckpointOnCommit(t *testing.T) { + lineage := activeLineage("lin_1") + // A checkpoint already past everything the lineage now holds. + lineage.Checkpoint = Position(renewal("lin_1-t9", "2027-01-01T00:00:00Z", "2027-02-01T00:00:00Z")) + + output := Compute(Input{ + Scope: Scope{ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1"}, + Lineages: []LineageInput{lineage}, + GrantVersions: []GrantVersion{proGrant()}, + }, at("2026-02-15T00:00:00Z")) + + if len(output.Checkpoints) != 1 { + t.Fatalf("got %d checkpoints, want one", len(output.Checkpoints)) + } + if !output.Checkpoints[0].Invalidated { + t.Fatal("out-of-order facts did not invalidate the checkpoint") + } +} + +// Scope keys drive advisory locking and job coalescing. A customer scope must +// subsume its lineages, or two jobs for the same customer would run +// concurrently and race on the pointer. +func TestScopeKeyPrefersCustomerAndNamesTheLock(t *testing.T) { + both := Scope{ProjectID: "proj_1", CustomerID: "bcu_1", LineageID: "lin_1"} + if both.Key() != "customer:bcu_1" { + t.Fatalf("scope key %q, want the customer scope to win", both.Key()) + } + if both.LockScope() != "billing-projection:customer:bcu_1" { + t.Fatalf("lock scope %q", both.LockScope()) + } + lineageOnly := Scope{ProjectID: "proj_1", LineageID: "lin_1"} + if lineageOnly.Key() != "lineage:lin_1" { + t.Fatalf("lineage scope key %q", lineageOnly.Key()) + } +} + +// A late-arriving fact for an already-projected customer must produce the same +// state as projecting the whole history from scratch, otherwise reprojection +// after out-of-order delivery would drift from the truth. +func TestReprojectionMatchesFullHistory(t *testing.T) { + lineage := activeLineage("lin_1") + withLate := lineage + withLate.Facts = append(append([]Fact(nil), lineage.Facts...), + renewal("lin_1-t3", "2026-03-01T00:00:00Z", "2026-04-01T00:00:00Z")) + + incremental := Compute(Input{ + Scope: Scope{ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1"}, + Lineages: []LineageInput{{ + LineageID: withLate.LineageID, InstanceID: withLate.InstanceID, Type: withLate.Type, + CustomerResolved: true, Facts: withLate.Facts, + Checkpoint: HighWatermark(Sort(append([]Fact(nil), lineage.Facts...))), + }}, + GrantVersions: []GrantVersion{proGrant()}, + }, at("2026-03-15T00:00:00Z")) + + fromScratch := Compute(Input{ + Scope: Scope{ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1"}, + Lineages: []LineageInput{withLate}, + GrantVersions: []GrantVersion{proGrant()}, + }, at("2026-03-15T00:00:00Z")) + + if len(incremental.Subscriptions) != 1 || len(fromScratch.Subscriptions) != 1 { + t.Fatalf("expected one subscription commit each, got %d and %d", + len(incremental.Subscriptions), len(fromScratch.Subscriptions)) + } + if string(incremental.Subscriptions[0].Snapshot.Checksum) != + string(fromScratch.Subscriptions[0].Snapshot.Checksum) { + t.Fatal("resuming from a checkpoint diverged from projecting the full history") + } +} + +// --- replay --------------------------------------------------------------- + +// replayRepository is the smallest Repository that can observe what a replay +// asked the projection command to do. It exists only so the rule-version +// selection can be checked end to end without a database; nothing here models +// persistence behaviour, which projection_integration_test.go covers against +// PostgreSQL. +type replayRepository struct { + scope Scope + // commandRuleVersions records the rule version each committed projection + // command carried, which is the observable proof that the selection reached + // the engine rather than being dropped on the way. + commandRuleVersions []int + loads int +} + +func (r *replayRepository) BillingEnabled(context.Context, string) (bool, error) { return true, nil } + +func (r *replayRepository) LoadInput(_ context.Context, scope Scope) (Input, error) { + r.loads++ + return Input{Scope: scope}, nil +} + +func (r *replayRepository) Commit(_ context.Context, input Input, _ Output, _ time.Time) error { + r.commandRuleVersions = append(r.commandRuleVersions, input.RuleVersion) + return nil +} + +func (r *replayRepository) RecordAttempt(context.Context, Scope, string, Output, string, time.Time, time.Time) error { + return nil +} +func (r *replayRepository) Enqueue(context.Context, Scope, string, time.Time) error { return nil } +func (r *replayRepository) LeaseJob(context.Context, string, time.Time, time.Time) (Job, bool, error) { + return Job{}, false, nil +} +func (r *replayRepository) CompleteJob(context.Context, Job, string, string, time.Time, time.Time) error { + return nil +} + +type replayScopeKeys struct { + scopes []Scope + calls int +} + +func (k *replayScopeKeys) ScopesForReplay(context.Context, ReplayScope, int) ([]Scope, error) { + k.calls++ + return k.scopes, nil +} + +// Replay.RuleVersion was accepted and ignored, so a replay requested under a +// rule version this build does not derive under silently recomputed the active +// semantics and reported the resulting checksum as that version's answer — a +// determinism proof produced by the wrong engine, which is worse than no proof. +// +// This pins both halves of the correction: the requested version reaches the +// projection command, and a version the build does not implement is refused +// before any scope is touched. A recomputation under genuinely different +// semantics cannot be asserted until a second rule version exists (OD-11(a) +// defers that), so what is proven here is that the parameter is read and +// honoured rather than discarded. +func TestReplayHonoursTheSelectedRuleVersion(t *testing.T) { + scope := Scope{ProjectID: "proj_1", EnvironmentID: "env_1", CustomerID: "bcu_1"} + repository := &replayRepository{scope: scope} + keys := &replayScopeKeys{scopes: []Scope{scope}} + service := NewService(repository) + + results, err := service.RunReplay(context.Background(), keys, + Replay{RuleVersion: ActiveRuleVersion}, ReplayScope{ProjectID: "proj_1"}, 10) + if err != nil { + t.Fatalf("replay under the active rule version failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("got %d replay results, want one per scope", len(results)) + } + if len(repository.commandRuleVersions) != 1 || repository.commandRuleVersions[0] != ActiveRuleVersion { + t.Fatalf("projection command carried rule versions %v, want [%d]", + repository.commandRuleVersions, ActiveRuleVersion) + } + + unsupported := ActiveRuleVersion + 1 + if RuleVersionImplemented(unsupported) { + t.Skip("a second rule version now exists; rewrite this case against its semantics") + } + loadsBefore, callsBefore := repository.loads, keys.calls + if _, err := service.RunReplay(context.Background(), keys, + Replay{RuleVersion: unsupported}, ReplayScope{ProjectID: "proj_1"}, 10); !errors.Is(err, ErrUnsupportedRuleVersion) { + t.Fatalf("replay under an unimplemented rule version returned %v, want ErrUnsupportedRuleVersion", err) + } + if keys.calls != callsBefore || repository.loads != loadsBefore { + t.Fatal("a refused rule version still enumerated scopes or loaded projection input") + } + if len(repository.commandRuleVersions) != 1 { + t.Fatal("a refused rule version still committed a projection") + } +} diff --git a/apps/api/internal/billingprojection/subscription.go b/apps/api/internal/billingprojection/subscription.go new file mode 100644 index 00000000..c23e08ed --- /dev/null +++ b/apps/api/internal/billingprojection/subscription.go @@ -0,0 +1,580 @@ +package billingprojection + +import "time" + +// ProjectSubscription derives the authoritative state of one subscription +// lineage from its canonically ordered validated facts. +// +// The function is pure. `asOf` is the evaluation instant and the only input +// that is not a provider statement; it is passed rather than read from a clock +// so a replay at any later date reproduces the same snapshot exactly. +// +// Derivation order (plan §6), applied to the state accumulated from the whole +// ordered timeline: +// +// effective revocation +// → effective invalidating refund (prorated does not revoke, OD-18(a)) +// → supersession +// → verified current period +// → verified grace (access active per provider docs and grant policy) +// → billing retry (inactive by default) +// → pause (Google only; a scheduled pause keeps access until effective) +// → period ended +// → unknown +// +// Cancellation flips renewal intent only. Access ends at the validated period +// end, never at the cancellation notice — the single most common way a +// subscription system wrongly takes access away. +// supersededByLineage is a property of the *lineage*, not of any fact. A +// Google purchase-token chain is keyed on its root (plan §5), so the +// `purchase_superseded` facts inside a chain describe a token handover within +// one lineage and must never terminate it — the successor token's facts are the +// same subscription continuing. Only an explicit +// `purchase_lineages.superseded_by_lineage_id` edge means this lineage was +// replaced by a different one, and that is what this parameter carries. +func ProjectSubscription(facts []Fact, asOf time.Time, policy Policy, supersededByLineage bool) SubscriptionResult { + ordered := Sort(append([]Fact(nil), facts...)) + asOf = asOf.UTC() + + result := SubscriptionResult{ + HighWatermark: HighWatermark(ordered), + FactsConsumed: len(ordered), + } + state := accumulate(ordered) + state.superseded = supersededByLineage + snapshot := SubscriptionSnapshot{ + AsOf: asOf, + PeriodStartAt: state.periodStart, + PeriodEndAt: state.periodEnd, + GracePeriodEndAt: state.graceEnd, + BillingRetryStartAt: state.retryStart, + PauseStartAt: state.pauseStart, + PauseResumeAt: state.pauseResume, + CancellationEffectiveAt: state.cancelledAt, + ExpirationEffectiveAt: state.expiredAt, + RevocationEffectiveAt: state.revokedAt, + RefundEffectiveAt: state.refundedAt, + CurrentProductID: state.productID, + PriorProductID: state.priorProductID, + ScheduledProductIdentifier: state.scheduledProduct, + SubscriptionGroupIdentifier: state.subscriptionGroup, + OwnershipType: state.ownershipType, + IsTestSource: state.isTestSource, + SourceFactIDs: state.factIDs, + } + + switch { + case len(ordered) == 0: + // Nothing to project. `unknown` rather than `inactive`: absence of + // evidence is not evidence of absence (principle 4). + snapshot.AccessState = AccessUnknown + snapshot.LifecycleState = LifecycleUnknown + snapshot.RenewalIntent = RenewalUnknown + snapshot.BillingState = BillingUnknown + snapshot.UncertaintyReason = UncertaintyMissingFact + + case state.productUnresolved: + // A validated purchase of something Mosaic cannot map is real revenue + // with unknown meaning. Guessing an Entitlement would be worse than + // admitting the gap. + snapshot.AccessState = AccessUnknown + snapshot.LifecycleState = LifecycleUnknown + snapshot.RenewalIntent = renewalIntent(state) + snapshot.BillingState = BillingUnknown + snapshot.UncertaintyReason = UncertaintyProductUnresolved + + case effective(state.revokedAt, asOf): + snapshot.AccessState = AccessInactive + snapshot.LifecycleState = LifecycleRevoked + snapshot.RenewalIntent = renewalIntent(state) + snapshot.BillingState = BillingRevoked + snapshot.UncertaintyReason = UncertaintyNone + snapshot.Terminal = true + + case state.refundInvalidates && effective(state.refundedAt, asOf): + // OD-18(a): a prorated Apple refund does not revoke the remaining + // period unless the provider also reports revocation, so it never sets + // refundInvalidates. + snapshot.AccessState = AccessInactive + snapshot.LifecycleState = LifecycleRefunded + snapshot.RenewalIntent = renewalIntent(state) + snapshot.BillingState = BillingRefunded + snapshot.UncertaintyReason = UncertaintyNone + snapshot.Terminal = true + + case state.superseded: + // The lineage was replaced by another. It stops granting access while + // remaining fully visible in history; nothing is deleted. + snapshot.AccessState = AccessInactive + snapshot.LifecycleState = LifecycleSuperseded + snapshot.RenewalIntent = renewalIntent(state) + snapshot.BillingState = BillingCurrent + snapshot.UncertaintyReason = UncertaintyNone + snapshot.Terminal = true + + case state.pauseStart != nil && effective(state.pauseStart, asOf) && !resumed(state, asOf): + // Google pause. A pause that is only scheduled has not started, so it + // falls through to the period branch and keeps access. + snapshot.AccessState = AccessInactive + snapshot.LifecycleState = LifecyclePaused + snapshot.RenewalIntent = RenewalPaused + snapshot.BillingState = BillingCurrent + snapshot.UncertaintyReason = UncertaintyNone + + case graceActive(state, asOf): + // Verified grace grants access on both providers per their own + // documentation; a grant version may opt out. + // + // Grace is evaluated *before* the current period on purpose. Google + // extends `expiryTime` through the grace window, so a period check + // first would report a customer in grace as plainly active — which + // looks harmless until a Project sets grants_in_grace to false and + // discovers the opt-out was structurally unreachable on Android. + snapshot.LifecycleState = LifecycleGracePeriod + snapshot.BillingState = BillingGrace + snapshot.RenewalIntent = renewalIntent(state) + snapshot.UncertaintyReason = UncertaintyNone + if policy.GrantsInGrace { + snapshot.AccessState = AccessActive + } else { + snapshot.AccessState = AccessInactive + } + + case periodActive(state, asOf): + snapshot.AccessState = AccessActive + snapshot.LifecycleState = LifecycleActive + if state.trialing { + snapshot.LifecycleState = LifecycleTrialing + } + snapshot.RenewalIntent = renewalIntent(state) + snapshot.BillingState = BillingCurrent + snapshot.UncertaintyReason = UncertaintyNone + + case retryActive(state, asOf): + // Billing retry / account hold does not grant access on either + // provider. Enabling it contradicts provider documentation and needs + // explicit owner approval, which is why the default is closed. + snapshot.LifecycleState = LifecycleBillingRetry + snapshot.BillingState = BillingRetrying + snapshot.RenewalIntent = renewalIntent(state) + snapshot.UncertaintyReason = UncertaintyNone + if policy.GrantsInBillingRetry { + snapshot.AccessState = AccessActive + } else { + snapshot.AccessState = AccessInactive + } + + case state.periodEnd != nil && !state.periodEnd.After(asOf): + snapshot.AccessState = AccessInactive + snapshot.LifecycleState = LifecycleExpired + snapshot.RenewalIntent = renewalIntent(state) + snapshot.BillingState = BillingFailed + if state.renewalExpected != nil && !*state.renewalExpected { + // An expiry after a deliberate cancellation is not a billing + // failure; the customer asked for it. + snapshot.BillingState = BillingCurrent + } + snapshot.UncertaintyReason = UncertaintyNone + snapshot.Terminal = true + + default: + // Facts exist but do not describe a period. Unknown, explained. + snapshot.AccessState = AccessUnknown + snapshot.LifecycleState = LifecycleUnknown + snapshot.RenewalIntent = renewalIntent(state) + snapshot.BillingState = BillingUnknown + snapshot.UncertaintyReason = UncertaintyMissingFact + } + + if state.expiredAt == nil && snapshot.LifecycleState == LifecycleExpired { + snapshot.ExpirationEffectiveAt = state.periodEnd + } + snapshot.Checksum = subscriptionChecksum(snapshot) + result.Snapshot = snapshot + result.Timeline = timelineFor(ordered) + result.Warnings = state.warnings + return result +} + +// Policy is the versioned access policy applied to one projection. It is +// supplied by the effective grant version rather than hardcoded, so no handler +// can decide access behaviour on its own (WP8). +type Policy struct { + GrantsInActive bool + GrantsInTrial bool + GrantsInGrace bool + GrantsInBillingRetry bool + GrantsInOneTime bool +} + +// GrantsAccess reports whether this policy grants access for a lifecycle +// state, and whether the lifecycle is policy-dependent at all. +// +// The second return value is what keeps a grant version from being able to +// grant access during a revocation or a refund: those states are not +// negotiable, so no policy is consulted for them. +func (p Policy) GrantsAccess(lifecycle string) (grants bool, policyDependent bool) { + switch lifecycle { + case LifecycleTrialing: + return p.GrantsInTrial, true + case LifecycleActive: + return p.GrantsInActive, true + case LifecycleGracePeriod: + return p.GrantsInGrace, true + case LifecycleBillingRetry: + return p.GrantsInBillingRetry, true + default: + return false, false + } +} + +// AnyGrantsAccess reports whether any of the grant versions in force grants +// access for a lifecycle state. It is the subscription snapshot's headline +// access answer: the snapshot has one access column but a Product may carry +// several Entitlement grants with different policies, so the honest single +// value is "at least one grant version says yes". +// +// Per-Entitlement access is decided per grant version in the entitlement +// engine, not from this value. +func AnyGrantsAccess(lifecycle string, grants []GrantVersion) (bool, bool) { + dependent := false + for _, grant := range grants { + granted, policyDependent := grant.Policy.GrantsAccess(lifecycle) + if !policyDependent { + return false, false + } + dependent = true + if granted { + return true, true + } + } + return false, dependent +} + +// DefaultPolicy is policy version 1 (plan §7): grace grants access, billing +// retry does not, pause never does. +func DefaultPolicy() Policy { + return Policy{ + GrantsInActive: true, GrantsInTrial: true, GrantsInGrace: true, + GrantsInBillingRetry: false, GrantsInOneTime: true, + } +} + +// lineageState is the accumulated reading of an ordered fact timeline. It is +// deliberately a fold rather than a state machine with transitions: the +// provider is the state machine, and Mosaic's job is to read its statements in +// order, not to invent transitions between them. +type lineageState struct { + periodStart *time.Time + periodEnd *time.Time + graceEnd *time.Time + retryStart *time.Time + pauseStart *time.Time + pauseResume *time.Time + cancelledAt *time.Time + expiredAt *time.Time + revokedAt *time.Time + refundedAt *time.Time + + refundInvalidates bool + superseded bool + trialing bool + renewalExpected *bool + productUnresolved bool + isTestSource bool + + productID string + priorProductID string + scheduledProduct string + subscriptionGroup string + ownershipType string + + factIDs []string + warnings []string +} + +func accumulate(ordered []Fact) lineageState { + state := lineageState{factIDs: make([]string, 0, len(ordered))} + for _, fact := range ordered { + state.factIDs = append(state.factIDs, fact.ID) + if fact.IsTestSource { + state.isTestSource = true + } + if fact.SubscriptionGroupIdentifier != "" { + state.subscriptionGroup = fact.SubscriptionGroupIdentifier + } + if fact.InAppOwnershipType != "" { + state.ownershipType = fact.InAppOwnershipType + } + if fact.ResolutionState == "unresolved" { + state.productUnresolved = true + } + if fact.MosaicProductID != "" { + if fact.MosaicProductID != state.productID { + if state.productID != "" { + state.priorProductID = state.productID + } + state.productID = fact.MosaicProductID + } + // Any fact that resolved to a Product clears the unresolved + // reading, including one that resolved to the *same* Product. + // Clearing only on a change made the flag permanent: an unresolved + // fact carries a NULL product, so re-resolution to the product the + // lineage already had never satisfied the inequality, and the + // lineage stayed `unknown` for the rest of its life. + state.productUnresolved = false + } + if fact.RenewalExpected != nil { + expected := *fact.RenewalExpected + state.renewalExpected = &expected + } + if fact.AutoRenewProductIdentifier != "" { + state.scheduledProduct = fact.AutoRenewProductIdentifier + } + + switch fact.FactKind { + case "initial_purchase", "offer_redeemed": + state.periodStart, state.periodEnd = fact.PeriodStartAt, fact.PeriodEndAt + state.trialing = fact.FactKind == "offer_redeemed" + state.clearTerminal() + case "renewal", "plan_change": + // A renewal extends the period. It also reinstates a lineage whose + // expiry has been superseded by a late-arriving renewal fact — + // which is exactly the out-of-order case the failure model + // requires to reactivate rather than stay expired. + if fact.PeriodStartAt != nil { + state.periodStart = fact.PeriodStartAt + } + if fact.PeriodEndAt != nil { + state.periodEnd = fact.PeriodEndAt + } + state.trialing = false + state.clearTerminal() + case "grace_period_start": + state.graceEnd = fact.GracePeriodExpiresAt + if state.graceEnd == nil { + // A grace fact with no provider grace end cannot be trusted to + // bound access. It is recorded and reported rather than turned + // into an open-ended grant. + state.warnings = append(state.warnings, "grace_period_without_provider_end") + } + case "billing_retry_start": + retryAt := EffectiveAt(fact) + state.retryStart = &retryAt + // Apple has no separate grace notification. Grace is expressed as + // `gracePeriodExpiresDate` on the renewal payload that accompanies + // DID_FAIL_TO_RENEW, so a retry fact carrying one *is* the grace + // statement. Reading only `grace_period_start` meant an Apple + // customer spent a sixteen-day grace window projected as + // billing_retry and therefore inactive — access removed while Apple + // was still granting it. + state.graceEnd = fact.GracePeriodExpiresAt + case "paused": + pausedAt := EffectiveAt(fact) + state.pauseStart = &pausedAt + case "resumed": + resumedAt := EffectiveAt(fact) + state.pauseResume = &resumedAt + case "cancellation_scheduled", "auto_renew_disabled": + cancelledAt := EffectiveAt(fact) + state.cancelledAt = &cancelledAt + disabled := false + state.renewalExpected = &disabled + case "auto_renew_enabled": + enabled := true + state.renewalExpected = &enabled + state.cancelledAt = nil + case "expiration": + expiredAt := EffectiveAt(fact) + state.expiredAt = &expiredAt + if fact.PeriodEndAt != nil { + state.periodEnd = fact.PeriodEndAt + } else { + state.periodEnd = &expiredAt + } + state.graceEnd, state.retryStart = nil, nil + case "refund": + refundedAt := effectiveRefund(fact) + state.refundedAt = refundedAt + // A refund invalidates the remaining period only when the provider + // says ownership ended: a full/unspecified refund carrying a + // revocation date. A *partial* refund never does. + // + // Review finding I-14.1: Google's `quantity_partial` void was read + // as fully invalidating. The Google void path sets `revoked_at` + // from its own event time for every void, partial included, so + // `RefundType != "prorated" && RevokedAt != nil` made every + // quantity-partial refund terminate the subscription. Per OD-18's + // spirit — a partial refund does not revoke the remaining period + // unless provider state says revoked — both partial shapes are now + // non-invalidating. A genuine revocation still arrives as a + // `revocation` fact or a provider status that produces one, and + // that branch is untouched. + state.refundInvalidates = !partialRefund(fact.RefundType) && fact.RevokedAt != nil + if partialRefund(fact.RefundType) { + state.warnings = append(state.warnings, "partial_refund_preserves_period") + } + case "revocation": + if fact.RevokedAt != nil { + state.revokedAt = fact.RevokedAt + } else { + revokedAt := EffectiveAt(fact) + state.revokedAt = &revokedAt + } + if fact.RefundedAt != nil { + state.refundedAt = fact.RefundedAt + } + case "purchase_superseded": + // Deliberately no state change. Under root lineage keying this fact + // records a token handover inside one Google chain: the successor + // token's own facts continue the same subscription. Treating it as + // terminal is how a live successor was projected inactive. + // Cross-lineage supersession arrives as the lineage-level parameter. + } + } + return state +} + +// clearTerminal is what makes Apple's REFUND_REVERSED and a late renewal +// reinstating: a later purchase or renewal fact for the same lineage overrides +// an earlier terminal statement, because the provider has said the lineage is +// live again. +func (s *lineageState) clearTerminal() { + s.revokedAt, s.refundedAt = nil, nil + s.refundInvalidates = false + s.expiredAt = nil + // A successful purchase or renewal also ends grace and billing retry: the + // provider took the money. Leaving either set would keep reporting a + // recovery state after the recovery happened. + s.graceEnd, s.retryStart = nil, nil +} + +// partialRefund reports whether the provider described a refund of part of the +// purchase rather than all of it. Apple states this as `prorated` +// (REFUND_PRORATED); Google states it as `quantity_partial` on a voided +// purchase. Neither ends ownership of the remaining period on its own. +func partialRefund(refundType string) bool { + return refundType == "prorated" || refundType == "quantity_partial" +} + +func effectiveRefund(fact Fact) *time.Time { + if fact.RefundedAt != nil { + return fact.RefundedAt + } + if fact.RevokedAt != nil { + return fact.RevokedAt + } + when := EffectiveAt(fact) + return &when +} + +func effective(at *time.Time, asOf time.Time) bool { + return at != nil && !at.After(asOf) +} + +func resumed(state lineageState, asOf time.Time) bool { + if state.pauseResume == nil || state.pauseStart == nil { + return false + } + return state.pauseResume.After(*state.pauseStart) && !state.pauseResume.After(asOf) +} + +func periodActive(state lineageState, asOf time.Time) bool { + if state.periodEnd == nil { + return false + } + if state.expiredAt != nil && !state.expiredAt.After(asOf) { + return false + } + // Half-open interval [start, end): the instant the period ends, it is over. + if state.periodStart != nil && state.periodStart.After(asOf) { + return false + } + return state.periodEnd.After(asOf) +} + +func graceActive(state lineageState, asOf time.Time) bool { + return state.graceEnd != nil && state.graceEnd.After(asOf) +} + +func retryActive(state lineageState, asOf time.Time) bool { + if state.retryStart == nil || state.retryStart.After(asOf) { + return false + } + return state.expiredAt == nil || state.expiredAt.After(asOf) +} + +func renewalIntent(state lineageState) string { + if state.pauseStart != nil && state.pauseResume == nil { + return RenewalPaused + } + if state.renewalExpected == nil { + return RenewalUnknown + } + if *state.renewalExpected { + return RenewalEnabled + } + return RenewalDisabled +} + +// timelineFor emits one entry per fact that changes the story. Facts that +// restate the current position (a duplicate renewal, a repeated status query) +// produce no entry, which is what keeps a timeline readable. +func timelineFor(ordered []Fact) []TimelineEntry { + entries := make([]TimelineEntry, 0, len(ordered)) + for _, fact := range ordered { + entryType, explanation := timelineTypeFor(fact) + if entryType == "" { + continue + } + entries = append(entries, TimelineEntry{ + EntryType: entryType, + EffectiveAt: EffectiveAt(fact), + ObservedAt: fact.RecordedAt, + ProductID: fact.MosaicProductID, + SourceFactIDs: []string{fact.ID}, + ExplanationCode: explanation, + }) + } + return entries +} + +func timelineTypeFor(fact Fact) (string, string) { + switch fact.FactKind { + case "initial_purchase": + return TimelinePurchaseValidated, "initial_purchase_validated" + case "offer_redeemed": + return TimelineTrialStarted, "offer_redeemed" + case "renewal": + return TimelineRenewalValidated, "renewal_validated" + case "plan_change": + if fact.IsUpgraded != nil && *fact.IsUpgraded { + return TimelineProductUpgraded, "provider_reported_upgrade" + } + return TimelineProductDowngraded, "provider_reported_plan_change" + case "auto_renew_enabled": + return TimelineAutoRenewEnabled, "auto_renew_enabled" + case "auto_renew_disabled": + return TimelineAutoRenewDisabled, "auto_renew_disabled" + case "cancellation_scheduled": + return TimelineCancellation, "cancellation_scheduled" + case "grace_period_start": + return TimelineGraceStarted, "grace_period_started" + case "billing_retry_start": + return TimelineBillingRetry, "billing_retry_started" + case "paused": + return TimelinePauseStarted, "subscription_paused" + case "resumed": + return TimelinePauseEnded, "subscription_resumed" + case "expiration": + return TimelineExpiration, "period_ended" + case "refund": + return TimelineRefund, "refund_validated" + case "revocation": + return TimelineRevocation, "revocation_validated" + case "purchase_superseded": + return TimelinePurchaseSuperseded, "lineage_superseded" + default: + return "", "" + } +} diff --git a/apps/api/internal/billingrestore/decide.go b/apps/api/internal/billingrestore/decide.go new file mode 100644 index 00000000..751f3617 --- /dev/null +++ b/apps/api/internal/billingrestore/decide.go @@ -0,0 +1,191 @@ +package billingrestore + +// Decision is the answer for one attempt at one restore job. +// +// Its snapshot evidence is unexported and there is exactly one constructor that +// sets it. That is the structural half of the central invariant: a Decision +// assembled anywhere else — including a struct literal that names +// OutcomeRestored — carries no evidence, so SnapshotVersion returns zero and +// Validate refuses it before a row is ever written. The schema's CHECK +// constraint then catches what neither of those did, which is the order the +// defences should be in rather than the reverse. +type Decision struct { + // Outcome is Mosaic's authoritative answer on the contract's vocabulary. + Outcome string + // UncertaintyReason explains every outcome that is not definite. The schema + // requires it to be something other than `none` for those, and the contract + // requires the matching `uncertainty` member. + UncertaintyReason string + // PendingValidationCount is carried on the decision because the contract + // requires it whenever the outcome is validation_pending. + PendingValidationCount int + // Terminal reports that no further attempt can change this answer. A + // non-terminal decision is rescheduled with backoff until the attempt + // budget runs out, at which point the last non-terminal answer becomes the + // reported one — honestly uncertain rather than falsely definite. + Terminal bool + + // evidence is the accepted snapshot version that demonstrates a restore. It + // is set by restoredBy and by nothing else. + evidence int64 +} + +// SnapshotVersion is the accepted snapshot that proves the restore. It returns +// zero for every outcome but `restored`, so no other outcome can smuggle a +// version onto its row and no forged `restored` can carry one. +func (d Decision) SnapshotVersion() int64 { + if d.Outcome != OutcomeRestored { + return 0 + } + return d.evidence +} + +// Validate refuses a decision that would write a lie. +// +// It is called by the service before every completion, and by the repository +// before every write, because the whole point of the table is that this pairing +// is never wrong. +func (d Decision) Validate() error { + switch d.Outcome { + case OutcomeRestored: + // Never report restored before an authoritative snapshot reflects the + // source. + if d.evidence < 1 { + return ErrUnprovenRestore + } + if d.UncertaintyReason != ReasonNone { + return ErrInvalidOutcome + } + case OutcomeNoAdditionalPurchases: + if d.UncertaintyReason != ReasonNone { + return ErrInvalidOutcome + } + case OutcomeValidationPending, OutcomeIdentityUnresolved, OutcomeProductUnresolved, + OutcomeProviderUnavailable, OutcomeFailed: + // Every non-definite outcome stays explainable. + if d.UncertaintyReason == "" || d.UncertaintyReason == ReasonNone { + return ErrInvalidOutcome + } + default: + return ErrInvalidOutcome + } + return nil +} + +// restoredBy is the only constructor that can produce a `restored` decision. +// +// It refuses unless there is a baseline to have moved past, the observed +// version is a real accepted snapshot, and it is strictly greater than the +// baseline. A restore whose customer had version 5 before and still has version +// 5 restored nothing, however successfully the native restore returned. +func restoredBy(baseline *int64, observed int64) (Decision, bool) { + if baseline == nil || observed < 1 || observed <= *baseline { + return Decision{}, false + } + return Decision{ + Outcome: OutcomeRestored, + UncertaintyReason: ReasonNone, + Terminal: true, + evidence: observed, + }, true +} + +func uncertain(outcome, reason string, pending int, terminal bool) Decision { + return Decision{ + Outcome: outcome, + UncertaintyReason: reason, + PendingValidationCount: pending, + Terminal: terminal, + } +} + +// Decide maps one chain state onto one outcome. It is pure: it reads no clock, +// touches no database, and is the single place the chain-state-to-outcome table +// lives. +// +// The rules are ordered, first match wins, and the order is the order the chain +// itself runs in. Judging identity before validation has settled would report +// `identity_unresolved` for a customer whose facts simply had not landed yet; +// judging the snapshot before identity would compare versions on a customer +// that does not exist. +// +// Only three outcomes are terminal on sight — `restored`, +// `no_additional_purchases`, and `failed` — because only those three cannot +// become something else on a later attempt. The rest describe a chain that is +// still moving, and are reported as the final answer only once the attempt +// budget is spent. +func Decide(job Job, chain ChainState) Decision { + pending := chain.PendingValidationCount + + switch { + // A dead-lettered projection is the one failure that is Mosaic's own and + // cannot clear itself. + case chain.ProjectionFailed: + return uncertain(OutcomeFailed, ReasonProjectionFailed, pending, true) + + // A real identity conflict is quarantined: Mosaic grants neither claimant + // automatically and an operator resolves it (OD-10). It is terminal because + // no retry resolves it, and the row keeps no customer when none was ever + // resolved — which is what the schema's identity CHECK requires. + case chain.IdentityConflict && chain.CustomerID == "": + return uncertain(OutcomeIdentityUnresolved, ReasonConflictingFacts, pending, true) + case chain.IdentityConflict: + return uncertain(OutcomeFailed, ReasonConflictingFacts, pending, true) + + // The purchase is real and validated but names a Product Mosaic cannot map, + // so no Entitlement can be granted for it. An operator mapping the Product + // clears this, so it is not terminal while attempts remain. + case chain.ProductUnresolved: + return uncertain(OutcomeProductUnresolved, ReasonProductUnresolved, pending, false) + + // Validation is still running. Whether the wait is on the store or on + // Mosaic is the difference between provider_unavailable and + // validation_pending, and it is a difference a caller acts on. + case pending > 0 && chain.ProviderUnavailable: + return uncertain(OutcomeProviderUnavailable, ReasonProviderUnavailable, pending, false) + case pending > 0: + return uncertain(OutcomeValidationPending, ReasonMissingFact, pending, false) + + // Validation settled and something in it permanently failed. + case chain.PermanentFailure: + return uncertain(OutcomeFailed, ReasonUnsupportedProviderState, pending, true) + + // Validation settled with no customer. Now — and only now — the absence of + // an identity is an answer rather than a race. + case chain.CustomerID == "": + if chain.FactCount == 0 && job.ObservedTransactionCount == 0 { + // The native restore found nothing and submitted nothing. There is + // no identity to resolve because there is nothing to attach. + return Decision{Outcome: OutcomeNoAdditionalPurchases, UncertaintyReason: ReasonNone, Terminal: true} + } + return uncertain(OutcomeIdentityUnresolved, ReasonIdentityUnresolved, pending, false) + + // Nothing was submitted, so nothing can have changed. + case job.ObservedTransactionCount == 0: + return Decision{Outcome: OutcomeNoAdditionalPurchases, UncertaintyReason: ReasonNone, Terminal: true} + } + + // The facts are in and attached. The only question left is whether the + // authoritative snapshot has moved. + if decision, ok := restoredBy(job.BaselineSnapshotVersion, chain.SnapshotVersion); ok { + return decision + } + if !chain.ProjectionSettled || job.BaselineSnapshotVersion == nil { + // Facts exist but the projection that would reflect them has not + // finished. This is the state a naive implementation reports as + // restored; it is stale, not restored. + return uncertain(OutcomeValidationPending, ReasonStaleValidation, pending, false) + } + // The projection ran and the customer's version did not move: the restored + // purchases were ones Mosaic already held. That is a definite, correct + // answer, and it is not `restored`. + return Decision{Outcome: OutcomeNoAdditionalPurchases, UncertaintyReason: ReasonNone, Terminal: true} +} + +// TerminalStatus maps a decision onto the job status it is stored under. +func TerminalStatus(decision Decision) string { + if decision.Outcome == OutcomeFailed { + return StatusFailed + } + return StatusCompleted +} diff --git a/apps/api/internal/billingrestore/errors.go b/apps/api/internal/billingrestore/errors.go new file mode 100644 index 00000000..2c7547d8 --- /dev/null +++ b/apps/api/internal/billingrestore/errors.go @@ -0,0 +1,25 @@ +package billingrestore + +import "errors" + +// Stable domain errors. Transport maps these in one place and nothing compares +// an error message string. +var ( + // ErrBillingDisabled is a service state, never a statement about a customer. + // A Project that turned billing off has not told Mosaic its customers lost + // access — it has told Mosaic to stop answering. + ErrBillingDisabled = errors.New("billing is not enabled for this Project") + ErrUnauthenticated = errors.New("the request could not be authenticated") + ErrNotFound = errors.New("the requested restore was not found") + ErrInvalid = errors.New("the request is not valid") + ErrUnavailable = errors.New("restore state could not be read") + + // ErrUnprovenRestore is the refusal at the heart of this package: a + // `restored` outcome was assembled without the accepted snapshot version + // that demonstrates it. It is never returned to a caller — it is a + // programming error caught before a row is written. + ErrUnprovenRestore = errors.New("restored requires the accepted snapshot version that demonstrates it") + // ErrInvalidOutcome is an outcome paired with an uncertainty reason the + // contract and the schema do not allow together. + ErrInvalidOutcome = errors.New("the restore outcome and uncertainty reason are not a valid pairing") +) diff --git a/apps/api/internal/billingrestore/model.go b/apps/api/internal/billingrestore/model.go new file mode 100644 index 00000000..ed945035 --- /dev/null +++ b/apps/api/internal/billingrestore/model.go @@ -0,0 +1,187 @@ +// Package billingrestore owns Mosaic's restore and sync chain. +// +// A restore is not one action. The SDK asks the store to restore, submits the +// provider transaction references it got back as observations, those +// observations become Raw Billing Inputs, validation turns them into +// Transaction Facts, identity resolution attaches those facts to a Billing +// Customer, and only then does a projection produce an accepted snapshot that +// reflects them. This package records the whole chain so the outcome reported +// to a caller is derived from where the chain actually got to — never from the +// fact that the native restore returned. +// +// The invariant the package exists to protect: `restored` is admissible only +// together with the accepted snapshot version that demonstrates it. It is +// enforced three times over — in Decide, which is the only producer of a +// Decision carrying evidence; in Decision.Validate, which the service calls +// before any write; and in the schema's CHECK constraint, which is the last +// line rather than the first. +package billingrestore + +import "time" + +// JobFamily is the worker job-family name for the restore/sync queue. +const JobFamily = "billing_restore_sync" + +// ContractVersion is the Authoritative Entitlement Contract version every +// restore record on this surface is written under. +const ContractVersion = "1" + +// Mosaic's authoritative restore outcomes. This is the contract's closed +// vocabulary and the schema's CHECK list; nothing else may ever be written. +const ( + OutcomeRestored = "restored" + OutcomeNoAdditionalPurchases = "no_additional_purchases" + OutcomeValidationPending = "validation_pending" + OutcomeIdentityUnresolved = "identity_unresolved" + OutcomeProductUnresolved = "product_unresolved" + OutcomeProviderUnavailable = "provider_unavailable" + OutcomeFailed = "failed" +) + +// What the native provider restore itself did. This axis is reported by the +// caller, stored beside the Mosaic outcome, and never merged into it: a +// completed native restore whose facts have not reached a snapshot is not +// restored access, and collapsing the two axes is precisely how a restore flow +// starts lying. +const ( + ProviderOutcomeCompleted = "completed" + ProviderOutcomeNoPurchasesFound = "no_purchases_found" + ProviderOutcomeCancelled = "cancelled" + ProviderOutcomeFailed = "failed" + ProviderOutcomeUnsupported = "unsupported" + ProviderOutcomeNotAttempted = "not_attempted" +) + +// The shared uncertainty vocabulary every Mosaic entitlement surface uses. +const ( + ReasonNone = "none" + ReasonProviderUnavailable = "provider_unavailable" + ReasonMissingFact = "missing_fact" + ReasonIdentityUnresolved = "identity_unresolved" + ReasonProductUnresolved = "product_unresolved" + ReasonConflictingFacts = "conflicting_facts" + ReasonProjectionFailed = "projection_failed" + ReasonStaleValidation = "stale_validation" + ReasonUnsupportedProviderState = "unsupported_provider_state" +) + +const ( + StoreApple = "apple_app_store" + StoreGoogle = "google_play" +) + +// Job statuses, matching the schema's CHECK. +const ( + StatusQueued = "queued" + StatusLeased = "leased" + StatusCompleted = "completed" + StatusFailed = "failed" +) + +// MaxSubmittedObservations bounds one restore. The contract caps +// observedTransactionCount at 10000; this is the far tighter operational bound +// a real native restore stays under, and it keeps one request from linking an +// unbounded number of raw inputs. +const MaxSubmittedObservations = 200 + +// Job is one restore/sync job as stored. It is the whole chain record, not a +// queue entry with a payload attached. +type Job struct { + ID string + ProjectID string + EnvironmentID string + // CustomerID is empty until identity resolves. `identity_unresolved` is + // exactly the outcome in which it stays empty, which is why it is not + // required. + CustomerID string + StorePlatform string + + Status string + Outcome string + ProviderOutcome string + UncertaintyReason string + + ObservedTransactionCount int + PendingValidationCount int + // BaselineSnapshotVersion is the customer's snapshot version at the moment + // the restore was requested (or at the moment identity first resolved). A + // nil baseline makes `restored` inadmissible: without it there is nothing + // for an observed version to have moved past. + BaselineSnapshotVersion *int64 + // SnapshotVersion is the accepted snapshot that reflects the restore. It is + // the evidence for `restored` and is written only with it. + SnapshotVersion *int64 + + CorrelationID string + AttemptCount int + MaxAttempts int + + RequestedAt time.Time + UpdatedAt time.Time + CompletedAt *time.Time +} + +// SubmitRequest is one restore submission. It carries no provider transaction +// reference: the references were already submitted to the observation endpoint, +// and this request names those submissions so the chain is linked to the Raw +// Billing Inputs they produced rather than re-transmitting store credentials +// through a second surface. +type SubmitRequest struct { + StorePlatform string + ProviderOutcome string + // ObservationSubmissionIDs are the submissionId values the caller used on + // POST /v1/sdk/billing/observations (or the trusted-server equivalent). + ObservationSubmissionIDs []string + // CustomerID is accepted only on the trusted-server surface, where the + // caller's own backend has already authenticated the user. A client-asserted + // identifier can never select a Billing Customer here — restore resolves + // identity through server-validated store lineage. + CustomerID string + CorrelationID string +} + +// ChainState is where the chain actually got to, read fresh on every attempt. +// Every field answers one question about one stage; nothing here is inferred +// from a timestamp. +type ChainState struct { + // CustomerID is the customer the restore's facts resolved to, empty when + // identity has not resolved. + CustomerID string + // IdentityConflict is set when a lineage in this chain is frozen or carries + // an open identity conflict. Mosaic grants nothing automatically in that + // case (OD-10). + IdentityConflict bool + // LinkedInputCount is how many Raw Billing Inputs the submission linked. + LinkedInputCount int + // PendingValidationCount is how many of them have neither produced a fact + // nor terminally failed. + PendingValidationCount int + // ProviderUnavailable is set when a pending input is waiting on a provider + // failure rather than on Mosaic. + ProviderUnavailable bool + // ProductUnresolved is set when an input validated but names a Product + // Mosaic cannot map. The purchase is real; the entitlement is unknown. + ProductUnresolved bool + // PermanentFailure is set when an input exhausted validation or quarantined + // for a reason no retry can clear. + PermanentFailure bool + // FactCount is how many Transaction Facts the linked inputs produced. + FactCount int + // ProjectionSettled reports that no projection for this customer is queued + // or leased, so the current snapshot version is the answer rather than an + // intermediate one. + ProjectionSettled bool + // ProjectionFailed reports a dead-lettered projection for this customer. + ProjectionFailed bool + // SnapshotVersion is the customer's current accepted snapshot version, 0 + // when the customer has never been projected in this Environment. + SnapshotVersion int64 +} + +// View is a restore as read back by the status endpoint. +type View struct { + Job Job + // EvaluatedAt is when the reported uncertainty was last observed. The + // contract requires a `since` on every non-definite answer. + EvaluatedAt time.Time +} diff --git a/apps/api/internal/billingrestore/repository.go b/apps/api/internal/billingrestore/repository.go new file mode 100644 index 00000000..8e0b3fa1 --- /dev/null +++ b/apps/api/internal/billingrestore/repository.go @@ -0,0 +1,68 @@ +package billingrestore + +import ( + "context" + "time" +) + +// KeyScope is the tenant an API key authenticated into. It is a local copy of +// the ingestion package's scope so this package depends on an interface it +// declares rather than on the write path. +type KeyScope struct { + APIKeyID string + OrganizationID string + ProjectID string + EnvironmentID string + EnvironmentMode string + ApplicationID string +} + +// KeyAuthenticator authenticates an API key into a tenant. +// +// A public SDK key proves which Environment is asking and nothing more. It can +// never select a Billing Customer: on the restore surface identity is resolved +// from server-validated store lineage, never from anything the client asserts. +type KeyAuthenticator interface { + AuthenticateServerKey(ctx context.Context, raw string) (KeyScope, error) + AuthenticateSDKKey(ctx context.Context, raw string) (KeyScope, error) +} + +// Repository is the persistence port for the restore chain. +// +// There is no method here that validates, projects, or resolves identity. This +// package reads where the chain got to and records the answer; every stage it +// observes is owned by the service that runs it, and a restore that could +// advance a stage itself would eventually disagree with the stage's owner. +type Repository interface { + BillingEnabled(ctx context.Context, projectID string) (bool, error) + + // CreateJob records a restore and links the Raw Billing Inputs the named + // observation submissions created, in one transaction. It returns the job + // as stored, with ObservedTransactionCount set to the number of inputs + // actually linked — never to the number the caller claimed. + CreateJob(ctx context.Context, job Job, submissionIDs []string, now time.Time) (Job, error) + + // LeaseJob claims one due job with SELECT ... FOR UPDATE SKIP LOCKED. + LeaseJob(ctx context.Context, workerID string, now, leaseUntil time.Time) (Job, bool, error) + + // LoadChain reads where the chain got to for one job. + LoadChain(ctx context.Context, job Job) (ChainState, error) + + // AdoptBaseline records the customer and the snapshot version that existed + // when identity first resolved. It is a no-op once a baseline is set: a + // baseline that could move would let `restored` be proven against a version + // chosen after the fact. + AdoptBaseline(ctx context.Context, job Job, customerID string, baseline int64, now time.Time) error + + // CompleteJob writes a terminal outcome. Implementations must refuse a + // decision whose Validate fails. + CompleteJob(ctx context.Context, job Job, decision Decision, chain ChainState, now time.Time) error + + // RescheduleJob records progress on a non-terminal attempt and sets the next + // availability. The outcome column stays null: the job has no answer yet, + // and writing a provisional one would make an unfinished chain look decided. + RescheduleJob(ctx context.Context, job Job, decision Decision, chain ChainState, availableAt, now time.Time) error + + // Job reads one restore for the status endpoint, scoped to its tenant. + Job(ctx context.Context, projectID, environmentID, restoreID string) (Job, error) +} diff --git a/apps/api/internal/billingrestore/service.go b/apps/api/internal/billingrestore/service.go new file mode 100644 index 00000000..f9de58ca --- /dev/null +++ b/apps/api/internal/billingrestore/service.go @@ -0,0 +1,446 @@ +package billingrestore + +import ( + "context" + cryptorand "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + mathrand "math/rand/v2" + "strings" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/jobtelemetry" +) + +// restoreLease bounds how long one worker may hold a restore job. It is short +// on purpose: an attempt makes no external call, so a job held for minutes is a +// stuck worker rather than slow work. +const restoreLease = 60 * time.Second + +// Backoff bounds. A restore is the one billing job a person is waiting on — the +// SDK polls three times over roughly six seconds before it reports +// validation_pending — so the first retries are seconds apart rather than the +// tens of seconds validation uses, and the ceiling is a minute rather than ten. +const ( + backoffBase = 2 * time.Second + backoffCap = 60 * time.Second + // DefaultMaxAttempts spans roughly four minutes across the schedule below. + // That comfortably outlasts a normal validate-then-project chain while + // still ending: a restore that has not resolved in four minutes has an + // answer, and the answer is the honest uncertain one. + DefaultMaxAttempts = 12 +) + +// Service is the restore application service. It owns the job lifecycle, the +// outcome decision, and the guarantee that `restored` is never written without +// the snapshot that proves it. +type Service struct { + repository Repository + keys KeyAuthenticator + now func() time.Time + random io.Reader + jitter *mathrand.Rand + tracer trace.Tracer + + outcomes metric.Int64Counter + chainLatency metric.Float64Histogram +} + +type Option func(*Service) + +func WithClock(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +func WithRandom(random io.Reader) Option { + return func(s *Service) { + if random != nil { + s.random = random + } + } +} + +// WithJitter fixes the retry jitter source. Tests use it so a reschedule is +// reproducible; production leaves it nil and gets a per-process source. +func WithJitter(jitter *mathrand.Rand) Option { + return func(s *Service) { s.jitter = jitter } +} + +func NewService(repository Repository, keys KeyAuthenticator, options ...Option) *Service { + meter := otel.Meter("mosaic/billingrestore") + service := &Service{ + repository: repository, + keys: keys, + now: func() time.Time { return time.Now().UTC() }, + random: cryptorand.Reader, + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/billingrestore"), + } + service.outcomes, _ = meter.Int64Counter("mosaic.billing.restore.outcomes", + metric.WithDescription("Restore jobs by Mosaic outcome and native provider outcome.")) + // The latency that matters is the whole chain — request to authoritative + // answer — not the worker attempt. A fast worker attached to a slow + // validation queue is still a user waiting on a restore. + service.chainLatency, _ = meter.Float64Histogram("mosaic.billing.restore.chain_latency", + metric.WithDescription("Time from restore request to authoritative outcome."), + metric.WithUnit("ms")) + for _, option := range options { + option(service) + } + return service +} + +// --------------------------------------------------------------------------- +// Submission +// --------------------------------------------------------------------------- + +// SubmitFromSDK records a restore requested by an SDK. +// +// It authenticates the public SDK key only. That is deliberate and it is what +// makes the surface safe: a public key proves which Environment is asking and +// can never select a customer, and this request never names one. Identity is +// resolved later, from the store lineage the submitted observations validate +// into — which is the same server-validated path plan §5a requires, and the +// reason `identity_unresolved` is a first-class outcome rather than an error. +func (s *Service) SubmitFromSDK(ctx context.Context, rawKey string, request SubmitRequest) ([]byte, error) { + scope, err := s.keys.AuthenticateSDKKey(ctx, strings.TrimSpace(rawKey)) + if err != nil { + return nil, ErrUnauthenticated + } + // A client-supplied customer identifier is dropped rather than rejected: + // the surface accepts one shape from both callers, and the untrusted one + // simply cannot use it to select anybody. + request.CustomerID = "" + return s.submit(ctx, scope, request) +} + +// SubmitFromServer records a restore requested by an application backend over +// the trusted secret-server key. Such a caller may name the Billing Customer, +// because it has authenticated the user itself. +func (s *Service) SubmitFromServer(ctx context.Context, rawKey string, request SubmitRequest) ([]byte, error) { + scope, err := s.keys.AuthenticateServerKey(ctx, strings.TrimSpace(rawKey)) + if err != nil { + return nil, ErrUnauthenticated + } + return s.submit(ctx, scope, request) +} + +func (s *Service) submit(ctx context.Context, scope KeyScope, request SubmitRequest) ([]byte, error) { + ctx, span := s.tracer.Start(ctx, "billing.restore.submit") + defer span.End() + + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return nil, err + } + if err := validateSubmission(request); err != nil { + return nil, err + } + + id, err := s.newID("rst") + if err != nil { + return nil, err + } + now := s.now() + job := Job{ + ID: id, + ProjectID: scope.ProjectID, + EnvironmentID: scope.EnvironmentID, + CustomerID: strings.TrimSpace(request.CustomerID), + StorePlatform: request.StorePlatform, + Status: StatusQueued, + ProviderOutcome: request.ProviderOutcome, + UncertaintyReason: ReasonNone, + CorrelationID: SafeCorrelation(request.CorrelationID), + MaxAttempts: DefaultMaxAttempts, + RequestedAt: now, + UpdatedAt: now, + } + + stored, err := s.repository.CreateJob(ctx, job, request.ObservationSubmissionIDs, now) + if err != nil { + return nil, err + } + span.SetAttributes( + attribute.String("mosaic.billing.restore.id", stored.ID), + attribute.String("mosaic.billing.restore.provider_outcome", stored.ProviderOutcome), + attribute.Int("mosaic.billing.restore.observed", stored.ObservedTransactionCount)) + + return s.render(View{Job: stored, EvaluatedAt: now}) +} + +func validateSubmission(request SubmitRequest) error { + switch request.StorePlatform { + case StoreApple, StoreGoogle: + default: + return ErrInvalid + } + switch request.ProviderOutcome { + case ProviderOutcomeCompleted, ProviderOutcomeNoPurchasesFound, ProviderOutcomeCancelled, + ProviderOutcomeFailed, ProviderOutcomeUnsupported, ProviderOutcomeNotAttempted: + default: + return ErrInvalid + } + if len(request.ObservationSubmissionIDs) > MaxSubmittedObservations { + return ErrInvalid + } + for _, id := range request.ObservationSubmissionIDs { + if trimmed := strings.TrimSpace(id); trimmed == "" || len(trimmed) > 128 { + return ErrInvalid + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Status +// --------------------------------------------------------------------------- + +// RestoreForSDK reads one restore over the public SDK key. The job must belong +// to the key's Project and Environment; a restore from another tenant reads as +// absent rather than forbidden, so the surface cannot be used to probe for the +// existence of another tenant's restores. +func (s *Service) RestoreForSDK(ctx context.Context, rawKey, restoreID string) ([]byte, error) { + scope, err := s.keys.AuthenticateSDKKey(ctx, strings.TrimSpace(rawKey)) + if err != nil { + return nil, ErrUnauthenticated + } + return s.restore(ctx, scope, restoreID) +} + +// RestoreForServer reads one restore over the trusted secret-server key. +func (s *Service) RestoreForServer(ctx context.Context, rawKey, restoreID string) ([]byte, error) { + scope, err := s.keys.AuthenticateServerKey(ctx, strings.TrimSpace(rawKey)) + if err != nil { + return nil, ErrUnauthenticated + } + return s.restore(ctx, scope, restoreID) +} + +func (s *Service) restore(ctx context.Context, scope KeyScope, restoreID string) ([]byte, error) { + ctx, span := s.tracer.Start(ctx, "billing.restore.status") + defer span.End() + + if err := s.requireEnabled(ctx, scope.ProjectID); err != nil { + return nil, err + } + restoreID = strings.TrimSpace(restoreID) + if restoreID == "" || len(restoreID) > 128 { + return nil, ErrNotFound + } + job, err := s.repository.Job(ctx, scope.ProjectID, scope.EnvironmentID, restoreID) + if err != nil { + return nil, err + } + span.SetAttributes( + attribute.String("mosaic.billing.restore.id", job.ID), + attribute.String("mosaic.billing.restore.status", job.Status)) + return s.render(View{Job: job, EvaluatedAt: job.UpdatedAt}) +} + +// render produces the contract record's canonical serialization. The response +// body is the canonical bytes rather than a second encoding of the same map, so +// a caller that digests what it received digests what Mosaic produced. +func (s *Service) render(view View) ([]byte, error) { + record, err := RestoreRecord(view) + if err != nil { + return nil, err + } + return CanonicalJSON(record) +} + +// --------------------------------------------------------------------------- +// Worker +// --------------------------------------------------------------------------- + +// ProcessNextRestoreSync leases and advances one restore job. It matches the +// (processed, error) contract every other Mosaic job family uses. +// +// The attempt is a read and a decision, never a repair: it does not validate, +// does not project, and does not resolve identity. It looks at where the chain +// got to and either records the answer or schedules another look. +func (s *Service) ProcessNextRestoreSync(ctx context.Context, workerID string) (bool, error) { + now := s.now() + job, leased, err := s.repository.LeaseJob(ctx, workerID, now, now.Add(restoreLease)) + if err != nil { + return false, fmt.Errorf("lease restore job: %w", err) + } + if !leased { + return false, nil + } + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: job.ID, JobKind: JobFamily, + ProjectID: job.ProjectID, EnvironmentID: job.EnvironmentID, ResourceID: job.ID, + }) + ctx, span := s.tracer.Start(ctx, "billing.restore.attempt") + defer span.End() + span.SetAttributes(attribute.String("mosaic.billing.restore.id", job.ID)) + + // A Project that turned billing off decides nothing. The job is parked + // rather than failed: disabling is reversible, and failing would need an + // operator action to recover work that only ever needed to wait. + if err := s.requireEnabled(ctx, job.ProjectID); err != nil { + parked := s.now() + return true, s.repository.RescheduleJob(ctx, job, Decision{}, ChainState{}, + parked.Add(5*time.Minute), parked) + } + + chain, err := s.repository.LoadChain(ctx, job) + if err != nil { + // The chain could not be read at all. That is Mosaic's own failure, not + // an answer about the restore, so nothing is written to the outcome + // column while attempts remain. + // + // The error is returned rather than absorbed (defect D-2). It used to be + // logged and swallowed as (true, nil), which meant a permanently broken + // read — the stage-3 query referenced a column that does not exist — + // looked identical to healthy work to the worker loop and to every + // metric derived from it. Every restore in the Environment was failing + // while `restoreFailedJobs` stayed at zero and only `restoreBacklog` + // rose. A failure the (processed, error) contract cannot see is a + // failure nobody is paged for. + completed := s.now() + if job.AttemptCount >= job.MaxAttempts { + // Out of attempts. The job becomes terminally failed so it stops + // consuming lease capacity and starts being counted by + // `restoreFailedJobs` on the projection-health surface. `failed` + // with an explanation is the honest terminal state for a restore + // Mosaic could never evaluate; the schema requires an outcome + // beside the status, and inventing a definite one here would be a + // claim about purchases nobody read. + decision := Decision{ + Outcome: OutcomeFailed, + UncertaintyReason: ReasonProjectionFailed, + Terminal: true, + } + if completeErr := s.repository.CompleteJob(ctx, job, decision, ChainState{}, completed); completeErr != nil { + return true, errors.Join(err, completeErr) + } + s.outcomes.Add(ctx, 1, metric.WithAttributes( + attribute.String("outcome", decision.Outcome), + attribute.String("provider_outcome", job.ProviderOutcome), + attribute.String("uncertainty_reason", decision.UncertaintyReason), + attribute.Bool("attempts_exhausted", true))) + return true, fmt.Errorf("load restore chain: %w", err) + } + if rescheduleErr := s.repository.RescheduleJob(ctx, job, Decision{}, ChainState{}, + s.nextAttemptAt(completed, job.AttemptCount), completed); rescheduleErr != nil { + return true, errors.Join(err, rescheduleErr) + } + return true, fmt.Errorf("load restore chain: %w", err) + } + + // The baseline is adopted the first time identity resolves, and never + // again. Until it exists there is no version for an accepted snapshot to + // have moved past, so Decide cannot return `restored` — which is why the + // adoption happens on its own attempt rather than being folded into a + // decision made from the same read. + if job.BaselineSnapshotVersion == nil && chain.CustomerID != "" { + adopted := s.now() + if err := s.repository.AdoptBaseline(ctx, job, chain.CustomerID, + chain.SnapshotVersion, adopted); err != nil { + return true, fmt.Errorf("adopt restore baseline: %w", err) + } + return true, s.repository.RescheduleJob(ctx, job, Decision{}, chain, + s.nextAttemptAt(adopted, job.AttemptCount), adopted) + } + + decision := Decide(job, chain) + exhausted := job.AttemptCount >= job.MaxAttempts + if !decision.Terminal && !exhausted { + scheduled := s.now() + return true, s.repository.RescheduleJob(ctx, job, decision, chain, + s.nextAttemptAt(scheduled, job.AttemptCount), scheduled) + } + + // Belt and braces before the row is written. Decide cannot produce an + // unproven `restored`, and the schema would reject one, but the check that + // catches a future edit is the one in the code path rather than in either + // of those. + if err := decision.Validate(); err != nil { + zerolog.Ctx(ctx).Error(). + Str("restore_id", job.ID). + Str("restore_outcome", decision.Outcome). + Msg("a restore decision was refused before it could be written") + return true, err + } + + completed := s.now() + if err := s.repository.CompleteJob(ctx, job, decision, chain, completed); err != nil { + return true, err + } + + s.outcomes.Add(ctx, 1, metric.WithAttributes( + attribute.String("outcome", decision.Outcome), + attribute.String("provider_outcome", job.ProviderOutcome), + attribute.String("uncertainty_reason", decision.UncertaintyReason), + attribute.Bool("attempts_exhausted", exhausted && !decision.Terminal))) + s.chainLatency.Record(ctx, float64(completed.Sub(job.RequestedAt).Milliseconds()), + metric.WithAttributes(attribute.String("outcome", decision.Outcome))) + span.SetAttributes( + attribute.String("mosaic.billing.restore.outcome", decision.Outcome), + attribute.String("mosaic.billing.restore.uncertainty", decision.UncertaintyReason), + attribute.Int64("mosaic.billing.restore.snapshot_version", decision.SnapshotVersion())) + return true, nil +} + +// nextAttemptAt computes when the next look becomes available. +// +// Jitter is applied for the same reason validation applies it: a store outage +// or a slow projection makes every waiting restore due at the same instant, and +// a synchronized burst turns a recoverable delay into a self-inflicted one. +func (s *Service) nextAttemptAt(now time.Time, attempt int) time.Time { + shift := attempt + if shift < 0 { + shift = 0 + } + if shift > 6 { + shift = 6 + } + delay := backoffBase << shift + if delay > backoffCap { + delay = backoffCap + } + factor := 1 + (mathrand.Float64()*2-1)*0.25 + if s.jitter != nil { + factor = 1 + (s.jitter.Float64()*2-1)*0.25 + } + return now.Add(time.Duration(float64(delay) * factor)) +} + +// requireEnabled fails closed, matching every sibling billing service: an +// unreadable setting is treated as disabled, so a transient database error +// cannot quietly re-enable a Project that asked Mosaic to hold no billing +// state. +func (s *Service) requireEnabled(ctx context.Context, projectID string) error { + enabled, err := s.repository.BillingEnabled(ctx, projectID) + if err != nil { + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("billing_error_kind", fmt.Sprintf("%T", err)). + Msg("billing enablement could not be read; treating the Project as disabled") + return ErrBillingDisabled + } + if !enabled { + return ErrBillingDisabled + } + return nil +} + +func (s *Service) newID(prefix string) (string, error) { + buffer := make([]byte, 16) + if _, err := io.ReadFull(s.random, buffer); err != nil { + return "", fmt.Errorf("generate restore identifier: %w", err) + } + return prefix + "_" + base64.RawURLEncoding.EncodeToString(buffer), nil +} diff --git a/apps/api/internal/billingrestore/service_test.go b/apps/api/internal/billingrestore/service_test.go new file mode 100644 index 00000000..2c0a44d7 --- /dev/null +++ b/apps/api/internal/billingrestore/service_test.go @@ -0,0 +1,404 @@ +package billingrestore + +import ( + "context" + "errors" + "testing" + "time" +) + +// The two tests in this file cover the two failures that would be invisible +// until production. +// +// The first is the lie the restore_sync_jobs table exists to prevent: reporting +// `restored` before an accepted snapshot reflects the restore. Nothing about it +// is a compile error and nothing about it fails loudly — the caller simply gets +// told they have access Mosaic has not granted. +// +// The second is that outcome and uncertainty_reason are paired by database CHECK +// constraints, so a wrong pairing is an insert failure at runtime rather than a +// build failure. The table below is the pairing table, asserted where it can be +// asserted cheaply. + +// fakeRepository records what the service decided to write. It deliberately +// implements no chain logic: the point is to observe the decision, not to +// re-simulate the database. +type fakeRepository struct { + enabled bool + job Job + chain ChainState + chainErr error + + completed *Decision + completedAs ChainState + rescheduled int + baseline *int64 + leased bool +} + +func (f *fakeRepository) BillingEnabled(context.Context, string) (bool, error) { + return f.enabled, nil +} + +func (f *fakeRepository) CreateJob(_ context.Context, job Job, _ []string, _ time.Time) (Job, error) { + return job, nil +} + +func (f *fakeRepository) LeaseJob(context.Context, string, time.Time, time.Time) (Job, bool, error) { + if f.leased { + return Job{}, false, nil + } + f.leased = true + return f.job, true, nil +} + +func (f *fakeRepository) LoadChain(context.Context, Job) (ChainState, error) { + return f.chain, f.chainErr +} + +func (f *fakeRepository) AdoptBaseline(_ context.Context, _ Job, customerID string, baseline int64, _ time.Time) error { + f.baseline = &baseline + f.job.CustomerID = customerID + return nil +} + +func (f *fakeRepository) CompleteJob(_ context.Context, _ Job, decision Decision, chain ChainState, _ time.Time) error { + if err := decision.Validate(); err != nil { + return err + } + f.completed = &decision + f.completedAs = chain + return nil +} + +func (f *fakeRepository) RescheduleJob(context.Context, Job, Decision, ChainState, time.Time, time.Time) error { + f.rescheduled++ + return nil +} + +func (f *fakeRepository) Job(context.Context, string, string, string) (Job, error) { + return f.job, nil +} + +func baselineOf(version int64) *int64 { return &version } + +func settledChain(customerID string, snapshotVersion int64) ChainState { + return ChainState{ + CustomerID: customerID, + LinkedInputCount: 1, + FactCount: 1, + ProjectionSettled: true, + SnapshotVersion: snapshotVersion, + } +} + +// TestRestoredRequiresTheSnapshotThatProvesIt is the load-bearing test of this +// package. +// +// Risk covered: reporting restored access that no accepted snapshot has granted. +// Every case below is a chain where the native restore succeeded and the facts +// validated — the exact situation in which a naive implementation answers +// `restored` — and none of them may produce it. +func TestRestoredRequiresTheSnapshotThatProvesIt(t *testing.T) { + base := Job{ + ID: "rst_1", ProjectID: "prj", EnvironmentID: "env", + StorePlatform: StoreApple, ProviderOutcome: ProviderOutcomeCompleted, + ObservedTransactionCount: 2, MaxAttempts: DefaultMaxAttempts, AttemptCount: 1, + RequestedAt: time.Unix(1700000000, 0).UTC(), + } + + t.Run("a snapshot that has not moved past the baseline is not a restore", func(t *testing.T) { + job := base + job.BaselineSnapshotVersion = baselineOf(5) + repository := &fakeRepository{enabled: true, job: job, chain: settledChain("cus_1", 5)} + service := NewService(repository, nil) + + if _, err := service.ProcessNextRestoreSync(context.Background(), "worker"); err != nil { + t.Fatalf("process restore: %v", err) + } + if repository.completed == nil { + t.Fatal("the restore should have reached a terminal outcome") + } + if repository.completed.Outcome == OutcomeRestored { + t.Fatalf("a snapshot still at the baseline was reported as %q", OutcomeRestored) + } + if repository.completed.Outcome != OutcomeNoAdditionalPurchases { + t.Fatalf("outcome = %q, want %q", repository.completed.Outcome, OutcomeNoAdditionalPurchases) + } + if version := repository.completed.SnapshotVersion(); version != 0 { + t.Fatalf("a non-restored outcome carried snapshot evidence %d", version) + } + }) + + t.Run("validated facts whose projection has not run are stale, not restored", func(t *testing.T) { + // This is the case the whole design turns on: the native restore + // succeeded, the facts are validated and attached to the customer, and + // the only thing missing is the projection. Access has not been granted + // yet, so the answer is not restored — and the job is not even terminal. + job := base + job.BaselineSnapshotVersion = baselineOf(5) + chain := settledChain("cus_1", 5) + chain.ProjectionSettled = false + repository := &fakeRepository{enabled: true, job: job, chain: chain} + service := NewService(repository, nil) + + if _, err := service.ProcessNextRestoreSync(context.Background(), "worker"); err != nil { + t.Fatalf("process restore: %v", err) + } + if repository.completed != nil { + t.Fatalf("an unfinished chain reached a terminal outcome %q", + repository.completed.Outcome) + } + if repository.rescheduled != 1 { + t.Fatalf("reschedules = %d, want 1", repository.rescheduled) + } + }) + + t.Run("no baseline means no restore, however far the version has moved", func(t *testing.T) { + job := base + job.BaselineSnapshotVersion = nil + repository := &fakeRepository{enabled: true, job: job, chain: settledChain("cus_1", 99)} + service := NewService(repository, nil) + + if _, err := service.ProcessNextRestoreSync(context.Background(), "worker"); err != nil { + t.Fatalf("process restore: %v", err) + } + if repository.completed != nil && repository.completed.Outcome == OutcomeRestored { + t.Fatal("restored was reported against a baseline that was never captured") + } + if repository.baseline == nil || *repository.baseline != 99 { + t.Fatalf("the baseline should have been adopted at the version seen when identity resolved, got %v", + repository.baseline) + } + }) + + t.Run("an advanced snapshot is a restore and carries its evidence", func(t *testing.T) { + job := base + job.BaselineSnapshotVersion = baselineOf(5) + repository := &fakeRepository{enabled: true, job: job, chain: settledChain("cus_1", 6)} + service := NewService(repository, nil) + + if _, err := service.ProcessNextRestoreSync(context.Background(), "worker"); err != nil { + t.Fatalf("process restore: %v", err) + } + if repository.completed == nil || repository.completed.Outcome != OutcomeRestored { + t.Fatalf("an advanced snapshot should be %q, got %+v", OutcomeRestored, repository.completed) + } + if version := repository.completed.SnapshotVersion(); version != 6 { + t.Fatalf("snapshot evidence = %d, want 6", version) + } + }) + + t.Run("a restored decision assembled outside Decide carries no evidence", func(t *testing.T) { + // This is the structural half of the invariant. A future caller that + // builds the outcome by hand gets a decision that proves nothing and is + // refused before it reaches a row. + forged := Decision{Outcome: OutcomeRestored, UncertaintyReason: ReasonNone} + if version := forged.SnapshotVersion(); version != 0 { + t.Fatalf("a hand-built restored decision reported evidence %d", version) + } + if err := forged.Validate(); err == nil { + t.Fatal("a restored decision without evidence was accepted") + } + }) +} + +// TestChainStateOutcomeTable pins the chain-state-to-outcome mapping. +// +// Risk covered: the schema pairs `outcome` with `uncertainty_reason` under CHECK +// constraints — a definite outcome may not carry a reason, and an uncertain one +// must. A wrong pairing therefore fails as a database insert error on a live +// restore rather than at build time, and the caller polling that restore gets a +// 500 instead of an answer. The table asserts both the mapping and, through +// Validate, that every pairing it produces is one the schema will accept. +func TestChainStateOutcomeTable(t *testing.T) { + job := Job{ + ID: "rst_1", ObservedTransactionCount: 1, MaxAttempts: DefaultMaxAttempts, + BaselineSnapshotVersion: baselineOf(3), + } + + cases := []struct { + name string + job Job + chain ChainState + outcome string + reason string + terminal bool + }{ + { + name: "a dead-lettered projection is a failure Mosaic owns", + chain: ChainState{CustomerID: "cus_1", FactCount: 1, ProjectionFailed: true}, + outcome: OutcomeFailed, + reason: ReasonProjectionFailed, + terminal: true, + }, + { + name: "a disputed identity with no customer resolves to nobody", + chain: ChainState{IdentityConflict: true, FactCount: 1}, + outcome: OutcomeIdentityUnresolved, + reason: ReasonConflictingFacts, + terminal: true, + }, + { + name: "a disputed identity that already named a customer fails rather than granting", + chain: ChainState{CustomerID: "cus_1", IdentityConflict: true, FactCount: 1}, + outcome: OutcomeFailed, + reason: ReasonConflictingFacts, + terminal: true, + }, + { + name: "an unmappable Product is its own outcome, not a failure", + chain: ChainState{CustomerID: "cus_1", FactCount: 1, ProductUnresolved: true}, + outcome: OutcomeProductUnresolved, + reason: ReasonProductUnresolved, + terminal: false, + }, + { + name: "waiting on the store is reported as the store being unavailable", + chain: ChainState{LinkedInputCount: 1, PendingValidationCount: 1, ProviderUnavailable: true}, + outcome: OutcomeProviderUnavailable, + reason: ReasonProviderUnavailable, + terminal: false, + }, + { + name: "waiting on Mosaic is reported as validation pending", + chain: ChainState{LinkedInputCount: 1, PendingValidationCount: 1}, + outcome: OutcomeValidationPending, + reason: ReasonMissingFact, + terminal: false, + }, + { + name: "an input that permanently failed validation fails the restore", + chain: ChainState{LinkedInputCount: 1, PermanentFailure: true}, + outcome: OutcomeFailed, + reason: ReasonUnsupportedProviderState, + terminal: true, + }, + { + name: "no customer once validation settled is an unresolved identity", + chain: ChainState{LinkedInputCount: 1, FactCount: 1}, + outcome: OutcomeIdentityUnresolved, + reason: ReasonIdentityUnresolved, + terminal: false, + }, + { + name: "a native restore that found nothing changed nothing", + job: Job{ID: "rst_1", ObservedTransactionCount: 0, MaxAttempts: DefaultMaxAttempts}, + chain: ChainState{}, + outcome: OutcomeNoAdditionalPurchases, + reason: ReasonNone, + terminal: true, + }, + { + name: "facts attached but the projection has not caught up is stale, not restored", + chain: ChainState{CustomerID: "cus_1", FactCount: 1, SnapshotVersion: 3}, + outcome: OutcomeValidationPending, + reason: ReasonStaleValidation, + terminal: false, + }, + { + name: "a settled projection that did not move the version restored nothing new", + chain: settledChain("cus_1", 3), + outcome: OutcomeNoAdditionalPurchases, + reason: ReasonNone, + terminal: true, + }, + { + name: "a settled projection past the baseline is a restore", + chain: settledChain("cus_1", 4), + outcome: OutcomeRestored, + reason: ReasonNone, + terminal: true, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + subject := job + if testCase.job.ID != "" { + subject = testCase.job + } + decision := Decide(subject, testCase.chain) + if decision.Outcome != testCase.outcome { + t.Fatalf("outcome = %q, want %q", decision.Outcome, testCase.outcome) + } + if decision.UncertaintyReason != testCase.reason { + t.Fatalf("uncertainty reason = %q, want %q", decision.UncertaintyReason, testCase.reason) + } + if decision.Terminal != testCase.terminal { + t.Fatalf("terminal = %v, want %v", decision.Terminal, testCase.terminal) + } + // Every pairing the decision table can produce must be one the + // schema's CHECK constraints accept. + if err := decision.Validate(); err != nil { + t.Fatalf("the decision table produced a pairing the schema would reject: %v", err) + } + }) + } +} + +// TestChainReadFailureSurfacesToTheWorker is the regression for defect D-2's +// aggravating factor. +// +// A LoadChain error used to be absorbed: the job was rescheduled and the method +// returned (true, nil), which is the shape of healthy work. The stage-3 read +// referenced a column that does not exist, so every restore in the Environment +// was failing permanently while the worker's (processed, error) contract said +// nothing was wrong, `restoreFailedJobs` stayed at zero, and only the backlog +// rose. A failure nothing can observe is a failure nobody is paged for. +// +// Two properties are asserted: the error reaches the caller while attempts +// remain, and an exhausted job becomes terminally `failed` — which is the row +// state `restoreFailedJobs` counts — rather than being rescheduled forever. +func TestChainReadFailureSurfacesToTheWorker(t *testing.T) { + base := Job{ + ID: "rst_chain", ProjectID: "prj", EnvironmentID: "env", + StorePlatform: StoreApple, ProviderOutcome: ProviderOutcomeCompleted, + MaxAttempts: DefaultMaxAttempts, RequestedAt: time.Unix(1700000000, 0).UTC(), + } + readFailure := errors.New("read restore identity chain") + + t.Run("a retryable read failure is rescheduled and still reported", func(t *testing.T) { + repository := &fakeRepository{enabled: true, job: base, chainErr: readFailure} + repository.job.AttemptCount = 1 + service := NewService(repository, nil) + + processed, err := service.ProcessNextRestoreSync(context.Background(), "worker") + if !processed { + t.Fatal("the job was leased, so the worker must be told work was processed") + } + if !errors.Is(err, readFailure) { + t.Fatalf("error = %v, want the chain read failure to reach the worker loop", err) + } + if repository.rescheduled != 1 { + t.Fatalf("rescheduled %d times, want 1", repository.rescheduled) + } + if repository.completed != nil { + t.Fatal("a transient read failure must not write a terminal outcome") + } + }) + + t.Run("an exhausted read failure becomes a counted failed job", func(t *testing.T) { + repository := &fakeRepository{enabled: true, job: base, chainErr: readFailure} + repository.job.AttemptCount = DefaultMaxAttempts + service := NewService(repository, nil) + + processed, err := service.ProcessNextRestoreSync(context.Background(), "worker") + if !processed { + t.Fatal("the job was leased, so the worker must be told work was processed") + } + if !errors.Is(err, readFailure) { + t.Fatalf("error = %v, want the chain read failure to reach the worker loop", err) + } + if repository.completed == nil { + t.Fatal("an exhausted restore that could never be read must reach a terminal outcome") + } + if repository.completed.Outcome != OutcomeFailed { + t.Fatalf("outcome = %q, want %q so restoreFailedJobs counts it", + repository.completed.Outcome, OutcomeFailed) + } + if repository.rescheduled != 0 { + t.Fatal("an exhausted job was rescheduled instead of failed") + } + }) +} diff --git a/apps/api/internal/billingrestore/wire.go b/apps/api/internal/billingrestore/wire.go new file mode 100644 index 00000000..b39b1b50 --- /dev/null +++ b/apps/api/internal/billingrestore/wire.go @@ -0,0 +1,162 @@ +package billingrestore + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" +) + +// This file is the only place a restoreResult record is produced. +// +// Records are built as map[string]any rather than as tagged structs for the +// same reason the access surfaces do it: the contract closes every object with +// additionalProperties:false and forbids null, and a tagged struct with pointer +// fields puts "absent" and "null" one keystroke apart. A map cannot carry a +// member it was not given. + +// ContractTimestamp renders an instant in the contract's fixed form: RFC 3339 +// UTC with exactly three fractional digits and a literal Z. +func ContractTimestamp(at time.Time) string { + return at.UTC().Format("2006-01-02T15:04:05.000Z") +} + +// CanonicalJSON renders the contract's canonical serialization: minified, keys +// ascending at every depth, absent members omitted, minimal escaping. +func CanonicalJSON(value any) ([]byte, error) { + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return nil, fmt.Errorf("serialize restore record: %w", err) + } + return bytes.TrimRight(buffer.Bytes(), "\n"), nil +} + +// RestoreRecord renders one restore as an Authoritative Entitlement Contract v1 +// restoreResult. +// +// The two axes stay separate throughout: `outcome` is Mosaic's authoritative +// answer and `providerOutcome` is what the native restore did, and neither is +// ever derived from the other. +// +// A job that has not reached a terminal outcome is reported as +// `validation_pending` rather than as an absent outcome. The contract has no +// "still working" member, and the honest reading of an unfinished chain is that +// Mosaic cannot yet confirm anything — which is exactly what validation_pending +// means. +func RestoreRecord(view View) (map[string]any, error) { + job := view.Job + + outcome, reason := job.Outcome, job.UncertaintyReason + if outcome == "" { + outcome = OutcomeValidationPending + if reason == "" || reason == ReasonNone { + reason = ReasonMissingFact + } + } + + payload := map[string]any{ + "restoreId": job.ID, + "projectId": job.ProjectID, + "environmentId": job.EnvironmentID, + "storePlatform": job.StorePlatform, + "outcome": outcome, + "providerOutcome": job.ProviderOutcome, + "requestedAt": ContractTimestamp(job.RequestedAt), + "observedTransactionCount": job.ObservedTransactionCount, + "correlationId": SafeCorrelation(job.CorrelationID), + } + + // identity_unresolved is the one outcome that must carry no customer: the + // whole meaning of the answer is that Mosaic does not know whose purchase + // this is, and naming a customer beside it would contradict it. + if job.CustomerID != "" && outcome != OutcomeIdentityUnresolved { + payload["billingCustomerId"] = job.CustomerID + } + if job.CompletedAt != nil { + payload["completedAt"] = ContractTimestamp(*job.CompletedAt) + } + // The snapshot version is emitted only with `restored`. It is that outcome's + // evidence, and attaching it to any other outcome would suggest the restore + // had been reflected when it had not. + if outcome == OutcomeRestored { + if job.SnapshotVersion == nil || *job.SnapshotVersion < 1 { + return nil, ErrUnprovenRestore + } + payload["snapshotVersion"] = *job.SnapshotVersion + } + if outcome == OutcomeValidationPending { + payload["pendingValidationCount"] = job.PendingValidationCount + } + if outcome != OutcomeRestored && outcome != OutcomeNoAdditionalPurchases { + if reason == "" || reason == ReasonNone { + return nil, ErrInvalidOutcome + } + since := view.EvaluatedAt + if since.IsZero() { + since = job.UpdatedAt + } + payload["uncertainty"] = uncertaintyRecord(reason, since) + } + + return map[string]any{ + "authoritativeEntitlementContractVersion": ContractVersion, + "recordType": "restoreResult", + "payload": payload, + }, nil +} + +func uncertaintyRecord(reason string, since time.Time) map[string]any { + record := map[string]any{ + "reason": reason, + "since": ContractTimestamp(since), + } + if resolution := expectedResolutionFor(reason); resolution != "" { + record["expectedResolution"] = resolution + } + return record +} + +// expectedResolutionFor is guidance for a caller deciding whether to poll +// again, not a promise. It matches the mapping the snapshot surfaces use, so a +// reader never sees the same reason resolve two different ways. +func expectedResolutionFor(reason string) string { + switch reason { + case ReasonProviderUnavailable, ReasonStaleValidation: + return "automatic_retry" + case ReasonMissingFact: + return "next_provider_notification" + case ReasonProjectionFailed: + return "next_projection_run" + case ReasonIdentityUnresolved, ReasonConflictingFacts, ReasonProductUnresolved, + ReasonUnsupportedProviderState: + return "operator_action" + default: + return "" + } +} + +// SafeCorrelation reduces a caller-supplied correlation id to the contract's +// identifier charset. A correlation id travels into logs and spans, so it is +// filtered rather than trusted. +func SafeCorrelation(value string) string { + value = strings.TrimSpace(value) + if len(value) > 128 { + value = value[:128] + } + cleaned := make([]rune, 0, len(value)) + for index, char := range value { + switch { + case char >= 'A' && char <= 'Z', char >= 'a' && char <= 'z', char >= '0' && char <= '9': + cleaned = append(cleaned, char) + case len(cleaned) > 0 && index > 0 && (char == '.' || char == '_' || char == ':' || char == '-'): + cleaned = append(cleaned, char) + } + } + if len(cleaned) == 0 { + return "mosaic" + } + return string(cleaned) +} diff --git a/apps/api/internal/billingwebhook/errors.go b/apps/api/internal/billingwebhook/errors.go new file mode 100644 index 00000000..c262ba26 --- /dev/null +++ b/apps/api/internal/billingwebhook/errors.go @@ -0,0 +1,36 @@ +package billingwebhook + +import "errors" + +// Stable domain errors. Handlers map these in one place; nothing anywhere +// compares an error-message string. +var ( + // ErrUnauthenticated is a missing operator identity. + ErrUnauthenticated = errors.New("the request could not be authenticated") + // 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. + ErrNotFound = errors.New("the requested resource was not found") + ErrInvalid = errors.New("the request is not valid") + // ErrConflict is a state transition the resource does not permit, including + // deleting a destination that still has delivery history. + ErrConflict = errors.New("the resource is in a conflicting state") + // ErrBillingDisabled is returned when the Project has asked Mosaic to hold + // no billing state. It fails closed: an unreadable setting is treated as + // disabled. + ErrBillingDisabled = errors.New("billing is not enabled for this Project") + // ErrUnavailable is a storage failure. It is never the destination's fault + // and never reaches the destination. + ErrUnavailable = errors.New("webhook storage is unavailable") + + // ErrDestinationRefused is an SSRF-policy refusal, returned to the operator + // configuring the destination and recorded as a permanent delivery failure. + // It deliberately does not say which rule refused: the resolved address is + // information about Mosaic's own network position. + ErrDestinationRefused = errors.New("the destination address is not allowed") + // ErrSecretUnavailable means a signing secret could not be sealed or + // opened. Delivery stops rather than sending an unsigned or wrongly signed + // body, because an unsigned entitlement webhook is an unauthenticated + // instruction to grant access. + ErrSecretUnavailable = errors.New("the signing secret is unavailable") +) diff --git a/apps/api/internal/billingwebhook/model.go b/apps/api/internal/billingwebhook/model.go new file mode 100644 index 00000000..999c8b83 --- /dev/null +++ b/apps/api/internal/billingwebhook/model.go @@ -0,0 +1,313 @@ +// Package billingwebhook owns application-webhook destinations, signing, the +// SSRF policy applied to operator-supplied URLs, and delivery. +// +// It implements ADR-0024. Three things in that decision shape everything here: +// the signature binds scheme version, timestamp, event id, and body together; +// a destination may hold more than one signing secret while a rotation is in +// flight; and delivery is a notification path that may never roll back or +// block committed customer state. +package billingwebhook + +import "time" + +// ContractVersion is the Billing State Webhook Contract this package speaks. +// It appears on the stored envelope, never assembled here — the projection +// transaction writes the complete body and delivery sends those exact bytes. +const ContractVersion = "1" + +// EventTypeEntitlementsChanged is the one event type Phase 9B emits. The +// contract declares ten; the other nine are reserved vocabulary. +const EventTypeEntitlementsChanged = "customer.entitlements.changed" + +// Destination lifecycle. These are the words the `webhook_destinations` CHECK +// stores, not a parallel Go vocabulary that would need translating. +const ( + // DestinationActive receives deliveries. + DestinationActive = "active" + // DestinationPaused is an operator's temporary stop. Events still fan out + // and are recorded as skipped, so the gap is visible afterwards. + DestinationPaused = "paused" + // DestinationDisabled is terminal until an operator re-enables it. Mosaic + // sets it automatically after a bounded run of exhausted deliveries. + DestinationDisabled = "disabled" +) + +// Delivery status, matching the `webhook_deliveries` CHECK. +const ( + DeliveryPending = "pending" + DeliverySucceeded = "succeeded" + DeliveryFailed = "failed" + DeliveryExhausted = "exhausted" + DeliverySkipped = "skipped" +) + +// Attempt outcome, matching the `webhook_delivery_attempts` CHECK. +const ( + OutcomeDelivered = "delivered" + OutcomeRetryableFailure = "retryable_failure" + OutcomePermanentFailure = "permanent_failure" + OutcomeExhausted = "exhausted" + OutcomeSkipped = "skipped" +) + +// Skip reasons, matching both CHECKs and the delivery schema enumeration. +const ( + SkippedDestinationDisabled = "destination_disabled" + SkippedEventTypeNotEnabled = "event_type_not_enabled" + SkippedDestinationDeleted = "destination_deleted" + SkippedTenantSuspended = "tenant_suspended" +) + +// Signing secret status, matching the `webhook_signing_secrets` CHECK. +const ( + SecretActive = "active" + SecretRetired = "retired" +) + +// Auto-disable reasons. Mosaic-owned stable codes, kept distinct from the +// free-text reason an operator writes when disabling by hand. +const ( + AutoDisabledConsecutiveExhausted = "consecutive_exhausted_deliveries" + AutoDisabledDestinationRefused = "destination_refused" +) + +const ( + // DefaultMaxAttempts matches the column default. Eight attempts across the + // backoff schedule spans roughly forty minutes, which outlasts an ordinary + // receiver deployment without holding a queue slot for days. + DefaultMaxAttempts = 8 + // MaxAttemptsCeiling is the contract's bound on an attempt number. + MaxAttemptsCeiling = 32 + // AutoDisableThreshold is how many exhausted deliveries in a row disable a + // destination. Three is a run, not an incident: a destination that has + // failed every attempt of three separate events is not coming back on its + // own, and any single success resets the count. + AutoDisableThreshold = 3 + // RotationOverlap is how long a retired secret keeps signing. Twenty-four + // hours is long enough for a receiver deploy to reach every instance and + // short enough that a secret rotated after a suspected compromise is out of + // use within a day. + RotationOverlap = 24 * time.Hour + // SecretRandomBytes is the entropy behind a signing secret. + SecretRandomBytes = 32 + // SecretPrefix marks the value in a receiver's own configuration so a + // secret pasted into the wrong field is recognizable. + SecretPrefix = "whsec_" + // MaxResponseExcerpt is the contract's ceiling on a stored excerpt. + MaxResponseExcerpt = 240 + // DeliveryLease bounds how long one worker may hold a delivery. It must + // exceed the total request timeout, or a slow destination would let a + // second worker claim a delivery that is still in flight. + DeliveryLease = 90 * time.Second + // FanOutBatch bounds how many committed events one poll expands. + FanOutBatch = 50 + // FanOutHorizon bounds how far back the fan-out scan looks for events that + // have never been expanded. It is generous on purpose: the marker table + // makes the scan cheap, and the horizon exists only so a table that has + // grown for years is not re-examined from the beginning on every poll. + FanOutHorizon = 30 * 24 * time.Hour +) + +// Actor is the authenticated operator behind a management call. Delivery has +// no actor: the worker acts for Mosaic. +type Actor struct{ ID string } + +// Destination is the operator-facing view of a webhook destination. It never +// 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"` + + SecretLastRotatedAt *time.Time `json:"secretLastRotatedAt,omitempty"` + DisabledReason string `json:"disabledReason,omitempty"` + ConsecutiveFailureCount int `json:"consecutiveFailureCount"` + AutoDisabledAt *time.Time `json:"autoDisabledAt,omitempty"` + AutoDisableReason string `json:"autoDisableReason,omitempty"` +} + +// Deliverable reports whether this destination should be attempted for an +// event of the given type. A destination that is not deliverable produces a +// recorded skip rather than nothing at all. +func (d Destination) Deliverable(eventType string) (bool, string) { + if d.Status != DestinationActive { + return false, SkippedDestinationDisabled + } + for _, candidate := range d.EventTypes { + if candidate == eventType { + return true, "" + } + } + return false, SkippedEventTypeNotEnabled +} + +// DestinationWithSecret is the create and rotate response. The secret exists +// in this struct and nowhere else in Mosaic: it is sealed on the way to +// storage and this plaintext is never written to a log, a span, or a second +// read of the same resource. +type DestinationWithSecret struct { + Destination Destination `json:"destination"` + // Secret is displayed exactly once. + Secret string `json:"secret"` + // SecretID names the row so a later explicit retirement can address it. + SecretID string `json:"secretId"` + // PreviousSecretHonoredUntil is set by a rotation: until this instant the + // superseded secret still signs, so a receiver may adopt the new one at its + // own pace. It is absent on a create, which has no previous secret. + PreviousSecretHonoredUntil *time.Time `json:"previousSecretHonoredUntil,omitempty"` +} + +// SecretMetadata describes a signing secret without revealing it. +type SecretMetadata struct { + ID string `json:"id"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` + HonoredUntil *time.Time `json:"honoredUntil,omitempty"` +} + +// SealedSecret is a signing secret on its way to storage. The plaintext is +// already gone by the time this exists. +type SealedSecret struct { + ID string + EnvelopeVersion int + Algorithm string + KeyID string + Nonce []byte + Ciphertext []byte + Fingerprint []byte +} + +// Delivery is one event's journey to one destination. +type Delivery struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + EventID string `json:"eventId"` + DestinationID string `json:"destinationId"` + Status string `json:"status"` + SkippedReason string `json:"skippedReason,omitempty"` + AttemptCount int `json:"attemptCount"` + MaxAttempts int `json:"maxAttempts"` + NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CompletedAt *time.Time `json:"completedAt,omitempty"` +} + +// Attempt is one recorded try. It is append-only history and is never sent to +// a destination: the contract's webhookDeliveryAttempt record is API-facing +// only, carries no URL and no secret, and this struct matches that. +type Attempt struct { + ID string `json:"id"` + DeliveryID string `json:"deliveryId"` + EventID string `json:"eventId"` + DestinationID string `json:"destinationId"` + AttemptNumber int `json:"attempt"` + MaxAttempts int `json:"maxAttempts"` + Outcome string `json:"outcome"` + ResponseStatus *int `json:"responseStatusCode,omitempty"` + ResponseExcerpt string `json:"responseExcerpt,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + LatencyMS *int `json:"latencyMs,omitempty"` + SkippedReason string `json:"skippedReason,omitempty"` + AttemptedAt time.Time `json:"requestedAt"` + RespondedAt *time.Time `json:"respondedAt,omitempty"` + NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"` +} + +// LeasedDelivery is everything one delivery attempt needs, read in the same +// transaction that claimed the row. +// +// It carries the stored body verbatim. Delivery must never re-render the +// envelope: the signature covers exact bytes, so a re-serialization that +// reorders one member produces a signature no receiver can reproduce. +type LeasedDelivery struct { + Delivery Delivery + Destination Destination + OrganizationID string + EventType string + // Body is the committed Billing State Webhook envelope, exactly as stored. + Body []byte + // Secrets are every secret still permitted to sign, newest first. More than + // one means a rotation is in its overlap window. + Secrets []StoredSecret +} + +// StoredSecret is a sealed secret read for signing. +type StoredSecret struct { + SealedSecret + Status string + HonoredUntil *time.Time +} + +// DestinationInput is a create or update request after transport validation. +type DestinationInput struct { + ProjectID string + EnvironmentID string + URL string + EventTypes []string + Description string +} + +// 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 +} + +// AttemptResult is the outcome of one delivery attempt, applied to the +// delivery row and appended to history in one transaction. +type AttemptResult struct { + Delivery Delivery + AttemptNumber int + Outcome string + // Status is the delivery's resulting status. + Status string + ErrorCode string + // SkippedReason is set only for a skipped outcome, where the column CHECK + // requires it and forbids it everywhere else. + SkippedReason string + ResponseStatus *int + // ResponseExcerpt is already bounded and control-character-free by the time + // it reaches here. + ResponseExcerpt string + LatencyMS int + AttemptedAt time.Time + RespondedAt *time.Time + NextAttemptAt *time.Time + CompletedAt *time.Time + // ResetDestinationFailures clears the auto-disable counter, and + // IncrementDestinationFailures advances it. Exactly one may be set. + ResetDestinationFailures bool + IncrementDestinationFailures bool +} + +// DeliveryFilter bounds an operator's read of delivery history. +type DeliveryFilter struct { + EnvironmentID string + EventID string + DestinationID string + Status string + Limit int +} + +// Bounded applies the page ceiling. +func (f DeliveryFilter) Bounded() DeliveryFilter { + if f.Limit <= 0 { + f.Limit = 50 + } + if f.Limit > 200 { + f.Limit = 200 + } + return f +} diff --git a/apps/api/internal/billingwebhook/repository.go b/apps/api/internal/billingwebhook/repository.go new file mode 100644 index 00000000..7ebfdb68 --- /dev/null +++ b/apps/api/internal/billingwebhook/repository.go @@ -0,0 +1,89 @@ +package billingwebhook + +import ( + "context" + "time" +) + +// Repository is the persistence port for destinations, secrets, and delivery. +// +// Every method that reads or writes a tenant-owned row takes projectID +// explicitly and filters on it. Tenant isolation is a property of the query +// 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 { + // 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) + // OrganizationForProject resolves the organization that owns a Project. The + // organization is part of the envelope's additional authenticated data, so + // it must be known before a secret is sealed rather than discovered during + // the insert. + OrganizationForProject(ctx context.Context, projectID string) (string, error) + + // --- Destinations -------------------------------------------------------- + + // CreateDestination writes the destination, its first signing secret, and + // the audit event in one transaction. A destination that exists without a + // secret could never sign a delivery, so the two are never separate + // commits. + CreateDestination(ctx context.Context, destination Destination, secret SealedSecret, actorID string, now time.Time) (Destination, error) + ListDestinations(ctx context.Context, projectID, environmentID string) ([]Destination, error) + Destination(ctx context.Context, projectID, destinationID string) (Destination, error) + UpdateDestination(ctx context.Context, projectID, destinationID string, update DestinationUpdate, actorID string, now time.Time) (Destination, error) + // SetDestinationStatus is the operator-driven transition. reason is free + // text an operator supplied and is stored separately from the Mosaic-owned + // auto-disable code. + SetDestinationStatus(ctx context.Context, projectID, destinationID, status, reason, actorID string, now time.Time) (Destination, error) + // AutoDisableDestination is the automatic transition. It sets the + // Mosaic-owned reason code and writes its own audit event, because a + // destination that stops receiving events without a recorded cause is an + // outage an operator cannot explain. + AutoDisableDestination(ctx context.Context, projectID, destinationID, reason string, now time.Time) error + // DeleteDestination removes a destination that has no delivery history. + // Delivery rows reference it with ON DELETE RESTRICT, so history is the + // guard: an operator disables a destination they are finished with, and + // deleting one would erase the record of what it was sent. + DeleteDestination(ctx context.Context, projectID, destinationID, actorID string, now time.Time) error + + // --- Signing secrets ----------------------------------------------------- + + // RotateSecret adds a new active secret and retires every previously active + // one with honoredUntil set, so the superseded secrets keep signing through + // the overlap window. It is one transaction: a rotation that added the new + // secret and failed to schedule the old one's retirement would leave two + // permanently active secrets. + RotateSecret(ctx context.Context, projectID, destinationID string, secret SealedSecret, honoredUntil time.Time, actorID string, now time.Time) (SecretMetadata, error) + ListSecrets(ctx context.Context, projectID, destinationID string) ([]SecretMetadata, error) + // RetireSecret ends a secret's life immediately, ignoring any remaining + // overlap. This is the action taken after a suspected compromise, and it is + // audited. + RetireSecret(ctx context.Context, projectID, destinationID, secretID, actorID string, now time.Time) (SecretMetadata, error) + + // --- Delivery ------------------------------------------------------------ + + // FanOut expands committed events that have never been expanded into one + // delivery per destination, recording a skipped delivery for a destination + // that is not eligible. It returns how many events were expanded. + FanOut(ctx context.Context, now time.Time, limit int) (int, error) + // LeaseDelivery claims one due delivery with SELECT ... FOR UPDATE SKIP + // LOCKED and commits the claim before returning. The transaction is closed + // by the time the caller makes an HTTP request: no lock is ever held across + // the network. + LeaseDelivery(ctx context.Context, workerID string, now, leaseUntil time.Time) (LeasedDelivery, bool, error) + // CompleteAttempt appends the attempt and applies the resulting delivery + // state in one transaction, and returns the destination's consecutive + // failure count afterwards so the caller can apply the auto-disable policy. + CompleteAttempt(ctx context.Context, result AttemptResult) (int, error) + + ListDeliveries(ctx context.Context, projectID string, filter DeliveryFilter) ([]Delivery, error) + Delivery(ctx context.Context, projectID, deliveryID string) (Delivery, error) + ListAttempts(ctx context.Context, projectID, deliveryID string) ([]Attempt, error) + // ReplayDelivery returns a terminal delivery to the queue with a fresh + // attempt budget. The event id is unchanged, so a receiver deduplicating on + // it sees the change once however many times an operator replays. + ReplayDelivery(ctx context.Context, projectID, deliveryID, actorID string, now time.Time) (Delivery, error) + + // RecordAudit writes an audit event for a sensitive mutation. + RecordAudit(ctx context.Context, projectID, environmentID, actorID, action, resourceID string, metadata map[string]string, at time.Time) error +} diff --git a/apps/api/internal/billingwebhook/rotation_test.go b/apps/api/internal/billingwebhook/rotation_test.go new file mode 100644 index 00000000..3dd89081 --- /dev/null +++ b/apps/api/internal/billingwebhook/rotation_test.go @@ -0,0 +1,135 @@ +package billingwebhook + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +// Rotation is the one place where "which secrets sign this delivery?" has more +// than one right answer, and getting it wrong is invisible until a tenant's +// receiver starts rejecting deliveries. +// +// Two failures are protected here. Signing with only the newest secret breaks +// every receiver that has not yet redeployed, which is the outage the overlap +// exists to prevent. Continuing to sign with a secret whose window has lapsed — +// or one an operator explicitly retired after a suspected compromise — leaves +// the compromised secret able to authenticate Mosaic's own traffic, which is +// the reason retirement exists at all. + +// plaintextCipher seals by copying. It still enforces the subject scope, so a +// secret read under the wrong destination fails to open exactly as the real +// AES-GCM additional-authenticated-data binding makes it fail. +type plaintextCipher struct { + boundTo providercredential.SubjectScope +} + +func (c *plaintextCipher) EncryptSubject(plaintext []byte, scope providercredential.SubjectScope) (providercredential.Envelope, error) { + c.boundTo = scope + return providercredential.Envelope{ + Version: 1, Algorithm: "test", KeyID: "test", + Ciphertext: append([]byte(nil), plaintext...), CredentialClass: scope.CredentialClass, + }, nil +} + +func (c *plaintextCipher) DecryptSubject(envelope providercredential.Envelope, scope providercredential.SubjectScope) ([]byte, error) { + if scope.SubjectKind != providercredential.SubjectWebhookSigningSecret || scope.SubjectID == "" { + return nil, ErrSecretUnavailable + } + return append([]byte(nil), envelope.Ciphertext...), nil +} + +func (c *plaintextCipher) ActiveKeyID() string { return "test" } + +func sealedFor(value, status string, honoredUntil *time.Time) StoredSecret { + return StoredSecret{ + SealedSecret: SealedSecret{ + ID: "whs_" + value, EnvelopeVersion: 1, Algorithm: "test", KeyID: "test", + Ciphertext: []byte(value), + }, + Status: status, HonoredUntil: honoredUntil, + } +} + +func TestOverlapSignsWithEverySecretStillHonored(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + service := NewService(nil, &plaintextCipher{}, NewPolicy(), WithClock(func() time.Time { return now })) + + insideWindow := now.Add(6 * time.Hour) + lapsed := now.Add(-1 * time.Minute) + + leased := LeasedDelivery{ + Delivery: Delivery{ID: "whdl_1", ProjectID: "prj_1", DestinationID: "whd_1", EventID: "evt_1"}, + OrganizationID: "org_1", + Body: []byte(`{"payload":{}}`), + Secrets: []StoredSecret{ + // Deliberately out of order: the newest active secret must lead the + // header regardless of the order storage returned. + sealedFor("superseded", SecretRetired, &insideWindow), + sealedFor("current", SecretActive, nil), + // Its window has passed. It must not sign again. + sealedFor("expired", SecretRetired, &lapsed), + // Retired with no window at all: an explicit retirement. + sealedFor("revoked", SecretRetired, nil), + }, + } + + secrets, err := service.openSecrets(context.Background(), leased) + if err != nil { + t.Fatalf("openSecrets: %v", err) + } + if len(secrets) != 2 { + t.Fatalf("signing with %d secrets (%v), want the active one and the one still inside its window", + len(secrets), secrets) + } + if secrets[0] != "current" { + t.Fatalf("first signature is from %q, want the active secret", secrets[0]) + } + if secrets[1] != "superseded" { + t.Fatalf("second signature is from %q, want the secret inside its overlap window", secrets[1]) + } + + timestamp := now.Unix() + signatures := make([]string, 0, len(secrets)) + for _, secret := range secrets { + signatures = append(signatures, Sign(secret, timestamp, leased.Delivery.EventID, leased.Body)) + } + header := Header(signatures, timestamp) + if strings.Count(header, "v1=") != 2 { + t.Fatalf("header carries %d v1 elements during a rotation, want one per signing secret: %q", + strings.Count(header, "v1="), header) + } + // A receiver that has adopted either secret must be able to verify. + for _, secret := range []string{"current", "superseded"} { + if !Verify(secret, timestamp, leased.Delivery.EventID, leased.Body, signatures) { + t.Fatalf("a receiver holding %q could not verify the delivery", secret) + } + } + // The lapsed and explicitly retired secrets must not. + for _, secret := range []string{"expired", "revoked"} { + if Verify(secret, timestamp, leased.Delivery.EventID, leased.Body, signatures) { + t.Fatalf("retired secret %q still signs deliveries", secret) + } + } +} + +// A destination with nothing left that can sign must fail the attempt rather +// than send an unsigned body. An unsigned entitlement webhook is an +// unauthenticated instruction to grant access, so "send it anyway" is never the +// safe degradation. +func TestNoHonoredSecretRefusesToSign(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + service := NewService(nil, &plaintextCipher{}, NewPolicy(), WithClock(func() time.Time { return now })) + lapsed := now.Add(-time.Second) + + _, err := service.openSecrets(context.Background(), LeasedDelivery{ + Delivery: Delivery{ID: "whdl_1", ProjectID: "prj_1", DestinationID: "whd_1"}, + Secrets: []StoredSecret{sealedFor("expired", SecretRetired, &lapsed)}, + }) + if err != ErrSecretUnavailable { + t.Fatalf("openSecrets with no honored secret = %v, want ErrSecretUnavailable", err) + } +} diff --git a/apps/api/internal/billingwebhook/service.go b/apps/api/internal/billingwebhook/service.go new file mode 100644 index 00000000..874c8925 --- /dev/null +++ b/apps/api/internal/billingwebhook/service.go @@ -0,0 +1,751 @@ +package billingwebhook + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + mathrand "math/rand/v2" + "net/http" + "sort" + "strings" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/jobtelemetry" + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +// Service owns destination management, secret rotation, and delivery. +// +// Delivery deliberately lives here rather than in a separate worker package: +// the decision of whether a failure is retryable, when the next attempt is +// due, and when a destination has failed often enough to disable is business +// behaviour, and splitting it from the destination lifecycle would put the +// auto-disable rule in one package and the thing it disables in another. +type Service struct { + repository Repository + cipher providercredential.SubjectCipher + policy *Policy + now func() time.Time + random io.Reader + jitter *mathrand.Rand + tracer trace.Tracer + userAgent string + + destinationsCreated metric.Int64Counter + secretsRotated metric.Int64Counter + deliveries metric.Int64Counter + deliveryLatency metric.Float64Histogram +} + +type Option func(*Service) + +func WithClock(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +func WithRandom(random io.Reader) Option { + return func(s *Service) { + if random != nil { + s.random = random + } + } +} + +// WithJitter makes the retry schedule deterministic for tests. +func WithJitter(source *mathrand.Rand) Option { + return func(s *Service) { s.jitter = source } +} + +func NewService(repository Repository, cipher providercredential.SubjectCipher, policy *Policy, options ...Option) *Service { + if policy == nil { + policy = NewPolicy() + } + meter := otel.Meter("mosaic/billingwebhook") + service := &Service{ + repository: repository, + cipher: cipher, + policy: policy, + now: func() time.Time { return time.Now().UTC() }, + random: rand.Reader, + jitter: mathrand.New(mathrand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)), + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/billingwebhook"), + userAgent: "Mosaic-Webhooks/1", + } + service.destinationsCreated, _ = meter.Int64Counter("mosaic.billing.webhook.destination.created") + service.secretsRotated, _ = meter.Int64Counter("mosaic.billing.webhook.secret.rotated") + service.deliveries, _ = meter.Int64Counter("mosaic.billing.webhook.delivery.attempts") + service.deliveryLatency, _ = meter.Float64Histogram("mosaic.billing.webhook.delivery.latency", + metric.WithUnit("ms")) + for _, option := range options { + option(service) + } + return service +} + +// --------------------------------------------------------------------------- +// Destinations +// --------------------------------------------------------------------------- + +// CreateDestination registers a destination and mints its first signing secret. +// +// The URL is screened before anything is written, so an operator learns +// immediately that the address is refused rather than discovering it when the +// first entitlement change fails to deliver hours later. The returned secret +// is the only time it exists outside the caller's process. +func (s *Service) CreateDestination(ctx context.Context, actor Actor, input DestinationInput) (DestinationWithSecret, error) { + ctx, span := s.tracer.Start(ctx, "billing.webhook.destination.create") + defer span.End() + + if actor.ID == "" { + return DestinationWithSecret{}, ErrUnauthenticated + } + if err := s.requireEnabled(ctx, input.ProjectID); err != nil { + return DestinationWithSecret{}, err + } + eventTypes, err := normalizeEventTypes(input.EventTypes) + if err != nil { + return DestinationWithSecret{}, err + } + // Registration-time screening. It runs again at every delivery attempt. + if _, err := s.policy.Check(ctx, input.URL); err != nil { + return DestinationWithSecret{}, err + } + + destinationID, err := s.newID("whd") + if err != nil { + return DestinationWithSecret{}, err + } + secretValue, sealed, err := s.mintSecret(ctx, input.ProjectID, destinationID) + if err != nil { + return DestinationWithSecret{}, err + } + + 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, + }, sealed, actor.ID, now) + if err != nil { + return DestinationWithSecret{}, err + } + + s.destinationsCreated.Add(ctx, 1) + span.SetAttributes(attribute.String("mosaic.billing.webhook.destination.id", created.ID)) + // The destination id and Environment are safe to log. The URL is not + // logged: it is operator-supplied, may carry a path segment the operator + // treats as secret, and no branch of this method can reach a logger with + // it. + zerolog.Ctx(ctx).Info(). + Str("webhook_destination_id", created.ID). + Str("project_id", created.ProjectID). + Str("environment_id", created.EnvironmentID). + Msg("webhook destination created") + + return DestinationWithSecret{Destination: created, Secret: secretValue, SecretID: sealed.ID}, nil +} + +func (s *Service) ListDestinations(ctx context.Context, actor Actor, projectID, environmentID string) ([]Destination, error) { + if actor.ID == "" { + return nil, ErrUnauthenticated + } + 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 + } + return s.repository.Destination(ctx, projectID, destinationID) +} + +// 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.requireEnabled(ctx, projectID); err != nil { + return Destination{}, err + } + if update.URL != nil { + if _, err := s.policy.Check(ctx, *update.URL); err != nil { + return Destination{}, err + } + trimmed := strings.TrimSpace(*update.URL) + update.URL = &trimmed + } + if update.EventTypes != nil { + eventTypes, err := normalizeEventTypes(update.EventTypes) + if err != nil { + return Destination{}, err + } + update.EventTypes = eventTypes + } + return s.repository.UpdateDestination(ctx, projectID, destinationID, update, actor.ID, s.now()) +} + +// SetStatus pauses, resumes, or disables a destination. +// +// 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 + } + switch status { + case DestinationActive, DestinationPaused, DestinationDisabled: + default: + return Destination{}, ErrInvalid + } + if len(reason) > 128 { + return Destination{}, ErrInvalid + } + return s.repository.SetDestinationStatus(ctx, projectID, destinationID, status, strings.TrimSpace(reason), actor.ID, s.now()) +} + +// DeleteDestination removes a destination that has never been sent anything. +// Once it has delivery history the answer is disable, not delete: the history +// 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 + } + return s.repository.DeleteDestination(ctx, projectID, destinationID, actor.ID, s.now()) +} + +// --------------------------------------------------------------------------- +// Secret rotation +// --------------------------------------------------------------------------- + +// RotateSecret mints a new signing secret and schedules the retirement of the +// current ones. +// +// Both sign during the overlap window and the delivery header carries one v1 +// element per signing secret, so a receiver that has adopted the new secret and +// one that has not both verify. Without the overlap, a rotation would require a +// simultaneous change on both sides or a period of rejected deliveries, and an +// operator can achieve neither. +func (s *Service) RotateSecret(ctx context.Context, actor Actor, projectID, destinationID string) (DestinationWithSecret, error) { + ctx, span := s.tracer.Start(ctx, "billing.webhook.secret.rotate") + defer span.End() + + if actor.ID == "" { + return DestinationWithSecret{}, ErrUnauthenticated + } + if err := s.requireEnabled(ctx, projectID); err != nil { + return DestinationWithSecret{}, err + } + destination, err := s.repository.Destination(ctx, projectID, destinationID) + if err != nil { + return DestinationWithSecret{}, err + } + + secretValue, sealed, err := s.mintSecret(ctx, projectID, destination.ID) + if err != nil { + return DestinationWithSecret{}, err + } + now := s.now() + honoredUntil := now.Add(RotationOverlap) + if _, err := s.repository.RotateSecret(ctx, projectID, destination.ID, sealed, honoredUntil, actor.ID, now); err != nil { + return DestinationWithSecret{}, err + } + + s.secretsRotated.Add(ctx, 1) + span.SetAttributes(attribute.String("mosaic.billing.webhook.destination.id", destination.ID)) + zerolog.Ctx(ctx).Info(). + Str("webhook_destination_id", destination.ID). + Str("project_id", projectID). + Time("previous_secret_honored_until", honoredUntil). + Msg("webhook signing secret rotated") + + refreshed, err := s.repository.Destination(ctx, projectID, destination.ID) + if err != nil { + refreshed = destination + } + return DestinationWithSecret{ + Destination: refreshed, + Secret: secretValue, + SecretID: sealed.ID, + PreviousSecretHonoredUntil: &honoredUntil, + }, nil +} + +// RetireSecret ends a secret's overlap immediately. This is the action after a +// 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 + } + 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 + } + return s.repository.ListSecrets(ctx, projectID, destinationID) +} + +// --------------------------------------------------------------------------- +// Delivery history and replay +// --------------------------------------------------------------------------- + +func (s *Service) ListDeliveries(ctx context.Context, actor Actor, projectID string, filter DeliveryFilter) ([]Delivery, error) { + if actor.ID == "" { + return nil, ErrUnauthenticated + } + 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 + } + 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 + } + return s.repository.ListAttempts(ctx, projectID, deliveryID) +} + +// ReplayDelivery re-queues one terminal delivery. +// +// The event id does not change. A replay is a new delivery attempt, never a +// 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.requireEnabled(ctx, projectID); err != nil { + return Delivery{}, err + } + return s.repository.ReplayDelivery(ctx, projectID, deliveryID, actor.ID, s.now()) +} + +// --------------------------------------------------------------------------- +// Worker +// --------------------------------------------------------------------------- + +// ProcessNextDelivery expands newly committed events and delivers at most one. +// It matches the (processed, error) contract every other Mosaic job family +// uses. +// +// Nothing here runs inside a projection transaction, and the delivery row's +// lease is committed before the HTTP request begins. A destination that hangs +// for the full request timeout holds no database lock while it does so. +func (s *Service) ProcessNextDelivery(ctx context.Context, workerID string) (bool, error) { + now := s.now() + // Fan-out is best-effort within the poll: a failure to expand must not stop + // the deliveries already queued from being attempted. + if _, err := s.repository.FanOut(ctx, now, FanOutBatch); err != nil { + zerolog.Ctx(ctx).Error(). + Str("webhook_error_kind", fmt.Sprintf("%T", err)). + Msg("webhook fan-out failed") + } + + leased, ok, err := s.repository.LeaseDelivery(ctx, workerID, now, now.Add(DeliveryLease)) + if err != nil { + return false, fmt.Errorf("lease webhook delivery: %w", err) + } + if !ok { + return false, nil + } + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: leased.Delivery.ID, JobKind: "billing_webhook_delivery", + ProjectID: leased.Delivery.ProjectID, EnvironmentID: leased.Delivery.EnvironmentID, + ResourceID: leased.Delivery.DestinationID, + }) + return true, s.deliver(ctx, leased) +} + +// deliver performs one attempt and records its outcome. +func (s *Service) deliver(ctx context.Context, leased LeasedDelivery) error { + ctx, span := s.tracer.Start(ctx, "webhook.deliver") + defer span.End() + span.SetAttributes( + attribute.String("mosaic.billing.webhook.delivery.id", leased.Delivery.ID), + attribute.String("mosaic.billing.webhook.destination.id", leased.Delivery.DestinationID), + attribute.Int("mosaic.billing.webhook.delivery.attempt", leased.Delivery.AttemptCount)) + + started := s.now() + + // The destination is re-checked at delivery time, not only at fan-out. An + // operator who pauses a destination while a delivery is queued expects the + // pause to take effect, and the fan-out decision may be hours old. + if deliverable, reason := leased.Destination.Deliverable(leased.EventType); !deliverable { + return s.record(ctx, leased, AttemptResult{ + Delivery: leased.Delivery, AttemptNumber: leased.Delivery.AttemptCount, + Outcome: OutcomeSkipped, Status: DeliverySkipped, SkippedReason: reason, + AttemptedAt: started, CompletedAt: &started, + }, reason) + } + + // Screening runs on every attempt. A hostname that resolved to a public + // address at registration can resolve to a private one now, and a check + // that ran only once would have approved that forever. + target, err := s.policy.Check(ctx, leased.Destination.URL) + if err != nil { + // A refused destination is permanent: the next attempt would resolve the + // same way, and retrying an SSRF-refused address is a scan. + return s.terminal(ctx, leased, started, "destination_refused", nil, "", + AutoDisabledDestinationRefused) + } + + secrets, err := s.openSecrets(ctx, leased) + if err != nil { + // Unsealing failed — a keyring the process cannot reach, or an envelope + // bound to another destination. Retryable, because restoring a keyring + // is an operator action that should recover queued deliveries by + // itself. Signing with nothing is not an option: an unsigned + // entitlement webhook is an unauthenticated instruction to grant access. + return s.retryable(ctx, leased, started, "signing_secret_unavailable", nil, "") + } + + timestamp := started.Unix() + signatures := make([]string, 0, len(secrets)) + for _, secret := range secrets { + signatures = append(signatures, Sign(secret, timestamp, leased.Delivery.EventID, leased.Body)) + } + header := http.Header{} + header.Set("Content-Type", "application/json") + header.Set("User-Agent", s.userAgent) + header.Set(HeaderName, Header(signatures, timestamp)) + + result, sendErr := s.policy.Send(ctx, target, header, leased.Body) + s.deliveryLatency.Record(ctx, float64(result.Latency.Milliseconds())) + + status := (*int)(nil) + if result.StatusCode != 0 { + code := result.StatusCode + status = &code + } + switch { + case sendErr != nil && errors.Is(sendErr, errRedirectRefused): + // A redirect is a second destination the operator never approved. + // Permanent: the destination has to be reconfigured. + return s.terminal(ctx, leased, started, "redirect_refused", nil, "", AutoDisabledDestinationRefused) + case sendErr != nil: + return s.retryable(ctx, leased, started, transportErrorCode(sendErr), nil, "") + case result.StatusCode >= 200 && result.StatusCode < 300: + responded := s.now() + return s.record(ctx, leased, AttemptResult{ + Delivery: leased.Delivery, AttemptNumber: leased.Delivery.AttemptCount, + Outcome: OutcomeDelivered, Status: DeliverySucceeded, + ResponseStatus: status, ResponseExcerpt: result.Excerpt, + LatencyMS: int(result.Latency.Milliseconds()), + AttemptedAt: started, RespondedAt: &responded, CompletedAt: &responded, + ResetDestinationFailures: true, + }, "") + case retryableStatus(result.StatusCode): + return s.retryable(ctx, leased, started, "destination_error", status, result.Excerpt) + default: + // Any other 3xx or 4xx is the destination saying no in a way that will + // not change on its own. Retrying spends the budget for nothing. + return s.terminal(ctx, leased, started, "destination_rejected", status, result.Excerpt, + AutoDisabledConsecutiveExhausted) + } +} + +// retryable schedules another attempt, or exhausts the delivery when the +// budget has run out. +func (s *Service) retryable(ctx context.Context, leased LeasedDelivery, started time.Time, + errorCode string, status *int, excerpt string) error { + + responded := s.now() + if leased.Delivery.AttemptCount >= leased.Delivery.MaxAttempts { + return s.record(ctx, leased, AttemptResult{ + Delivery: leased.Delivery, AttemptNumber: leased.Delivery.AttemptCount, + Outcome: OutcomeExhausted, Status: DeliveryExhausted, ErrorCode: errorCode, + ResponseStatus: status, ResponseExcerpt: excerpt, + LatencyMS: int(responded.Sub(started).Milliseconds()), + AttemptedAt: started, RespondedAt: &responded, CompletedAt: &responded, + IncrementDestinationFailures: true, + }, "") + } + next := s.nextAttemptAt(responded, leased.Delivery.AttemptCount) + return s.record(ctx, leased, AttemptResult{ + Delivery: leased.Delivery, AttemptNumber: leased.Delivery.AttemptCount, + Outcome: OutcomeRetryableFailure, Status: DeliveryPending, ErrorCode: errorCode, + ResponseStatus: status, ResponseExcerpt: excerpt, + LatencyMS: int(responded.Sub(started).Milliseconds()), + AttemptedAt: started, RespondedAt: &responded, NextAttemptAt: &next, + }, "") +} + +// terminal gives up on this delivery without spending the remaining budget. +func (s *Service) terminal(ctx context.Context, leased LeasedDelivery, started time.Time, + errorCode string, status *int, excerpt string, autoDisableReason string) error { + + responded := s.now() + return s.record(ctx, leased, AttemptResult{ + Delivery: leased.Delivery, AttemptNumber: leased.Delivery.AttemptCount, + Outcome: OutcomePermanentFailure, Status: DeliveryFailed, ErrorCode: errorCode, + ResponseStatus: status, ResponseExcerpt: excerpt, + LatencyMS: int(responded.Sub(started).Milliseconds()), + AttemptedAt: started, RespondedAt: &responded, CompletedAt: &responded, + IncrementDestinationFailures: true, + }, autoDisableReason) +} + +// record persists the attempt and applies the auto-disable policy. +func (s *Service) record(ctx context.Context, leased LeasedDelivery, result AttemptResult, autoDisableReason string) error { + result.ResponseExcerpt = SafeExcerpt(result.ResponseExcerpt) + failures, err := s.repository.CompleteAttempt(ctx, result) + if err != nil { + return fmt.Errorf("record webhook delivery attempt: %w", err) + } + s.deliveries.Add(ctx, 1, metric.WithAttributes( + attribute.String("outcome", result.Outcome), + attribute.String("status", result.Status))) + + if !result.IncrementDestinationFailures || failures < AutoDisableThreshold { + return nil + } + reason := autoDisableReason + if reason == "" { + reason = AutoDisabledConsecutiveExhausted + } + now := s.now() + if err := s.repository.AutoDisableDestination(ctx, leased.Delivery.ProjectID, + leased.Delivery.DestinationID, reason, now); err != nil { + // The delivery outcome is already committed. A failure to disable is + // worth an operator's attention but must not be reported as a failed + // job, because re-running the job would re-deliver an event that was + // already sent. + zerolog.Ctx(ctx).Error(). + Str("webhook_destination_id", leased.Delivery.DestinationID). + Str("webhook_error_kind", fmt.Sprintf("%T", err)). + Msg("webhook destination could not be auto-disabled") + return nil + } + zerolog.Ctx(ctx).Warn(). + Str("webhook_destination_id", leased.Delivery.DestinationID). + Str("project_id", leased.Delivery.ProjectID). + Str("auto_disable_reason", reason). + Int("consecutive_failure_count", failures). + Msg("webhook destination disabled after consecutive delivery failures") + return nil +} + +// openSecrets unseals every secret still permitted to sign, newest first. +// +// A retired secret whose overlap has lapsed is dropped here rather than being +// filtered in SQL alone, so a clock skew between the database and this process +// cannot resurrect one. +func (s *Service) openSecrets(ctx context.Context, leased LeasedDelivery) ([]string, error) { + now := s.now() + stored := append([]StoredSecret(nil), leased.Secrets...) + sort.SliceStable(stored, func(left, right int) bool { + // Active before retired, so the current secret's signature is the first + // header element a receiver reads. + return stored[left].Status == SecretActive && stored[right].Status != SecretActive + }) + + secrets := make([]string, 0, len(stored)) + for _, candidate := range stored { + if candidate.Status == SecretRetired && + (candidate.HonoredUntil == nil || !candidate.HonoredUntil.After(now)) { + continue + } + plaintext, err := s.cipher.DecryptSubject(providercredential.Envelope{ + Version: candidate.EnvelopeVersion, Algorithm: candidate.Algorithm, KeyID: candidate.KeyID, + Nonce: candidate.Nonce, Ciphertext: candidate.Ciphertext, + Fingerprint: candidate.Fingerprint, CredentialClass: billing.ClassWebhookSigningSecret, + }, providercredential.SubjectScope{ + OrganizationID: leased.OrganizationID, + ProjectID: leased.Delivery.ProjectID, + SubjectKind: providercredential.SubjectWebhookSigningSecret, + SubjectID: leased.Delivery.DestinationID, + CredentialClass: billing.ClassWebhookSigningSecret, + }) + if err != nil { + // One unopenable envelope does not condemn the rest: during a + // keyring rotation a superseded secret may be sealed under a key + // this process no longer holds, and the active one still signs. + continue + } + secrets = append(secrets, string(plaintext)) + } + if len(secrets) == 0 { + return nil, ErrSecretUnavailable + } + return secrets, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ( + backoffBase = 15 * time.Second + backoffCap = 10 * time.Minute +) + +// nextAttemptAt computes when a retry becomes available. +// +// It mirrors the shape of billing.NextAttemptAt: exponential from a fifteen- +// second base, capped at ten minutes, with ±25% jitter. The jitter matters more +// here than it does for provider calls, because a single entitlement change can +// fan out to every destination in a Project at once, and an unjittered schedule +// would turn one receiver's outage into a synchronized retry burst. +func (s *Service) nextAttemptAt(now time.Time, attempt int) time.Time { + shift := attempt + if shift < 0 { + shift = 0 + } + if shift > 6 { + shift = 6 + } + delay := backoffBase << shift + if delay > backoffCap { + delay = backoffCap + } + factor := 1.0 + if s.jitter != nil { + factor = 1 + (s.jitter.Float64()*2-1)*0.25 + } + return now.Add(time.Duration(float64(delay) * factor)) +} + +// retryableStatus reports whether a response status is worth another attempt. +func retryableStatus(status int) bool { + switch { + case status == http.StatusRequestTimeout, + status == http.StatusTooManyRequests, + status >= 500: + return true + default: + return false + } +} + +// transportErrorCode maps a dial, TLS, or deadline failure onto a stable +// Mosaic code. The error itself is never persisted or returned: it can carry +// the destination host and resolved address, which is information about +// Mosaic's network position. +func transportErrorCode(err error) string { + switch { + case errors.Is(err, context.DeadlineExceeded): + return "destination_timeout" + case errors.Is(err, context.Canceled): + return "delivery_cancelled" + case errors.Is(err, ErrDestinationRefused): + return "destination_refused" + default: + return "destination_unreachable" + } +} + +// mintSecret generates a signing secret and seals it under the destination's +// subject scope. The plaintext is returned once and is never stored. +func (s *Service) mintSecret(ctx context.Context, projectID, destinationID string) (string, SealedSecret, error) { + organizationID, err := s.repository.OrganizationForProject(ctx, projectID) + if err != nil { + return "", SealedSecret{}, ErrNotFound + } + buffer := make([]byte, SecretRandomBytes) + if _, err := io.ReadFull(s.random, buffer); err != nil { + return "", SealedSecret{}, ErrSecretUnavailable + } + value := SecretPrefix + base64.RawURLEncoding.EncodeToString(buffer) + secretID, err := s.newID("whs") + if err != nil { + return "", SealedSecret{}, err + } + // The additional authenticated data binds the ciphertext to this exact + // destination row, so a sealed secret moved to another destination — by a + // bug, or by a compromise that can write the table but not decrypt it — + // fails to open rather than signing deliveries for the wrong tenant. + envelope, err := s.cipher.EncryptSubject([]byte(value), providercredential.SubjectScope{ + OrganizationID: organizationID, + ProjectID: projectID, + SubjectKind: providercredential.SubjectWebhookSigningSecret, + SubjectID: destinationID, + CredentialClass: billing.ClassWebhookSigningSecret, + }) + if err != nil { + return "", SealedSecret{}, ErrSecretUnavailable + } + return value, SealedSecret{ + ID: secretID, EnvelopeVersion: envelope.Version, Algorithm: envelope.Algorithm, + KeyID: envelope.KeyID, Nonce: envelope.Nonce, Ciphertext: envelope.Ciphertext, + Fingerprint: envelope.Fingerprint, + }, nil +} + +func (s *Service) newID(prefix string) (string, error) { + buffer := make([]byte, 16) + if _, err := io.ReadFull(s.random, buffer); err != nil { + return "", fmt.Errorf("generate webhook identifier: %w", err) + } + return prefix + "_" + base64.RawURLEncoding.EncodeToString(buffer), nil +} + +// normalizeEventTypes validates the subscription list against the closed +// 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) { + 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 { + return nil, ErrInvalid + } + if seen[eventType] { + return nil, ErrInvalid + } + seen[eventType] = true + result = append(result, eventType) + } + return result, nil +} + +// requireEnabled fails closed, matching the ingestion, projection, and access +// paths: an unreadable setting is treated as disabled, so a transient database +// error cannot quietly re-enable a Project that asked Mosaic to hold no billing +// state. +func (s *Service) requireEnabled(ctx context.Context, projectID string) error { + enabled, err := s.repository.BillingEnabled(ctx, projectID) + if err != nil { + zerolog.Ctx(ctx).Error(). + Str("project_id", projectID). + Str("webhook_error_kind", fmt.Sprintf("%T", err)). + Msg("billing enablement could not be read; treating the Project as disabled") + return ErrBillingDisabled + } + if !enabled { + return ErrBillingDisabled + } + return nil +} diff --git a/apps/api/internal/billingwebhook/signing.go b/apps/api/internal/billingwebhook/signing.go new file mode 100644 index 00000000..521466d0 --- /dev/null +++ b/apps/api/internal/billingwebhook/signing.go @@ -0,0 +1,105 @@ +package billingwebhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strconv" + "strings" +) + +// HeaderName is the signature header every delivery carries. +const HeaderName = "Mosaic-Signature" + +// SigningVersion prefixes the signed string and names the header element. +// +// It is inside the signed bytes on purpose: a future scheme becomes a new `v` +// element rather than a silent reinterpretation of the same bytes by receivers +// that were never told the rules changed. +const SigningVersion = "v1" + +// signedPayload builds the exact bytes the HMAC covers: +// +// v1... +// +// Four things are bound together deliberately (ADR-0024 §1): the scheme +// version, the timestamp — which is what makes a receiver's replay window +// enforceable — the event id, so a captured signature cannot be re-attached to +// a different body inside that window, and the exact body bytes. +// +// Note for anyone comparing this against ADR-0024's prose, which writes the +// first element as "1": the canonical cross-implementation vectors in +// packages/test-fixtures/src/webhook-signature-vectors.json, the protocol +// document, and the header element name all use "v1", and the vectors are the +// artifact the Dart, Swift, and Kotlin implementations verify against. The +// vectors win; the ADR sentence is a typo in the prose, not a second scheme. +func signedPayload(timestamp int64, eventID string, body []byte) []byte { + stamp := strconv.FormatInt(timestamp, 10) + payload := make([]byte, 0, len(SigningVersion)+len(stamp)+len(eventID)+len(body)+3) + payload = append(payload, SigningVersion...) + payload = append(payload, '.') + payload = append(payload, stamp...) + payload = append(payload, '.') + payload = append(payload, eventID...) + payload = append(payload, '.') + payload = append(payload, body...) + return payload +} + +// Sign produces one lowercase-hex HMAC-SHA256 signature. +// +// The secret is used as its UTF-8 bytes verbatim — it is not hex- or +// base64-decoded first. That is a documented property of the scheme rather +// than an implementation detail, because a receiver that decodes the secret +// agrees with Mosaic on every ASCII vector and disagrees on none of the ones +// an integrator would notice. +func Sign(secret string, timestamp int64, eventID string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(signedPayload(timestamp, eventID, body)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// Header renders the delivery header from one or more signatures. +// +// One `v1` element per secret still permitted to sign. During a rotation +// overlap that is two, and a receiver accepts the delivery if *any* of them +// verifies — which is the whole reason the overlap works. A receiver that +// reads only the first element drops every delivery signed with the new key, +// so the contract says so explicitly and the vectors carry a rotation case. +// +// The separator is ", " to match the reference vectors byte for byte. +func Header(signatures []string, timestamp int64) string { + var builder strings.Builder + builder.WriteString("t=") + builder.WriteString(strconv.FormatInt(timestamp, 10)) + for _, signature := range signatures { + if signature == "" { + continue + } + builder.WriteString(", ") + builder.WriteString(SigningVersion) + builder.WriteString("=") + builder.WriteString(signature) + } + return builder.String() +} + +// Verify reports whether any supplied signature matches, in constant time. +// +// Mosaic is the producer and does not verify its own deliveries in the +// delivery path. This exists so the signature rule has exactly one +// implementation to point at, and so the conformance test can assert the +// must-not-verify vectors are actually refused rather than merely differing. +func Verify(secret string, timestamp int64, eventID string, body []byte, signatures []string) bool { + expected := []byte(Sign(secret, timestamp, eventID, body)) + matched := false + for _, candidate := range signatures { + // Every candidate is compared; the loop does not exit early. An early + // exit would make the number of comparisons depend on which element + // matched, which is a signal about the secret set. + if hmac.Equal(expected, []byte(strings.ToLower(strings.TrimSpace(candidate)))) { + matched = true + } + } + return matched +} diff --git a/apps/api/internal/billingwebhook/signing_test.go b/apps/api/internal/billingwebhook/signing_test.go new file mode 100644 index 00000000..b8e7a08b --- /dev/null +++ b/apps/api/internal/billingwebhook/signing_test.go @@ -0,0 +1,186 @@ +package billingwebhook + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The signature is the only thing standing between an entitlement-change +// webhook and an unauthenticated instruction to grant someone access. Four +// implementations have to agree on it byte for byte — Go here, and Dart, Swift, +// and Kotlin in the SDKs — and the published vector file is the only artifact +// all four can be checked against. +// +// The test loads the vectors rather than restating any expected string. A +// hand-written expectation would freeze whatever this implementation happened +// to do on the day it was written, which is exactly the drift the vectors +// exist to prevent. + +type signatureVectors struct { + Scheme struct { + Header string `json:"header"` + SigningVersion string `json:"signingVersion"` + } `json:"scheme"` + Vectors []struct { + ID string `json:"id"` + Secret string `json:"secret"` + Timestamp int64 `json:"timestamp"` + EventID string `json:"eventId"` + RawBody string `json:"rawBody"` + SignedPayload string `json:"signedPayload"` + Signature string `json:"signature"` + Header string `json:"header"` + } `json:"vectors"` +} + +func loadVectors(t *testing.T) signatureVectors { + t.Helper() + path := filepath.Join("..", "..", "..", "..", + "packages", "test-fixtures", "src", "webhook-signature-vectors.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read signature vectors: %v", err) + } + var vectors signatureVectors + if err := json.Unmarshal(raw, &vectors); err != nil { + t.Fatalf("decode signature vectors: %v", err) + } + if len(vectors.Vectors) == 0 { + t.Fatal("signature vectors file contains no vectors") + } + return vectors +} + +// TestSignMatchesPublishedVectors is the cross-implementation conformance +// check. It fails if Go's signing disagrees with the published contract in any +// way at all: a different separator, a decoded secret, a non-UTF-8 body +// encoding, uppercase hex, or a signed string that omits the timestamp or the +// event id. +func TestSignMatchesPublishedVectors(t *testing.T) { + vectors := loadVectors(t) + + if vectors.Scheme.Header != HeaderName { + t.Fatalf("header name = %q, vectors say %q", HeaderName, vectors.Scheme.Header) + } + if vectors.Scheme.SigningVersion != SigningVersion { + t.Fatalf("signing version = %q, vectors say %q", SigningVersion, vectors.Scheme.SigningVersion) + } + + for _, vector := range vectors.Vectors { + t.Run(vector.ID, func(t *testing.T) { + body := []byte(vector.RawBody) + + // The signed string itself is asserted, not only the digest. A + // mismatch here names the bug directly instead of reporting two + // unequal hex strings. + if got := string(signedPayload(vector.Timestamp, vector.EventID, body)); got != vector.SignedPayload { + t.Fatalf("signed payload mismatch\n got: %q\nwant: %q", got, vector.SignedPayload) + } + signature := Sign(vector.Secret, vector.Timestamp, vector.EventID, body) + if signature != vector.Signature { + t.Fatalf("signature = %s, want %s", signature, vector.Signature) + } + if signature != strings.ToLower(signature) { + t.Fatalf("signature is not lowercase hex: %s", signature) + } + if header := Header([]string{signature}, vector.Timestamp); header != vector.Header { + t.Fatalf("header = %q, want %q", header, vector.Header) + } + if !Verify(vector.Secret, vector.Timestamp, vector.EventID, body, []string{signature}) { + t.Fatal("Verify rejected a signature this implementation produced") + } + }) + } +} + +// TestMustNotVerifyVectors pins the negative half of the contract. +// +// The three tampering vectors carry a changed body, a changed event id, and a +// changed timestamp. Each one must fail against the canonical body, because +// that is the entire security property: without it a signature would be a +// decoration a receiver could not use to reject anything. +func TestMustNotVerifyVectors(t *testing.T) { + vectors := loadVectors(t) + + var canonical struct { + secret string + timestamp int64 + eventID string + body []byte + signature string + } + tampered := map[string]string{} + for _, vector := range vectors.Vectors { + switch vector.ID { + case "canonical-event-primary-key": + canonical.secret = vector.Secret + canonical.timestamp = vector.Timestamp + canonical.eventID = vector.EventID + canonical.body = []byte(vector.RawBody) + canonical.signature = vector.Signature + case "tampered-body-must-not-verify", + "different-event-id-must-not-verify", + "different-timestamp-must-not-verify": + tampered[vector.ID] = vector.Signature + } + } + if canonical.signature == "" || len(tampered) != 3 { + t.Fatalf("vector file no longer carries the canonical and three tampering vectors: %d found", len(tampered)) + } + + for id, signature := range tampered { + if signature == canonical.signature { + t.Fatalf("%s produced the canonical signature; the field it changes is not covered", id) + } + if Verify(canonical.secret, canonical.timestamp, canonical.eventID, canonical.body, + []string{signature}) { + t.Fatalf("%s verified against the canonical delivery", id) + } + } +} + +// TestHeaderCarriesEveryActiveSignature covers the rotation overlap on the +// wire. +// +// During a rotation the header must carry one v1 element per signing secret, +// and the rotation vector proves the second element is a real second key rather +// than a repeat of the first. A header that emitted only one element would +// drop every receiver that had already adopted the new secret, which is a +// silent, tenant-wide outage of exactly the mechanism rotation exists to avoid. +func TestHeaderCarriesEveryActiveSignature(t *testing.T) { + vectors := loadVectors(t) + + var primary, rotation string + var timestamp int64 + for _, vector := range vectors.Vectors { + switch vector.ID { + case "canonical-event-primary-key": + primary, timestamp = vector.Signature, vector.Timestamp + case "canonical-event-rotation-key": + rotation = vector.Signature + } + } + if primary == "" || rotation == "" { + t.Fatal("vector file no longer carries both the primary and rotation keys") + } + if primary == rotation { + t.Fatal("the rotation vector repeats the primary signature") + } + + header := Header([]string{primary, rotation}, timestamp) + if strings.Count(header, "v1=") != 2 { + t.Fatalf("header carries %d v1 elements, want 2: %q", strings.Count(header, "v1="), header) + } + if !strings.Contains(header, "v1="+primary) || !strings.Contains(header, "v1="+rotation) { + t.Fatalf("header omits one of the active signatures: %q", header) + } + // A retired secret drops out of the list; the header must then carry only + // what remains, with no empty element left behind. + retired := Header([]string{primary}, timestamp) + if strings.Count(retired, "v1=") != 1 || strings.Contains(retired, rotation) { + t.Fatalf("retired secret still present in header: %q", retired) + } +} diff --git a/apps/api/internal/billingwebhook/ssrf.go b/apps/api/internal/billingwebhook/ssrf.go new file mode 100644 index 00000000..453411e4 --- /dev/null +++ b/apps/api/internal/billingwebhook/ssrf.go @@ -0,0 +1,398 @@ +package billingwebhook + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "syscall" + "time" + "unicode" + "unicode/utf8" +) + +// A destination URL is operator-supplied and Mosaic makes outbound requests to +// it. That is a server-side request forgery primitive unless it is bounded, +// and the bound is ADR-0024 §4. This file is the whole of that policy. + +const ( + // maxURLLength matches the column CHECK. + maxURLLength = 2048 + // defaultConnectTimeout bounds the dial. A destination that cannot be + // reached in five seconds is not going to answer this attempt. + defaultConnectTimeout = 5 * time.Second + // defaultTotalTimeout bounds the whole request. It stays well under the + // delivery lease so a slow destination never lets a second worker claim a + // delivery that is still in flight. + defaultTotalTimeout = 20 * time.Second + // defaultMaxResponseBytes is the response read ceiling. A webhook + // receiver's response body is never used for anything except a bounded + // operator-facing excerpt, so the ceiling can be small. + defaultMaxResponseBytes = 4 << 10 +) + +// cgnat is RFC 6598 shared address space (100.64.0.0/10). Go's stdlib has no +// predicate for it, and it is a real internal range on carrier and cloud +// networks. +var cgnat = netip.MustParsePrefix("100.64.0.0/10") + +// nat64 is the well-known prefix (64:ff9b::/96). It embeds an IPv4 address in +// an IPv6 one, so without it a private IPv4 destination can be reached through +// an address that passes every IPv6 predicate. +var nat64 = netip.MustParsePrefix("64:ff9b::/96") + +// thisNetwork is 0.0.0.0/8. Only 0.0.0.0 itself is `IsUnspecified`, and the +// rest of the block is routed to the local host on several stacks. +var thisNetwork = netip.MustParsePrefix("0.0.0.0/8") + +// broadcast is the limited broadcast address. +var broadcast = netip.MustParseAddr("255.255.255.255") + +// Policy screens destination URLs and performs the bounded outbound request. +// +// It holds no per-destination state. The self-hosted exception is a field of +// the policy — fed from deployment configuration — and never a per-destination +// column, because a per-destination toggle would let anyone with +// destination-write permission reach Mosaic's internal network. +type Policy struct { + allowPrivate bool + connectTimeout time.Duration + totalTimeout time.Duration + maxResponseBytes int64 + + resolve func(ctx context.Context, host string) ([]netip.Addr, error) + dial func(ctx context.Context, network, address string) (net.Conn, error) +} + +type PolicyOption func(*Policy) + +// WithSelfHostedAllowlist permits private destinations. +// +// This is the deployment-level flag from ADR-0024 §4: an operator running +// Mosaic and their application backend on one private network legitimately +// needs a private destination. It never becomes a request field. +func WithSelfHostedAllowlist(enabled bool) PolicyOption { + return func(p *Policy) { p.allowPrivate = enabled } +} + +// WithTimeouts overrides the connect and total bounds. +func WithTimeouts(connect, total time.Duration) PolicyOption { + return func(p *Policy) { + if connect > 0 { + p.connectTimeout = connect + } + if total > 0 { + p.totalTimeout = total + } + } +} + +// WithMaxResponseBytes overrides the response read ceiling. +func WithMaxResponseBytes(limit int64) PolicyOption { + return func(p *Policy) { + if limit > 0 { + p.maxResponseBytes = limit + } + } +} + +// WithResolver replaces DNS resolution. Tests use it to express a rebinding +// host; nothing in production does. +func WithResolver(resolve func(ctx context.Context, host string) ([]netip.Addr, error)) PolicyOption { + return func(p *Policy) { + if resolve != nil { + p.resolve = resolve + } + } +} + +// WithDialer replaces the raw dial. The address screen still runs around it. +func WithDialer(dial func(ctx context.Context, network, address string) (net.Conn, error)) PolicyOption { + return func(p *Policy) { + if dial != nil { + p.dial = dial + } + } +} + +func NewPolicy(options ...PolicyOption) *Policy { + policy := &Policy{ + connectTimeout: defaultConnectTimeout, + totalTimeout: defaultTotalTimeout, + maxResponseBytes: defaultMaxResponseBytes, + } + policy.resolve = defaultResolve + for _, option := range options { + option(policy) + } + if policy.dial == nil { + dialer := &net.Dialer{ + Timeout: policy.connectTimeout, + // Control is the last line: it screens the address the kernel is + // actually about to connect to. Pinning below already decides the + // address, so this hook should never fire — and that is exactly why + // it is here, because the day something reintroduces a second + // resolution this refuses instead of connecting. + Control: func(network, address string, _ syscall.RawConn) error { + return policy.screenDialAddress(address) + }, + } + policy.dial = func(ctx context.Context, network, address string) (net.Conn, error) { + return dialer.DialContext(ctx, network, address) + } + } + return policy +} + +func defaultResolve(ctx context.Context, host string) ([]netip.Addr, error) { + return net.DefaultResolver.LookupNetIP(ctx, "ip", host) +} + +// Target is a screened destination: the URL, and the one address delivery is +// pinned to. +type Target struct { + URL *url.URL + Address netip.Addr + Port string +} + +// Check validates a destination URL and resolves it to a permitted address. +// +// It runs at registration *and* at every delivery attempt, and both are +// necessary for different reasons. At registration it is what refuses a bad +// destination while an operator is still looking at the screen. At delivery it +// is what closes DNS rebinding: a hostname that resolved to a public address +// when it was registered can resolve to a private one an hour later, and a +// check that ran only once would have approved that forever. +func (p *Policy) Check(ctx context.Context, raw string) (Target, error) { + parsed, err := parseDestinationURL(raw) + if err != nil { + return Target{}, err + } + host := parsed.Hostname() + port := parsed.Port() + if port == "" { + port = "443" + } + + // A literal address needs no resolution, and resolving one would be a way + // to reach a resolver with attacker-controlled input for no benefit. + if literal, parseErr := netip.ParseAddr(host); parseErr == nil { + if err := p.screen(literal); err != nil { + return Target{}, err + } + return Target{URL: parsed, Address: literal.Unmap(), Port: port}, nil + } + + addresses, err := p.resolve(ctx, host) + if err != nil { + return Target{}, fmt.Errorf("%w: destination host could not be resolved", ErrDestinationRefused) + } + if len(addresses) == 0 { + return Target{}, ErrDestinationRefused + } + // Every resolved address must pass, not merely one of them. A host that + // answers with one public and one private address is a rebinding attempt + // dressed as a multi-homed service, and picking the address that happens to + // pass would honour it. + for _, address := range addresses { + if err := p.screen(address); err != nil { + return Target{}, err + } + } + return Target{URL: parsed, Address: addresses[0].Unmap(), Port: port}, nil +} + +// parseDestinationURL enforces the shape rules that need no network. +func parseDestinationURL(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > maxURLLength { + return nil, ErrInvalid + } + parsed, err := url.Parse(raw) + if err != nil { + return nil, ErrInvalid + } + // HTTPS only. Plaintext delivery of entitlement state is not offered at any + // tier, including to the self-hosted allowlist: the flag exists to permit a + // private *address*, not to remove transport security. + if parsed.Scheme != "https" { + return nil, fmt.Errorf("%w: destinations must use https", ErrDestinationRefused) + } + if parsed.User != nil { + // Credentials in a URL leak through proxy logs and error reports, and + // Mosaic already has a signing secret for authentication. + return nil, ErrInvalid + } + if parsed.Hostname() == "" { + return nil, ErrInvalid + } + return parsed, nil +} + +// screen applies the denied-address policy. +func (p *Policy) screen(address netip.Addr) error { + if !address.IsValid() { + return ErrDestinationRefused + } + // An IPv4-mapped IPv6 address is refused before anything else. Unmapping + // first and screening the IPv4 form would be equally safe, but refusing + // outright removes a whole class of "which form was screened?" questions, + // and no legitimate destination is published in that notation. + if address.Is4In6() { + return fmt.Errorf("%w: ipv4-mapped address", ErrDestinationRefused) + } + // Never permitted, allowlist or not. None of these is an internal + // destination an operator could plausibly want; they are nonsense targets + // or amplification vectors. + switch { + case address.IsUnspecified(), + address.IsMulticast(), + address.IsInterfaceLocalMulticast(), + address.IsLinkLocalMulticast(), + address == broadcast, + thisNetwork.Contains(address), + nat64.Contains(address): + return fmt.Errorf("%w: reserved address", ErrDestinationRefused) + } + if p.allowPrivate { + return nil + } + switch { + case address.IsLoopback(): + return fmt.Errorf("%w: loopback address", ErrDestinationRefused) + case address.IsLinkLocalUnicast(): + // 169.254.0.0/16 and fe80::/10. This is the range the cloud metadata + // address 169.254.169.254 lives in, and reaching it from a webhook + // destination hands out instance credentials. + return fmt.Errorf("%w: link-local address", ErrDestinationRefused) + case address.IsPrivate(): + // RFC1918 for IPv4 and fc00::/7 unique-local for IPv6. + return fmt.Errorf("%w: private address", ErrDestinationRefused) + case cgnat.Contains(address): + return fmt.Errorf("%w: shared address space", ErrDestinationRefused) + } + return nil +} + +// screenDialAddress screens a host:port about to be dialled. +func (p *Policy) screenDialAddress(address string) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return ErrDestinationRefused + } + parsed, err := netip.ParseAddr(host) + if err != nil { + return ErrDestinationRefused + } + // The kernel is handed the unmapped form, so re-screening the mapped-form + // rule here would refuse every legitimate IPv4 dial. Everything else in the + // policy applies unchanged. + return p.screen(parsed.Unmap()) +} + +// Result is the bounded record of one outbound request. +type Result struct { + StatusCode int + // Excerpt is at most MaxResponseExcerpt characters and contains no control + // characters. It exists so an integrator can see why their own endpoint + // refused, and is never parsed. + Excerpt string + Latency time.Duration +} + +// errRedirectRefused marks a refused redirect distinctly from a transport +// failure: it is the destination's own doing and retrying will not fix it. +var errRedirectRefused = errors.New("redirects are not followed") + +// Send performs the delivery request against the pinned address. +// +// The connection goes to the address Check screened, not to a fresh resolution +// of the hostname. Checking the hostname and then letting the HTTP client +// resolve again is the classic rebinding hole: the second resolution can +// return an address the first check would have refused. +func (p *Policy) Send(ctx context.Context, target Target, header http.Header, body []byte) (Result, error) { + ctx, cancel := context.WithTimeout(ctx, p.totalTimeout) + defer cancel() + + pinned := net.JoinHostPort(target.Address.String(), target.Port) + transport := &http.Transport{ + DialContext: func(dialContext context.Context, network, _ string) (net.Conn, error) { + return p.dial(dialContext, network, pinned) + }, + // Connections are not reused across deliveries. A pooled connection + // outlives the screen that approved it, so the next delivery to the + // same host would travel over an address nothing re-checked. + DisableKeepAlives: true, + TLSHandshakeTimeout: p.connectTimeout, + ResponseHeaderTimeout: p.totalTimeout, + } + client := &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { + // A redirect is a second destination the operator never approved, + // and following one would carry the signed body to it. + return errRedirectRefused + }, + } + defer transport.CloseIdleConnections() + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.URL.String(), strings.NewReader(string(body))) + if err != nil { + return Result{}, ErrInvalid + } + for key, values := range header { + for _, value := range values { + request.Header.Add(key, value) + } + } + request.ContentLength = int64(len(body)) + + started := time.Now() + response, err := client.Do(request) + if err != nil { + if errors.Is(err, errRedirectRefused) { + return Result{Latency: time.Since(started)}, errRedirectRefused + } + return Result{Latency: time.Since(started)}, err + } + defer func() { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, p.maxResponseBytes)) + _ = response.Body.Close() + }() + + excerpt, _ := io.ReadAll(io.LimitReader(response.Body, p.maxResponseBytes)) + return Result{ + StatusCode: response.StatusCode, + Excerpt: SafeExcerpt(string(excerpt)), + Latency: time.Since(started), + }, nil +} + +// SafeExcerpt bounds and sanitizes a destination's response body. +// +// Control characters are dropped rather than escaped: the excerpt is displayed +// in operator tooling and written to a database column whose CHECK refuses +// them, and an endpoint that answers with a terminal escape sequence should not +// get to move an operator's cursor. Invalid UTF-8 is dropped for the same +// reason — the value is decoration, so there is nothing to preserve. +func SafeExcerpt(value string) string { + var builder strings.Builder + count := 0 + for _, character := range value { + if count >= MaxResponseExcerpt { + break + } + if character == utf8.RuneError || unicode.IsControl(character) || !utf8.ValidRune(character) { + continue + } + builder.WriteRune(character) + count++ + } + return strings.TrimSpace(builder.String()) +} diff --git a/apps/api/internal/billingwebhook/ssrf_test.go b/apps/api/internal/billingwebhook/ssrf_test.go new file mode 100644 index 00000000..181f5ea9 --- /dev/null +++ b/apps/api/internal/billingwebhook/ssrf_test.go @@ -0,0 +1,208 @@ +package billingwebhook + +import ( + "context" + "errors" + "net/netip" + "testing" +) + +// A destination URL is operator-supplied and Mosaic makes outbound requests to +// it. Every case below is a way that turns into a request Mosaic should never +// have made: reading cloud instance credentials off the link-local metadata +// address, probing an internal service on an RFC1918 address, or reaching a +// private host through an address form that passes the naive check. +// +// The policy is the only thing preventing any of it, so it is tested directly +// rather than through the service: the failure being protected against is a +// missing branch in the screen, and a test that had to construct a destination +// and a delivery to reach that branch would be harder to read and no more +// conclusive. + +func TestScreenDeniesEveryReservedClass(t *testing.T) { + cases := []struct { + name string + address string + }{ + {"rfc1918 ten", "10.0.0.1"}, + {"rfc1918 172.16", "172.16.5.4"}, + {"rfc1918 192.168", "192.168.1.1"}, + {"loopback v4", "127.0.0.1"}, + {"loopback v6", "::1"}, + {"link local v4", "169.254.1.1"}, + // The cloud metadata address. Reaching it from a webhook destination + // hands out instance credentials, which is the single highest-value + // target this policy exists to refuse. + {"cloud metadata", "169.254.169.254"}, + {"link local v6", "fe80::1"}, + {"cgnat", "100.64.0.1"}, + {"cgnat upper", "100.127.255.254"}, + {"ipv6 unique local", "fd00::1"}, + {"ipv6 unique local fc", "fc00::1"}, + // An IPv4-mapped IPv6 address is a private IPv4 destination wearing an + // IPv6 costume: it satisfies no IPv6 private predicate at all. + {"ipv4-mapped private", "::ffff:10.0.0.1"}, + {"ipv4-mapped public", "::ffff:93.184.216.34"}, + {"unspecified v4", "0.0.0.0"}, + {"unspecified v6", "::"}, + {"this network", "0.1.2.3"}, + {"broadcast", "255.255.255.255"}, + {"multicast", "224.0.0.1"}, + // NAT64 embeds an IPv4 address inside an IPv6 one, so without an + // explicit rule a private IPv4 host is reachable through it. + {"nat64", "64:ff9b::a00:1"}, + } + + policy := NewPolicy() + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + address := netip.MustParseAddr(testCase.address) + if err := policy.screen(address); !errors.Is(err, ErrDestinationRefused) { + t.Fatalf("screen(%s) = %v, want ErrDestinationRefused", testCase.address, err) + } + }) + } + + // A genuinely public address must pass, or the policy would refuse every + // legitimate destination and the failure would look like an outage. + if err := policy.screen(netip.MustParseAddr("93.184.216.34")); err != nil { + t.Fatalf("screen(public v4) = %v, want nil", err) + } + if err := policy.screen(netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")); err != nil { + t.Fatalf("screen(public v6) = %v, want nil", err) + } +} + +// The self-hosted allowlist is a deployment flag, not a per-destination field. +// It has to actually permit a private destination — an operator running Mosaic +// and their backend on one network has no other supported option — while still +// refusing the addresses that are not destinations at all. +func TestSelfHostedAllowlistPermitsPrivateOnly(t *testing.T) { + policy := NewPolicy(WithSelfHostedAllowlist(true)) + + for _, address := range []string{"10.0.0.1", "127.0.0.1", "169.254.169.254", "fd00::1", "100.64.0.1"} { + if err := policy.screen(netip.MustParseAddr(address)); err != nil { + t.Fatalf("allowlisted screen(%s) = %v, want nil", address, err) + } + } + // Never permitted, flag or not: none of these is a destination, and a + // broadcast or multicast target turns one entitlement change into an + // amplified send. + for _, address := range []string{"0.0.0.0", "255.255.255.255", "224.0.0.1", "::ffff:10.0.0.1"} { + if err := policy.screen(netip.MustParseAddr(address)); !errors.Is(err, ErrDestinationRefused) { + t.Fatalf("allowlisted screen(%s) = %v, want ErrDestinationRefused", address, err) + } + } +} + +func TestCheckRequiresHTTPS(t *testing.T) { + policy := NewPolicy(WithResolver(func(context.Context, string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil + })) + ctx := context.Background() + + if _, err := policy.Check(ctx, "http://example.com/hook"); !errors.Is(err, ErrDestinationRefused) { + t.Fatalf("plaintext destination accepted: %v", err) + } + // The allowlist permits a private address, never a plaintext scheme. + relaxed := NewPolicy(WithSelfHostedAllowlist(true), + WithResolver(func(context.Context, string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("10.0.0.5")}, nil + })) + if _, err := relaxed.Check(ctx, "http://internal.example/hook"); !errors.Is(err, ErrDestinationRefused) { + t.Fatalf("allowlist accepted a plaintext destination: %v", err) + } + if _, err := policy.Check(ctx, "https://user:pass@example.com/hook"); err == nil { + t.Fatal("credentials in the destination URL were accepted") + } + if _, err := policy.Check(ctx, "https://example.com/hook"); err != nil { + t.Fatalf("public https destination refused: %v", err) + } +} + +// TestCheckRefusesDNSRebinding is the case the whole "resolve and pin per +// attempt" rule exists for. +// +// A hostname that resolved to a public address when the operator registered it +// can resolve to a private one an hour later. Screening only at registration +// would approve that host forever, and every subsequent delivery would carry a +// signed request into Mosaic's own network. Because the screen runs again on +// every attempt, the second resolution is refused. +func TestCheckRefusesDNSRebinding(t *testing.T) { + resolutions := 0 + policy := NewPolicy(WithResolver(func(context.Context, string) ([]netip.Addr, error) { + resolutions++ + if resolutions == 1 { + // Registration time: a perfectly ordinary public address. + return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil + } + // Delivery time: the same hostname, now pointing inside. + return []netip.Addr{netip.MustParseAddr("169.254.169.254")}, nil + })) + ctx := context.Background() + + target, err := policy.Check(ctx, "https://rebind.example/hook") + if err != nil { + t.Fatalf("registration screen refused a public address: %v", err) + } + if target.Address != netip.MustParseAddr("93.184.216.34") { + t.Fatalf("pinned address = %s, want the address that was screened", target.Address) + } + + if _, err := policy.Check(ctx, "https://rebind.example/hook"); !errors.Is(err, ErrDestinationRefused) { + t.Fatalf("delivery-time screen accepted a rebound address: %v", err) + } + if resolutions != 2 { + t.Fatalf("resolver called %d times; the screen is not re-running per attempt", resolutions) + } +} + +// A host that answers with one public and one private address is a rebinding +// attempt dressed as a multi-homed service. Picking the address that happens to +// pass would honour it. +func TestCheckRefusesMixedResolution(t *testing.T) { + policy := NewPolicy(WithResolver(func(context.Context, string) ([]netip.Addr, error) { + return []netip.Addr{ + netip.MustParseAddr("93.184.216.34"), + netip.MustParseAddr("10.1.2.3"), + }, nil + })) + if _, err := policy.Check(context.Background(), "https://mixed.example/hook"); !errors.Is(err, ErrDestinationRefused) { + t.Fatalf("mixed resolution accepted: %v", err) + } +} + +// The dial-time control hook is the last line: it screens the address the +// kernel is about to connect to, so a second resolution reintroduced anywhere +// below the pin refuses instead of connecting. +func TestScreenDialAddressRefusesPrivateTarget(t *testing.T) { + policy := NewPolicy() + if err := policy.screenDialAddress("10.0.0.9:443"); !errors.Is(err, ErrDestinationRefused) { + t.Fatalf("dial screen accepted a private target: %v", err) + } + // The kernel is handed the unmapped IPv4 form, so an ordinary public dial + // must still pass: a rule that refused it would break every delivery. + if err := policy.screenDialAddress("93.184.216.34:443"); err != nil { + t.Fatalf("dial screen refused a public target: %v", err) + } +} + +// The excerpt is written to a column whose CHECK refuses control characters and +// bounds the length, and it is displayed in operator tooling. An endpoint that +// answers with a terminal escape sequence must not reach either. +func TestSafeExcerptIsBoundedAndPrintable(t *testing.T) { + hostile := "\x1b[2Jerased\nline\ttab\x00null" + excerpt := SafeExcerpt(hostile) + for _, character := range excerpt { + if character < 0x20 || character == 0x7f { + t.Fatalf("excerpt retained control character %q: %q", character, excerpt) + } + } + long := make([]rune, 0, 600) + for index := 0; index < 600; index++ { + long = append(long, 'a') + } + if got := len([]rune(SafeExcerpt(string(long)))); got != MaxResponseExcerpt { + t.Fatalf("excerpt length = %d, want %d", got, MaxResponseExcerpt) + } +} diff --git a/apps/api/internal/platform/appstorejws/payloads.go b/apps/api/internal/platform/appstorejws/payloads.go index c2ed8cc6..dad62095 100644 --- a/apps/api/internal/platform/appstorejws/payloads.go +++ b/apps/api/internal/platform/appstorejws/payloads.go @@ -11,9 +11,13 @@ import ( // // - `price`, `currency`, `offerDiscountType` — Apple's own documentation says // not to use them for accounting, and Phase 9A persists no monetary value. -// - `appAccountToken` — a developer-chosen customer correlator. Reading it -// into memory at all is the first step toward persisting customer identity, -// which this phase excludes. +// - `appAccountToken` was on this list for Phase 9A and no longer is. Phase 9B +// is the phase with a gate for customer identity, and the value is read here +// so the association resolver can match it. It is hashed the moment it +// leaves this struct: `billing.AliasDigest` is the only thing that receives +// it, no Transaction Fact column holds it, and no log line, metric +// attribute, or audit record ever sees either the value or its digest. +// Phase 9A's fact-shape exclusion is unchanged. // // Anything not named here stays inside the encrypted raw input, recoverable by // a future phase that has a gate for it. @@ -65,6 +69,12 @@ type TransactionPayload struct { RevocationDate int64 `json:"revocationDate"` RevocationReason *int `json:"revocationReason"` Storefront string `json:"storefront"` + // AppAccountToken is the developer-chosen customer correlator, a UUID the + // app supplied at purchase time. Apple scopes it to the purchase rather than + // to the store account, which is exactly why it is evidence of moderate + // authority rather than an identity: it says the app believed this purchase + // belonged to that user, not that the store agrees. + AppAccountToken string `json:"appAccountToken"` } // RenewalPayload is the subset of JWSRenewalInfoDecodedPayload Phase 9A reads. diff --git a/apps/api/internal/platform/billingaccesspostgres/keyauth.go b/apps/api/internal/platform/billingaccesspostgres/keyauth.go new file mode 100644 index 00000000..87844952 --- /dev/null +++ b/apps/api/internal/platform/billingaccesspostgres/keyauth.go @@ -0,0 +1,61 @@ +package billingaccesspostgres + +import ( + "context" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingpostgres" +) + +// KeyAuthenticator adapts the ingestion repository's API-key authentication to +// the access package's port. +// +// The adapter exists so the access service depends on an interface it declares +// rather than on the ingestion service. There is exactly one implementation of +// key authentication in Mosaic — prefix lookup then constant-time digest +// comparison — and a second one would eventually disagree with the first about +// which keys are valid. +type KeyAuthenticator struct { + repository *billingpostgres.Repository +} + +func NewKeyAuthenticator(repository *billingpostgres.Repository) KeyAuthenticator { + return KeyAuthenticator{repository: repository} +} + +var _ billingaccess.KeyAuthenticator = KeyAuthenticator{} + +// AuthenticateServerKey resolves a secret server key. This is the second +// consumer of that authentication, after trusted-server observations. +func (k KeyAuthenticator) AuthenticateServerKey(ctx context.Context, raw string) (billingaccess.KeyScope, error) { + scope, err := k.repository.AuthenticateServerKey(ctx, raw) + if err != nil { + return billingaccess.KeyScope{}, billingaccess.ErrUnauthenticated + } + return billingaccess.KeyScope{ + APIKeyID: scope.APIKeyID, + OrganizationID: scope.OrganizationID, + ProjectID: scope.ProjectID, + EnvironmentID: scope.EnvironmentID, + EnvironmentMode: scope.EnvironmentMode, + ApplicationID: scope.ApplicationID, + }, nil +} + +// AuthenticateSDKKey resolves a public SDK key. It proves which Environment and +// Application are calling and nothing else — selecting a customer is the +// Customer Access Token's job, and a public key can never do it. +func (k KeyAuthenticator) AuthenticateSDKKey(ctx context.Context, raw string) (billingaccess.KeyScope, error) { + scope, err := k.repository.AuthenticateSDKKey(ctx, raw) + if err != nil { + return billingaccess.KeyScope{}, billingaccess.ErrUnauthenticated + } + return billingaccess.KeyScope{ + APIKeyID: scope.APIKeyID, + OrganizationID: scope.OrganizationID, + ProjectID: scope.ProjectID, + EnvironmentID: scope.EnvironmentID, + EnvironmentMode: scope.EnvironmentMode, + ApplicationID: scope.ApplicationID, + }, nil +} diff --git a/apps/api/internal/platform/billingaccesspostgres/repository.go b/apps/api/internal/platform/billingaccesspostgres/repository.go new file mode 100644 index 00000000..1ff109e0 --- /dev/null +++ b/apps/api/internal/platform/billingaccesspostgres/repository.go @@ -0,0 +1,582 @@ +// Package billingaccesspostgres is the PostgreSQL implementation of the +// billing access persistence port. +// +// Everything here reads committed state. There is no write path except the +// token lifecycle and audit events: an access surface that could repair or +// derive would eventually disagree with the projection, and two authoritative +// answers is worse than one slow one. +package billingaccesspostgres + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +var _ billingaccess.Repository = (*Repository)(nil) + +func (r *Repository) BillingEnabled(ctx context.Context, projectID string) (bool, error) { + var enabled bool + err := r.pool.QueryRow(ctx, + `SELECT billing_enabled FROM billing_project_settings WHERE project_id = $1`, projectID). + Scan(&enabled) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read billing enablement: %w", err) + } + return enabled, nil +} + +// --------------------------------------------------------------------------- +// Customer Access Tokens +// --------------------------------------------------------------------------- + +func (r *Repository) CreateToken(ctx context.Context, token billingaccess.Token, digest []byte, actorReference string) (billingaccess.Token, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingaccess.Token{}, fmt.Errorf("begin token issuance: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, + `INSERT INTO customer_access_tokens( + id, project_id, environment_id, billing_customer_id, token_digest, + audience, scopes, issued_by_api_key_id, issued_at, expires_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,NULLIF($8,''),$9,$10)`, + token.ID, token.ProjectID, token.EnvironmentID, token.CustomerID, digest, + token.Audience, token.Scopes, token.IssuedByAPIKeyID, token.IssuedAt, token.ExpiresAt); err != nil { + return billingaccess.Token{}, fmt.Errorf("insert customer access token: %w", err) + } + + // The audit event is written in the same transaction as the token: an + // issuance that is not auditable is a credential nobody can account for. + if err := recordAudit(ctx, tx, token.ProjectID, token.EnvironmentID, actorReference, + "billing.customer_token.issued", "customer_access_token", token.ID, + map[string]string{ + "billingCustomerId": token.CustomerID, + "audience": token.Audience, + "scopes": strings.Join(token.Scopes, ","), + "expiresAt": token.ExpiresAt.UTC().Format(time.RFC3339), + }, token.IssuedAt); err != nil { + return billingaccess.Token{}, err + } + + if err := tx.Commit(ctx); err != nil { + return billingaccess.Token{}, fmt.Errorf("commit token issuance: %w", err) + } + return token, nil +} + +const tokenColumns = `id, project_id, environment_id, billing_customer_id, audience, scopes, + COALESCE(issued_by_api_key_id,''), issued_at, expires_at, revoked_at, + COALESCE(revocation_reason,''), last_used_at` + +func scanToken(row pgx.Row) (billingaccess.Token, error) { + var token billingaccess.Token + err := row.Scan(&token.ID, &token.ProjectID, &token.EnvironmentID, &token.CustomerID, + &token.Audience, &token.Scopes, &token.IssuedByAPIKeyID, &token.IssuedAt, + &token.ExpiresAt, &token.RevokedAt, &token.RevocationReason, &token.LastUsedAt) + return token, err +} + +func (r *Repository) TokenByDigest(ctx context.Context, digest []byte) (billingaccess.Token, error) { + token, err := scanToken(r.pool.QueryRow(ctx, + `SELECT `+tokenColumns+` FROM customer_access_tokens WHERE token_digest = $1`, digest)) + if errors.Is(err, pgx.ErrNoRows) { + return billingaccess.Token{}, billingaccess.ErrUnauthenticated + } + if err != nil { + return billingaccess.Token{}, fmt.Errorf("read customer access token: %w", err) + } + return token, nil +} + +func (r *Repository) TouchToken(ctx context.Context, tokenID string, at time.Time) error { + _, err := r.pool.Exec(ctx, + `UPDATE customer_access_tokens SET last_used_at = $2 WHERE id = $1`, tokenID, at) + if err != nil { + return fmt.Errorf("record token use: %w", err) + } + return nil +} + +func (r *Repository) RevokeToken(ctx context.Context, scope billingaccess.KeyScope, tokenID, reason, actorReference string, at time.Time) (billingaccess.Token, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingaccess.Token{}, fmt.Errorf("begin token revocation: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // The tenant predicate is part of the statement, not a check the caller can + // forget: a token id from another Project simply matches no row. + token, err := scanToken(tx.QueryRow(ctx, + `UPDATE customer_access_tokens + SET revoked_at = COALESCE(revoked_at, $4), revocation_reason = COALESCE(revocation_reason, $5) + WHERE id = $1 AND project_id = $2 AND environment_id = $3 + RETURNING `+tokenColumns, + tokenID, scope.ProjectID, scope.EnvironmentID, at, reason)) + if errors.Is(err, pgx.ErrNoRows) { + return billingaccess.Token{}, billingaccess.ErrNotFound + } + if err != nil { + return billingaccess.Token{}, fmt.Errorf("revoke customer access token: %w", err) + } + + if err := recordAudit(ctx, tx, scope.ProjectID, scope.EnvironmentID, actorReference, + "billing.customer_token.revoked", "customer_access_token", tokenID, + map[string]string{"revocationReason": token.RevocationReason}, at); err != nil { + return billingaccess.Token{}, err + } + if err := tx.Commit(ctx); err != nil { + return billingaccess.Token{}, fmt.Errorf("commit token revocation: %w", err) + } + return token, nil +} + +func (r *Repository) ListTokens(ctx context.Context, scope billingaccess.KeyScope, customerID string, limit int) ([]billingaccess.Token, error) { + rows, err := r.pool.Query(ctx, + `SELECT `+tokenColumns+` + FROM customer_access_tokens + WHERE project_id = $1 AND environment_id = $2 AND billing_customer_id = $3 + ORDER BY issued_at DESC, id DESC + LIMIT $4`, scope.ProjectID, scope.EnvironmentID, customerID, limit) + if err != nil { + return nil, fmt.Errorf("list customer access tokens: %w", err) + } + defer rows.Close() + tokens := make([]billingaccess.Token, 0, limit) + for rows.Next() { + token, err := scanToken(rows) + if err != nil { + return nil, fmt.Errorf("scan customer access token: %w", err) + } + tokens = append(tokens, token) + } + return tokens, rows.Err() +} + +// --------------------------------------------------------------------------- +// Committed projections +// --------------------------------------------------------------------------- + +func (r *Repository) CurrentSnapshot(ctx context.Context, projectID, environmentID, customerID string) (billingaccess.SnapshotView, error) { + var view billingaccess.SnapshotView + var previousID *string + err := r.pool.QueryRow(ctx, + `SELECT s.id, s.project_id, s.environment_id, s.billing_customer_id, s.snapshot_version, + s.rule_version, s.computed_at, s.as_of, s.previous_snapshot_id, s.checksum, + s.change_reason + FROM customer_entitlement_pointers p + JOIN customer_entitlement_snapshots s ON s.id = p.current_snapshot_id + WHERE p.billing_customer_id = $1 AND p.environment_id = $2 AND p.project_id = $3`, + customerID, environmentID, projectID). + 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 current entitlement snapshot: %w", err) + } + + if previousID != nil { + // The previous version is read rather than assumed to be one less: a + // snapshot sequence has no gaps today, but deriving it arithmetically + // would make that an invariant nobody stated. + if err := r.pool.QueryRow(ctx, + `SELECT snapshot_version FROM customer_entitlement_snapshots WHERE id = $1`, *previousID). + Scan(&view.PreviousSnapshotVersion); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return billingaccess.SnapshotView{}, fmt.Errorf("read previous snapshot version: %w", err) + } + } + + sourcesByEntitlement, sources, err := r.readSources(ctx, view.SnapshotID) + if err != nil { + return billingaccess.SnapshotView{}, err + } + view.Sources = sources + + entryRows, err := r.pool.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`, view.SnapshotID) + if err != nil { + return billingaccess.SnapshotView{}, fmt.Errorf("read snapshot entries: %w", err) + } + defer entryRows.Close() + for entryRows.Next() { + var entry billingaccess.SnapshotEntry + if err := entryRows.Scan(&entry.EntitlementID, &entry.EntitlementKey, &entry.State, + &entry.EffectiveStart, &entry.EffectiveEnd, &entry.EndKnown, &entry.SourceCount, + &entry.UncertaintyReason, &entry.IsTestSource, &entry.ExplanationCode); err != nil { + return billingaccess.SnapshotView{}, fmt.Errorf("scan snapshot entry: %w", err) + } + entry.SourceIDs = sourcesByEntitlement[entry.EntitlementID] + view.Entries = append(view.Entries, entry) + } + if err := entryRows.Err(); err != nil { + return billingaccess.SnapshotView{}, fmt.Errorf("read snapshot entries: %w", err) + } + + view.Projection = billingaccess.ProjectionStatus{ + State: billingaccess.ProjectionCurrent, LastProjectedAt: view.ComputedAt, + } + return view, nil +} + +func (r *Repository) readSources(ctx context.Context, snapshotID string) (map[string][]string, []billingaccess.SnapshotSource, error) { + rows, err := r.pool.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 nil, nil, fmt.Errorf("read entitlement sources: %w", err) + } + defer rows.Close() + + byEntitlement := map[string][]string{} + sources := make([]billingaccess.SnapshotSource, 0, 8) + 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 { + return nil, nil, fmt.Errorf("scan entitlement source: %w", err) + } + byEntitlement[source.EntitlementID] = append(byEntitlement[source.EntitlementID], source.RowID) + sources = append(sources, source) + } + return byEntitlement, sources, rows.Err() +} + +// ProjectionStatusFor reports how far behind the customer's projection is. +// +// `pending` and `stale` are the same condition at different ages: facts exist +// that no snapshot has consumed. The distinction exists because a reader can +// reasonably wait out a pending projection and should escalate a stale one. +func (r *Repository) ProjectionStatusFor(ctx context.Context, projectID, environmentID, customerID string) (billingaccess.ProjectionStatus, error) { + status := billingaccess.ProjectionStatus{State: billingaccess.ProjectionCurrent} + + var lastProjected *time.Time + var diagnostics string + if err := r.pool.QueryRow(ctx, + `SELECT last_projected_at, diagnostics_status FROM billing_customers + WHERE id = $1 AND project_id = $2`, customerID, projectID). + Scan(&lastProjected, &diagnostics); err != nil { + return status, fmt.Errorf("read customer projection state: %w", err) + } + if lastProjected != nil { + status.LastProjectedAt = lastProjected.UTC() + } + + // Facts recorded after the last projection are the backlog. Counting rows + // rather than trusting a queue depth means an enqueue that never happened + // still shows up. + // + // The join goes through the materialized chain-digest closure rather than + // comparing `lineage_key_digest` to `purchase_chain_digest` directly. Only + // the first fact of a Google chain carries the lineage's root digest; every + // fact recorded after a plan change carries the successor token's digest, so + // the direct comparison omitted precisely the customers whose state is most + // likely to be behind and reported them as current. Reaching for the + // projection loader's recursive CTE instead would put a per-customer chain + // walk on the SDK sync path, which is the highest-QPS authenticated surface + // Mosaic has — the closure is maintained by trigger so this stays one join. + var pending int + if err := r.pool.QueryRow(ctx, + `SELECT count(*) + FROM billing_transaction_facts f + JOIN purchase_chain_digest_links d + ON d.project_id = f.project_id + AND d.environment_id = f.environment_id + AND d.chain_digest = f.purchase_chain_digest + JOIN purchase_lineages l + ON l.environment_id = d.environment_id + AND l.lineage_key_digest = d.root_digest + WHERE l.project_id = $1 AND l.environment_id = $2 AND l.billing_customer_id = $3 + AND ($4::timestamptz IS NULL OR f.recorded_at > $4)`, + projectID, environmentID, customerID, lastProjected).Scan(&pending); err != nil { + return status, fmt.Errorf("count pending facts: %w", err) + } + status.PendingFactCount = pending + + var failedJobs int + if err := r.pool.QueryRow(ctx, + `SELECT count(*) FROM projection_jobs + WHERE project_id = $1 AND scope_key = $2 AND status = 'failed'`, + projectID, "customer:"+customerID).Scan(&failedJobs); err != nil { + return status, fmt.Errorf("count failed projections: %w", err) + } + + switch { + case failedJobs > 0: + status.State = billingaccess.ProjectionFailed + status.DiagnosticCode = "entitlement.projection.failed" + case diagnostics == "identity_conflict": + status.State = billingaccess.ProjectionDegraded + status.DiagnosticCode = "entitlement.identity.conflictOpen" + case pending > 0 && lastProjected != nil && time.Since(*lastProjected) > billingaccess.StaleAfter: + status.State = billingaccess.ProjectionStale + case pending > 0: + status.State = billingaccess.ProjectionPending + } + if status.LastProjectedAt.IsZero() { + status.LastProjectedAt = time.Now().UTC() + if status.State == billingaccess.ProjectionCurrent { + status.State = billingaccess.ProjectionPending + } + } + return status, nil +} + +func (r *Repository) Customer(ctx context.Context, projectID, customerID string) (billingaccess.CustomerView, error) { + var view billingaccess.CustomerView + err := r.pool.QueryRow(ctx, + `SELECT c.id, c.project_id, c.status, c.diagnostics_status, c.current_projection_version, + c.last_projected_at, c.created_at, c.updated_at, + EXISTS (SELECT 1 FROM billing_customer_aliases a + WHERE a.billing_customer_id = c.id + AND a.alias_type = 'application_user_id' + AND a.effective_end IS NULL) + FROM billing_customers c + WHERE c.id = $1 AND c.project_id = $2`, customerID, projectID). + Scan(&view.ID, &view.ProjectID, &view.Status, &view.DiagnosticsStatus, + &view.CurrentProjectionVersion, &view.LastProjectedAt, &view.CreatedAt, + &view.UpdatedAt, &view.Identified) + if errors.Is(err, pgx.ErrNoRows) { + return billingaccess.CustomerView{}, billingaccess.ErrNotFound + } + if err != nil { + return billingaccess.CustomerView{}, fmt.Errorf("read billing customer: %w", err) + } + return view, nil +} + +const subscriptionColumns = `s.id, s.subscription_instance_id, COALESCE(i.purchase_lineage_id,''), + COALESCE(i.billing_customer_id,''), s.project_id, s.environment_id, s.projection_version, + s.rule_version, s.computed_at, s.as_of, COALESCE(i.provider,''), + COALESCE(s.current_product_id,''), COALESCE(s.prior_product_id,''), + s.access_state, s.lifecycle_state, s.renewal_intent, s.billing_state, s.uncertainty_reason, + s.period_start_at, s.period_end_at, s.grace_period_end_at, s.billing_retry_start_at, + s.pause_start_at, s.pause_resume_at, s.cancellation_effective_at, s.expiration_effective_at, + s.revocation_effective_at, s.refund_effective_at, s.is_test_source, s.checksum, + s.projection_reason, + COALESCE((SELECT si2.id FROM purchase_lineages l2 + JOIN subscription_instances si2 ON si2.purchase_lineage_id = l2.id + WHERE l2.id = (SELECT superseded_by_lineage_id FROM purchase_lineages + WHERE id = i.purchase_lineage_id)), ''), + COALESCE((SELECT count(*) FROM subscription_snapshot_facts f WHERE f.snapshot_id = s.id), 0)` + +func scanSubscription(row pgx.Row) (billingaccess.SubscriptionView, error) { + var view billingaccess.SubscriptionView + err := row.Scan(&view.SnapshotID, &view.SubscriptionInstanceID, &view.PurchaseLineageID, + &view.CustomerID, &view.ProjectID, &view.EnvironmentID, &view.ProjectionVersion, + &view.RuleVersion, &view.ComputedAt, &view.AsOf, &view.StorePlatform, + &view.ProductID, &view.PriorProductID, &view.AccessState, &view.LifecycleState, + &view.RenewalIntent, &view.BillingState, &view.UncertaintyReason, + &view.PeriodStart, &view.PeriodEnd, &view.GracePeriodEnd, &view.BillingRetryStart, + &view.PauseEffectiveAt, &view.PauseResumeAt, &view.CancellationEffectiveAt, + &view.ExpirationEffectiveAt, &view.RevocationEffectiveAt, &view.RefundEffectiveAt, + &view.IsTestSource, &view.Checksum, &view.ChangeReason, + &view.SupersededByInstanceID, &view.SourceFactCount) + return view, err +} + +func (r *Repository) Subscriptions(ctx context.Context, projectID, environmentID, customerID string, limit int, cursor string) ([]billingaccess.SubscriptionView, string, error) { + after, err := decodeCursor(cursor) + if err != nil { + return nil, "", billingaccess.ErrInvalid + } + rows, err := r.pool.Query(ctx, + `SELECT `+subscriptionColumns+` + FROM subscription_instances i + JOIN subscription_snapshots s ON s.id = i.current_snapshot_id + WHERE i.project_id = $1 AND i.environment_id = $2 AND i.billing_customer_id = $3 + AND ($4::text = '' OR i.id > $4) + ORDER BY i.id + LIMIT $5`, projectID, environmentID, customerID, after, limit+1) + if err != nil { + return nil, "", fmt.Errorf("list subscriptions: %w", err) + } + defer rows.Close() + + views := make([]billingaccess.SubscriptionView, 0, limit) + for rows.Next() { + view, err := scanSubscription(rows) + if err != nil { + return nil, "", fmt.Errorf("scan subscription snapshot: %w", err) + } + views = append(views, view) + } + if err := rows.Err(); err != nil { + return nil, "", fmt.Errorf("list subscriptions: %w", err) + } + // Keyset paging: one extra row is read to learn whether another page exists, + // which is cheaper and more stable under concurrent writes than an offset. + next := "" + if len(views) > limit { + views = views[:limit] + next = encodeCursor(views[len(views)-1].SubscriptionInstanceID) + } + return views, next, nil +} + +func (r *Repository) Subscription(ctx context.Context, projectID, instanceID string) (billingaccess.SubscriptionView, error) { + view, err := scanSubscription(r.pool.QueryRow(ctx, + `SELECT `+subscriptionColumns+` + FROM subscription_instances i + JOIN subscription_snapshots s ON s.id = i.current_snapshot_id + WHERE i.project_id = $1 AND i.id = $2`, projectID, instanceID)) + if errors.Is(err, pgx.ErrNoRows) { + return billingaccess.SubscriptionView{}, billingaccess.ErrNotFound + } + if err != nil { + return billingaccess.SubscriptionView{}, fmt.Errorf("read subscription snapshot: %w", err) + } + return view, nil +} + +func (r *Repository) Timeline(ctx context.Context, projectID, instanceID string, limit int, cursor string) ([]billingaccess.TimelineEntry, string, error) { + after, err := decodeCursor(cursor) + if err != nil { + return nil, "", billingaccess.ErrInvalid + } + rows, err := r.pool.Query(ctx, + `SELECT id, entry_type, effective_at, observed_at, + COALESCE(subscription_instance_id,''), COALESCE(one_time_purchase_instance_id,''), + COALESCE(product_id,''), COALESCE(prior_product_id,''), explanation_code, detail + FROM subscription_timeline_entries + WHERE project_id = $1 AND subscription_instance_id = $2 + AND ($3::text = '' OR id > $3) + ORDER BY id + LIMIT $4`, projectID, instanceID, after, limit+1) + if err != nil { + return nil, "", fmt.Errorf("read subscription timeline: %w", err) + } + defer rows.Close() + + entries := make([]billingaccess.TimelineEntry, 0, limit) + for rows.Next() { + var entry billingaccess.TimelineEntry + var detail []byte + if err := rows.Scan(&entry.ID, &entry.EntryType, &entry.EffectiveAt, &entry.ObservedAt, + &entry.SubscriptionInstanceID, &entry.OneTimeInstanceID, &entry.ProductID, + &entry.PriorProductID, &entry.ExplanationCode, &detail); err != nil { + return nil, "", fmt.Errorf("scan timeline entry: %w", err) + } + // The detail column already passed the ledger safety guard on write, so + // it is decoded rather than re-filtered here. + if len(detail) > 0 { + _ = json.Unmarshal(detail, &entry.Detail) + } + entries = append(entries, entry) + } + if err := rows.Err(); err != nil { + return nil, "", fmt.Errorf("read subscription timeline: %w", err) + } + next := "" + if len(entries) > limit { + entries = entries[:limit] + next = encodeCursor(entries[len(entries)-1].ID) + } + return entries, next, nil +} + +func (r *Repository) RecordAudit(ctx context.Context, projectID, environmentID, actorReference, action, resourceType, resourceID string, metadata map[string]string, at time.Time) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin audit write: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := recordAudit(ctx, tx, projectID, environmentID, actorReference, action, resourceType, resourceID, metadata, at); err != nil { + return err + } + return tx.Commit(ctx) +} + +func recordAudit(ctx context.Context, tx pgx.Tx, projectID, environmentID, actorReference, action, resourceType, resourceID string, metadata map[string]string, at time.Time) error { + var organizationID string + if err := tx.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id = $1`, projectID). + Scan(&organizationID); err != nil { + return fmt.Errorf("read organization for audit: %w", err) + } + encoded := []byte("{}") + if len(metadata) > 0 { + if payload, err := json.Marshal(metadata); err == nil { + encoded = payload + } + } + actor := actorReference + if actor == "" { + actor = "system" + } + id := "aud_" + base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s-%d", resourceID, at.UnixNano()))) + if len(id) > 96 { + id = id[:96] + } + _, 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,NULLIF($5,''),$6,$7,$8,$9,$10) + ON CONFLICT (id) DO NOTHING`, + id, actor, organizationID, projectID, environmentID, action, resourceType, + resourceID, encoded, at) + if err != nil { + return fmt.Errorf("insert audit event: %w", err) + } + return nil +} + +// --------------------------------------------------------------------------- +// Cursors +// --------------------------------------------------------------------------- + +// Cursors are opaque to the caller and carry exactly one value: the last id of +// the previous page. Encoding it keeps callers from constructing one by hand +// and then depending on its shape. +func encodeCursor(value string) string { + return base64.RawURLEncoding.EncodeToString([]byte(value)) +} + +func decodeCursor(cursor string) (string, error) { + if cursor == "" { + return "", nil + } + decoded, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return "", err + } + if len(decoded) > 128 { + return "", errors.New("cursor too long") + } + return string(decoded), nil +} diff --git a/apps/api/internal/platform/billingcustomerpostgres/conflicts.go b/apps/api/internal/platform/billingcustomerpostgres/conflicts.go new file mode 100644 index 00000000..e857bd34 --- /dev/null +++ b/apps/api/internal/platform/billingcustomerpostgres/conflicts.go @@ -0,0 +1,431 @@ +package billingcustomerpostgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" +) + +// conflictColumns deliberately omits alias_digest. An operator resolves a +// conflict from the customer identifiers and the alias *family*; the digest is +// a stable per-person identifier and nothing on the surface needs it. +// +// DiagnosticCode has no column of its own: migration 00044 carries it in the +// existing `detail` object, so it is projected out here rather than duplicated +// into a column that could disagree with the document. +const conflictColumns = `id, project_id, conflict_scope, COALESCE(purchase_lineage_id,''), + COALESCE(alias_type,''), status, first_customer_id, second_customer_id, + COALESCE(detail->>'diagnosticCode',''), opened_at, resolved_at, COALESCE(resolution_action,''), + COALESCE(detail->>'resolutionReason','')` + +func scanConflict(row pgx.Row) (billingcustomer.Conflict, error) { + var conflict billingcustomer.Conflict + err := row.Scan(&conflict.ID, &conflict.ProjectID, &conflict.Scope, &conflict.PurchaseLineageID, + &conflict.AliasType, &conflict.Status, &conflict.FirstCustomerID, &conflict.SecondCustomerID, + &conflict.DiagnosticCode, &conflict.OpenedAt, &conflict.ResolvedAt, &conflict.ResolutionAction, + &conflict.ResolutionReason) + return conflict, err +} + +// OpenConflict opens the single open conflict for a disputed subject and +// freezes that subject, in one transaction. +// +// The freeze is not a separate call on purpose. A conflict that did not freeze +// would let the very next projection grant access to whichever candidate +// happened to be read first, which is the outcome OD-10 exists to prevent — and +// splitting the two across transactions leaves exactly that window open on +// every crash between them. +// +// It is idempotent. Re-opening returns the existing open conflict rather than a +// second one, and re-applies the freeze, so a caller's retry converges instead +// of accumulating operator work. The uniqueness is the database's: +// `billing_identity_conflicts_open_lineage_idx` and +// `…_open_alias_idx` are partial unique indexes over the two dispute subjects, +// so a concurrent second opener loses at the index rather than in Go. +func (r *Repository) OpenConflict(ctx context.Context, conflict billingcustomer.Conflict) (billingcustomer.Conflict, error) { + if conflict.Scope == "" { + conflict.Scope = billingcustomer.ConflictScopeLineage + } + switch conflict.Scope { + case billingcustomer.ConflictScopeLineage: + if conflict.PurchaseLineageID == "" { + return billingcustomer.Conflict{}, billingcustomer.ErrNotFound + } + case billingcustomer.ConflictScopeAlias: + if conflict.AliasType == "" || len(conflict.Digest()) == 0 { + return billingcustomer.Conflict{}, billingcustomer.ErrInvalidAlias + } + default: + return billingcustomer.Conflict{}, billingcustomer.ErrConflict + } + + detail := []byte(`{}`) + if conflict.DiagnosticCode != "" { + if raw, err := json.Marshal(map[string]string{"diagnosticCode": conflict.DiagnosticCode}); err == nil { + detail = raw + } + } + + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("begin identity conflict: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + conflictID := conflict.ID + // The insert runs inside a savepoint so a uniqueness loss does not abort + // the surrounding transaction — the freeze still has to happen on the + // idempotent path. + savepoint, err := tx.Begin(ctx) + if err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("begin identity conflict savepoint: %w", err) + } + _, insertErr := savepoint.Exec(ctx, + `INSERT INTO billing_identity_conflicts( + id, project_id, conflict_scope, purchase_lineage_id, alias_type, alias_digest, + status, first_customer_id, second_customer_id, detail, opened_at) + VALUES ($1,$2,$3,NULLIF($4,''),NULLIF($5,''),$6,'open',$7,$8,$9,$10)`, + conflict.ID, conflict.ProjectID, conflict.Scope, conflict.PurchaseLineageID, + conflict.AliasType, nullBytes(conflict.Digest()), conflict.FirstCustomerID, + conflict.SecondCustomerID, detail, conflict.OpenedAt) + switch { + case insertErr == nil: + if err := savepoint.Commit(ctx); err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("commit identity conflict insert: %w", err) + } + case isUniqueViolation(insertErr): + if err := savepoint.Rollback(ctx); err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("roll back identity conflict insert: %w", err) + } + existing, err := openConflictFor(ctx, tx, conflict) + if err != nil { + return billingcustomer.Conflict{}, err + } + conflictID = existing + default: + _ = savepoint.Rollback(ctx) + if isForeignKeyViolation(insertErr) { + return billingcustomer.Conflict{}, billingcustomer.ErrNotFound + } + return billingcustomer.Conflict{}, fmt.Errorf("open identity conflict: %w", insertErr) + } + + if err := freezeDisputedSubject(ctx, tx, conflict); err != nil { + return billingcustomer.Conflict{}, err + } + + opened, err := scanConflict(tx.QueryRow(ctx, + `SELECT `+conflictColumns+` FROM billing_identity_conflicts WHERE id=$1 AND project_id=$2`, + conflictID, conflict.ProjectID)) + if err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("read opened identity conflict: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("commit identity conflict: %w", err) + } + return opened, nil +} + +// openConflictFor re-reads the open conflict that already owns this dispute +// subject, which is what makes re-opening idempotent. +func openConflictFor(ctx context.Context, tx pgx.Tx, conflict billingcustomer.Conflict) (string, error) { + var id string + var err error + if conflict.Scope == billingcustomer.ConflictScopeAlias { + err = tx.QueryRow(ctx, + `SELECT id FROM billing_identity_conflicts + WHERE project_id=$1 AND conflict_scope='alias' AND alias_type=$2 + AND alias_digest=$3 AND status='open'`, + conflict.ProjectID, conflict.AliasType, conflict.Digest()).Scan(&id) + } else { + err = tx.QueryRow(ctx, + `SELECT id FROM billing_identity_conflicts + WHERE project_id=$1 AND conflict_scope='lineage' AND purchase_lineage_id=$2 + AND status='open'`, + conflict.ProjectID, conflict.PurchaseLineageID).Scan(&id) + } + if errors.Is(err, pgx.ErrNoRows) { + // The uniqueness that rejected the insert was the primary key, not the + // open-conflict index: the caller reused a conflict identifier. + return "", billingcustomer.ErrConflict + } + if err != nil { + return "", fmt.Errorf("read existing identity conflict: %w", err) + } + return id, nil +} + +// freezeDisputedSubject is the other half of the transaction. +// +// A lineage-scoped conflict freezes the lineage, so the projector preserves the +// last committed state instead of choosing a claimant. An alias-scoped conflict +// disputes no lineage, so it freezes the customer the caller tried to extend — +// which is what stops the next identical request quietly retrying the same +// reassignment. The other customer is deliberately untouched: its grants come +// from its own lineages and are not in dispute, and freezing a paying customer +// because someone else's backend sent a bad attach would be a self-inflicted +// outage. +func freezeDisputedSubject(ctx context.Context, tx pgx.Tx, conflict billingcustomer.Conflict) error { + if conflict.Scope == billingcustomer.ConflictScopeAlias { + tag, err := tx.Exec(ctx, + `UPDATE billing_customers + SET status='frozen', diagnostics_status='identity_conflict', updated_at=$3 + WHERE id=$1 AND project_id=$2 AND status <> 'anonymized'`, + conflict.FirstCustomerID, conflict.ProjectID, conflict.OpenedAt) + if err != nil { + return fmt.Errorf("freeze disputed billing customer: %w", err) + } + if tag.RowsAffected() == 0 { + return billingcustomer.ErrNotFound + } + return nil + } + tag, err := tx.Exec(ctx, + `UPDATE purchase_lineages + SET projection_frozen=true, diagnostic_status='identity_conflict', updated_at=$3 + WHERE id=$1 AND project_id=$2`, + conflict.PurchaseLineageID, conflict.ProjectID, conflict.OpenedAt) + if err != nil { + return fmt.Errorf("freeze disputed purchase lineage: %w", err) + } + if tag.RowsAffected() == 0 { + return billingcustomer.ErrNotFound + } + return nil +} + +func (r *Repository) Conflict(ctx context.Context, actor billingcustomer.Actor, projectID, conflictID string) (billingcustomer.Conflict, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, operatorRoles...); err != nil { + return billingcustomer.Conflict{}, err + } + conflict, err := scanConflict(r.pool.QueryRow(ctx, + `SELECT `+conflictColumns+` FROM billing_identity_conflicts WHERE id=$1 AND project_id=$2`, + conflictID, projectID)) + if errors.Is(err, pgx.ErrNoRows) { + return billingcustomer.Conflict{}, billingcustomer.ErrNotFound + } + if err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("read identity conflict: %w", err) + } + return conflict, nil +} + +func (r *Repository) ListConflicts(ctx context.Context, actor billingcustomer.Actor, projectID, status string) ([]billingcustomer.Conflict, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, operatorRoles...); err != nil { + return nil, err + } + rows, err := r.pool.Query(ctx, + `SELECT `+conflictColumns+` FROM billing_identity_conflicts + WHERE project_id=$1 AND ($2::text = '' OR status = $2) + ORDER BY opened_at DESC, id`, projectID, status) + if err != nil { + return nil, fmt.Errorf("list identity conflicts: %w", err) + } + defer rows.Close() + conflicts := make([]billingcustomer.Conflict, 0, 8) + for rows.Next() { + conflict, err := scanConflict(rows) + if err != nil { + return nil, fmt.Errorf("scan identity conflict: %w", err) + } + conflicts = append(conflicts, conflict) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read identity conflicts: %w", err) + } + return conflicts, nil +} + +// ResolveConflict applies an operator's decision in one transaction. +// +// The row is taken FOR UPDATE and re-read under the lock, so two operators +// clicking at once cannot both apply an action, and the second sees the +// conflict already resolved rather than overwriting the first's decision. +// +// An action naming a customer that is not party to the conflict is refused. A +// resolution surface that accepted an arbitrary customer identifier would be an +// unaudited "give this purchase to anyone" control, which is strictly more +// authority than the dispute it is supposed to settle. +func (r *Repository) ResolveConflict(ctx context.Context, actor billingcustomer.Actor, projectID, conflictID, action, assignedCustomerID, reason string, now time.Time) (billingcustomer.Conflict, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, operatorRoles...); err != nil { + return billingcustomer.Conflict{}, err + } + switch action { + case "assigned_first", "assigned_second", "detached_both": + default: + return billingcustomer.Conflict{}, billingcustomer.ErrConflict + } + if strings.TrimSpace(reason) == "" || len(reason) > billingcustomer.MaxResolutionReasonLength { + return billingcustomer.Conflict{}, billingcustomer.ErrInvalidAlias + } + + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("begin identity conflict resolution: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var scope, lineageID, aliasType string + var aliasDigest []byte + var first, second string + err = tx.QueryRow(ctx, + `SELECT conflict_scope, COALESCE(purchase_lineage_id,''), COALESCE(alias_type,''), + alias_digest, first_customer_id, second_customer_id + FROM billing_identity_conflicts + WHERE id=$1 AND project_id=$2 AND status='open' FOR UPDATE`, conflictID, projectID). + Scan(&scope, &lineageID, &aliasType, &aliasDigest, &first, &second) + if errors.Is(err, pgx.ErrNoRows) { + // A prior attempt may have committed the pointer and conflict row before + // the application service failed to enqueue every affected aggregate. + // Treat an identical resolution as an idempotent retry so the caller can + // finish those projection obligations. A different action or reason is a + // genuine attempt to rewrite an operator decision and remains refused. + existing, readErr := scanConflict(tx.QueryRow(ctx, + `SELECT `+conflictColumns+` FROM billing_identity_conflicts WHERE id=$1 AND project_id=$2`, + conflictID, projectID)) + switch { + case errors.Is(readErr, pgx.ErrNoRows): + return billingcustomer.Conflict{}, billingcustomer.ErrNotFound + case readErr != nil: + return billingcustomer.Conflict{}, fmt.Errorf("read resolved identity conflict: %w", readErr) + case existing.Status == "resolved" && existing.ResolutionAction == action && + existing.ResolutionReason == strings.TrimSpace(reason): + return existing, nil + default: + return billingcustomer.Conflict{}, billingcustomer.ErrConflict + } + } + if err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("read identity conflict for resolution: %w", err) + } + + assigned, err := assignedParty(action, assignedCustomerID, first, second) + if err != nil { + return billingcustomer.Conflict{}, err + } + + if scope == billingcustomer.ConflictScopeAlias { + if err := applyAliasResolution(ctx, tx, actor, projectID, conflictID, aliasType, + aliasDigest, assigned, now); err != nil { + return billingcustomer.Conflict{}, err + } + } else { + // The lineage is still frozen at this point, so the assignment is written + // here rather than through AttachLineageCustomer — which refuses a frozen + // lineage by design. Unfreezing is the caller's next step, after the + // resolution has been committed and audited. + if _, err := tx.Exec(ctx, + `UPDATE purchase_lineages SET billing_customer_id=NULLIF($3,''), updated_at=$4 + WHERE id=$1 AND project_id=$2`, lineageID, projectID, assigned, now); err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("apply lineage conflict resolution: %w", err) + } + } + + // The reason joins the diagnostic code in the existing detail document + // rather than taking a column of its own: 00044 already established detail + // as where a conflict's explanatory fields live, and one document cannot + // disagree with itself the way a column and a document can. + if _, err := tx.Exec(ctx, + `UPDATE billing_identity_conflicts + SET status='resolved', resolved_at=$3, resolved_by_actor_id=NULLIF($4,''), resolution_action=$5, + detail = detail || jsonb_build_object('resolutionReason', $6::text) + WHERE id=$1 AND project_id=$2 AND status='open'`, + conflictID, projectID, now, actor.ID, action, strings.TrimSpace(reason)); err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("close identity conflict: %w", err) + } + + resolved, err := scanConflict(tx.QueryRow(ctx, + `SELECT `+conflictColumns+` FROM billing_identity_conflicts WHERE id=$1 AND project_id=$2`, + conflictID, projectID)) + if err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("read resolved identity conflict: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return billingcustomer.Conflict{}, fmt.Errorf("commit identity conflict resolution: %w", err) + } + return resolved, nil +} + +// assignedParty maps an action to the customer it awards the disputed subject +// to, and refuses an explicit identifier that is not party to the conflict. +func assignedParty(action, assignedCustomerID, first, second string) (string, error) { + switch action { + case "assigned_first": + if assignedCustomerID != "" && assignedCustomerID != first { + return "", billingcustomer.ErrConflict + } + return first, nil + case "assigned_second": + if assignedCustomerID != "" && assignedCustomerID != second { + return "", billingcustomer.ErrConflict + } + return second, nil + default: + if assignedCustomerID != "" { + // "Detach both" names no winner. An identifier alongside it means the + // operator meant something else, and guessing which is not this + // layer's decision to make. + return "", billingcustomer.ErrConflict + } + return "", nil + } +} + +// applyAliasResolution moves — or removes — the live resolution for a disputed +// alias digest. +// +// The end-date and the re-attach are in the caller's transaction and in this +// order, so the partial unique index never sees two live rows for the digest. +// The previous holder's row is kept and end-dated rather than deleted: who was +// linked when is exactly the history an operator needs if the resolution turns +// out to be wrong. +func applyAliasResolution(ctx context.Context, tx pgx.Tx, actor billingcustomer.Actor, + projectID, conflictID, aliasType string, aliasDigest []byte, assigned string, now time.Time) error { + var currentHolder string + err := tx.QueryRow(ctx, + `UPDATE billing_customer_aliases + SET effective_end = GREATEST($4, effective_start), revoked_by_actor_id = NULLIF($5,'') + WHERE project_id=$1 AND alias_type=$2 AND alias_digest=$3 AND effective_end IS NULL + RETURNING billing_customer_id`, + projectID, aliasType, aliasDigest, now, actor.ID).Scan(¤tHolder) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("end-date disputed alias: %w", err) + } + if assigned == "" || assigned == currentHolder { + if assigned != "" { + // The winner already held it. Re-insert so the resolution leaves a + // live row rather than an alias nobody resolves to. + return insertOperatorAlias(ctx, tx, projectID, conflictID, aliasType, aliasDigest, assigned, now) + } + return nil + } + return insertOperatorAlias(ctx, tx, projectID, conflictID, aliasType, aliasDigest, assigned, now) +} + +func insertOperatorAlias(ctx context.Context, tx pgx.Tx, projectID, conflictID, aliasType string, + aliasDigest []byte, customerID string, now time.Time) error { + _, err := tx.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)`, + "bca_"+hashID(conflictID, customerID, now.UnixNano()), projectID, customerID, + aliasType, aliasDigest, now) + if err != nil { + if isUniqueViolation(err) { + return billingcustomer.ErrConflict + } + if isForeignKeyViolation(err) { + return billingcustomer.ErrNotFound + } + return fmt.Errorf("attach alias for conflict resolution: %w", err) + } + return nil +} diff --git a/apps/api/internal/platform/billingcustomerpostgres/identity_integration_test.go b/apps/api/internal/platform/billingcustomerpostgres/identity_integration_test.go new file mode 100644 index 00000000..43c3754c --- /dev/null +++ b/apps/api/internal/platform/billingcustomerpostgres/identity_integration_test.go @@ -0,0 +1,260 @@ +package billingcustomerpostgres + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "os" + "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/billingcustomer" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +// These tests cover only the two guarantees that live in PostgreSQL rather than +// in Go, where a unit test with a fake repository would pass while the real +// behaviour was broken: +// +// - one identity cannot resolve to two Billing Customers, enforced by the +// partial unique index rather than by a pre-check the caller can race; +// - opening an identity conflict freezes the disputed subject in the same +// transaction, and re-opening converges on the one open conflict. +// +// Everything else in this package — role checks, column mapping, cursor +// encoding — is either already covered by the domain's own tests or is not a +// risk worth a database round trip. + +func testPool(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + 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(), 60*time.Second) + t.Cleanup(cancel) + + db, err := sql.Open("pgx", databaseURL) + 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.Fatalf("apply migrations: %v", err) + } + _ = db.Close() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + return pool, ctx +} + +type tenant struct { + projectID string + environmentID string + applicationID string + firstCustomer string + otherCustomer string +} + +// seedTenant builds the minimum tenant billing identity needs: a Project with +// billing enabled, one production Environment, one Application, and two +// customers so a dispute has two parties. +func seedTenant(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) tenant { + t.Helper() + now := time.Now().UTC() + scope := tenant{ + projectID: "proj_ident_" + suffix, + environmentID: "env_ident_" + suffix, + applicationID: "app_ident_" + suffix, + firstCustomer: "bcu_ident_" + suffix + "_a", + otherCustomer: "bcu_ident_" + suffix + "_b", + } + organizationID := "org_ident_" + suffix + + cleanupTenant(ctx, pool, scope.projectID) + statements := []struct { + query string + args []any + }{ + {`INSERT INTO organizations(id,name,created_at,updated_at) VALUES ($1,'Identity Test',$2,$2) + ON CONFLICT (id) DO NOTHING`, []any{organizationID, now}}, + {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES ($1,$2,$3,'Identity','active',$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{scope.projectID, organizationID, "identity-" + suffix, 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{scope.environmentID, scope.projectID, now}}, + {`INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES ($1,$2,'Identity App','ios',$3,$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{scope.applicationID, scope.projectID, "com.mosaic.identity." + suffix, now}}, + {`INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) + VALUES ($1,$2,'active','none',$3,$3) ON CONFLICT (id) DO NOTHING`, + []any{scope.firstCustomer, scope.projectID, now}}, + {`INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) + VALUES ($1,$2,'active','none',$3,$3) ON CONFLICT (id) DO NOTHING`, + []any{scope.otherCustomer, scope.projectID, now}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatalf("seed identity tenant: %v", err) + } + } + t.Cleanup(func() { cleanupTenant(context.Background(), pool, scope.projectID) }) + return scope +} + +func cleanupTenant(ctx context.Context, pool *pgxpool.Pool, projectID string) { + // Association evidence carries an append-only trigger, so it is disabled for + // the teardown of test data only. Nothing in the package's own code path + // touches the trigger. + _, _ = pool.Exec(ctx, + `ALTER TABLE billing_association_evidence DISABLE TRIGGER billing_association_evidence_append_only`) + for _, statement := range []string{ + `DELETE FROM audit_events WHERE project_id=$1`, + `DELETE FROM billing_identity_conflicts WHERE project_id=$1`, + `DELETE FROM billing_association_evidence WHERE project_id=$1`, + `DELETE FROM billing_customer_aliases WHERE project_id=$1`, + `DELETE FROM purchase_lineages WHERE project_id=$1`, + `DELETE FROM billing_customers WHERE project_id=$1`, + } { + _, _ = pool.Exec(ctx, statement, projectID) + } + _, _ = pool.Exec(ctx, + `ALTER TABLE billing_association_evidence ENABLE TRIGGER billing_association_evidence_append_only`) +} + +// One application-user identity must never resolve to two Billing Customers. +// Without the partial unique index behind AttachAlias, a concurrent login would +// attach the same person to two customers and each would hold half their +// purchases — the exact proliferation failure plan §5a exists to prevent. This +// is the guarantee the repository delegates to the database instead of +// pre-checking in Go, so only a real database can prove it holds. +func TestAttachAliasRefusesASecondLiveResolution(t *testing.T) { + pool, ctx := testPool(t) + scope := seedTenant(t, ctx, pool, "alias") + repository := New(pool) + now := time.Now().UTC() + + digest := billingcustomer.AliasDigest(billingcustomer.AliasApplicationUser, "user-42") + alias := func(id, customerID string) billingcustomer.Alias { + return billingcustomer.Alias{ + ID: id, ProjectID: scope.projectID, BillingCustomerID: customerID, + AliasType: billingcustomer.AliasApplicationUser, + SourceAuthority: billingcustomer.AuthorityTrustedServer, + VerificationStatus: "verified", EffectiveStart: now, CreatedAt: now, + }.WithDigest(digest) + } + + if _, err := repository.AttachAlias(ctx, alias("bca_ident_first", scope.firstCustomer)); err != nil { + t.Fatalf("first attach rejected: %v", err) + } + _, err := repository.AttachAlias(ctx, alias("bca_ident_second", scope.otherCustomer)) + if !errors.Is(err, billingcustomer.ErrConflict) { + t.Fatalf("second attach returned %v, want ErrConflict", err) + } + + // The resolver must still see exactly one answer for the digest, keyed the + // way the pure resolver indexes it (raw digest bytes). + resolutions, err := repository.ActiveAliasResolutions(ctx, scope.projectID, [][]byte{digest}) + if err != nil { + t.Fatalf("read active resolutions: %v", err) + } + if got := resolutions[string(digest)]; got != scope.firstCustomer { + t.Fatalf("digest resolves to %q, want %q", got, scope.firstCustomer) + } +} + +// Opening a lineage-scoped conflict must freeze the lineage in the same +// transaction, and re-opening must converge on the one open conflict. A +// conflict that did not freeze would let the next projection grant the purchase +// to whichever candidate it read first (OD-10), and a second conflict row for +// the same lineage would put the same dispute in an operator's queue twice with +// no way to tell which resolution wins. +func TestOpenConflictFreezesLineageAndIsIdempotent(t *testing.T) { + pool, ctx := testPool(t) + scope := seedTenant(t, ctx, pool, "conflict") + repository := New(pool) + now := time.Now().UTC() + + // The fact-commit transaction is the only writer of purchase lineages, so + // the row is seeded the way it writes it. This test is about the conflict + // and the freeze, not about lineage creation. + lineageID := "bpl_ident_conflict" + if _, err := pool.Exec(ctx, + `INSERT INTO purchase_lineages( + id, project_id, environment_id, environment_mode, application_id, provider, + store_environment, lineage_key_digest, lineage_type, projection_frozen, + diagnostic_status, created_at, updated_at) + VALUES ($1,$2,$3,'production',$4,'app_store','production',$5,'subscription',false, + 'identity_unresolved',$6,$6) + ON CONFLICT (environment_id, provider, lineage_key_digest) DO NOTHING`, + lineageID, scope.projectID, scope.environmentID, scope.applicationID, + sha256Of("chain-ident-conflict"), now); err != nil { + t.Fatalf("seed purchase lineage: %v", err) + } + lineage, err := repository.Lineage(ctx, scope.projectID, lineageID) + if err != nil { + t.Fatalf("read seeded lineage: %v", err) + } + + conflict := billingcustomer.Conflict{ + ID: "bic_ident_first", ProjectID: scope.projectID, + Scope: billingcustomer.ConflictScopeLineage, PurchaseLineageID: lineage.ID, + Status: "open", FirstCustomerID: scope.firstCustomer, SecondCustomerID: scope.otherCustomer, + DiagnosticCode: billingcustomer.DiagnosticMultipleClaims, OpenedAt: now, + } + opened, err := repository.OpenConflict(ctx, conflict) + if err != nil { + t.Fatalf("open conflict: %v", err) + } + if opened.DiagnosticCode != billingcustomer.DiagnosticMultipleClaims { + t.Fatalf("diagnostic code %q was not mapped out of detail", opened.DiagnosticCode) + } + + frozen, err := repository.Lineage(ctx, scope.projectID, lineage.ID) + if err != nil { + t.Fatalf("re-read lineage: %v", err) + } + if !frozen.ProjectionFrozen || frozen.DiagnosticStatus != "identity_conflict" { + t.Fatalf("opening a conflict left the lineage frozen=%v diagnostic=%q", + frozen.ProjectionFrozen, frozen.DiagnosticStatus) + } + + // Re-opening with a fresh identifier must return the existing conflict. + second := conflict + second.ID = "bic_ident_second" + reopened, err := repository.OpenConflict(ctx, second) + if err != nil { + t.Fatalf("re-open conflict: %v", err) + } + if reopened.ID != opened.ID { + t.Fatalf("re-opening produced conflict %q, want the existing %q", reopened.ID, opened.ID) + } + var open int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM billing_identity_conflicts + WHERE purchase_lineage_id=$1 AND status='open'`, lineage.ID).Scan(&open); err != nil { + t.Fatal(err) + } + if open != 1 { + t.Fatalf("two OpenConflict calls left %d open conflicts, want one", open) + } +} + +// sha256Of produces a 32-byte lineage key digest for seeded rows. +func sha256Of(value string) []byte { + sum := sha256.Sum256([]byte(value)) + return sum[:] +} diff --git a/apps/api/internal/platform/billingcustomerpostgres/lineages.go b/apps/api/internal/platform/billingcustomerpostgres/lineages.go new file mode 100644 index 00000000..7e9d712e --- /dev/null +++ b/apps/api/internal/platform/billingcustomerpostgres/lineages.go @@ -0,0 +1,148 @@ +package billingcustomerpostgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" +) + +const lineageColumns = `id, project_id, environment_id, environment_mode, application_id, provider, + store_environment, lineage_key_digest, lineage_type, COALESCE(billing_customer_id,''), + COALESCE(superseded_by_lineage_id,''), projection_frozen, diagnostic_status, created_at, updated_at` + +func scanLineage(row pgx.Row) (billingcustomer.Lineage, error) { + var lineage billingcustomer.Lineage + err := row.Scan(&lineage.ID, &lineage.ProjectID, &lineage.EnvironmentID, &lineage.EnvironmentMode, + &lineage.ApplicationID, &lineage.Provider, &lineage.StoreEnvironment, &lineage.LineageKeyDigest, + &lineage.LineageType, &lineage.BillingCustomerID, &lineage.SupersededByLineageID, + &lineage.ProjectionFrozen, &lineage.DiagnosticStatus, &lineage.CreatedAt, &lineage.UpdatedAt) + return lineage, err +} + +// LineageByKey reads the lineage for one provider chain key. +// +// It is scoped by Environment and provider rather than by Project because that +// triple is the UNIQUE constraint the schema declares, and because Apple's chain +// digest is unique only per (store environment, original transaction id): two +// sandbox Environments in one Project can legitimately hold the same digest. +func (r *Repository) LineageByKey(ctx context.Context, environmentID, provider string, keyDigest []byte) (billingcustomer.Lineage, error) { + if len(keyDigest) == 0 { + return billingcustomer.Lineage{}, billingcustomer.ErrNotFound + } + lineage, err := scanLineage(r.pool.QueryRow(ctx, + `SELECT `+lineageColumns+` FROM purchase_lineages + WHERE environment_id=$1 AND provider=$2 AND lineage_key_digest=$3`, + environmentID, provider, keyDigest)) + if errors.Is(err, pgx.ErrNoRows) { + return billingcustomer.Lineage{}, billingcustomer.ErrNotFound + } + if err != nil { + return billingcustomer.Lineage{}, fmt.Errorf("read purchase lineage by key: %w", err) + } + return lineage, nil +} + +func (r *Repository) Lineage(ctx context.Context, projectID, lineageID string) (billingcustomer.Lineage, error) { + lineage, err := scanLineage(r.pool.QueryRow(ctx, + `SELECT `+lineageColumns+` FROM purchase_lineages WHERE id=$1 AND project_id=$2`, + lineageID, projectID)) + if errors.Is(err, pgx.ErrNoRows) { + return billingcustomer.Lineage{}, billingcustomer.ErrNotFound + } + if err != nil { + return billingcustomer.Lineage{}, fmt.Errorf("read purchase lineage: %w", err) + } + return lineage, nil +} + +// AttachLineageCustomer records the resolver's accepted association. +// +// A frozen lineage is excluded from the predicate. Freezing exists precisely to +// stop an association being applied while an operator owns the dispute, and a +// write that ignored it would hand the purchase to whichever candidate the next +// projection read first — which is the failure OD-10 is about. +func (r *Repository) AttachLineageCustomer(ctx context.Context, projectID, lineageID, customerID string, now time.Time) error { + tag, err := r.pool.Exec(ctx, + `UPDATE purchase_lineages + SET billing_customer_id=$3, diagnostic_status='none', updated_at=$4 + WHERE id=$1 AND project_id=$2 AND projection_frozen = false`, + lineageID, projectID, customerID, now) + if err != nil { + if isForeignKeyViolation(err) { + return billingcustomer.ErrNotFound + } + return fmt.Errorf("attach lineage customer: %w", err) + } + if tag.RowsAffected() == 0 { + // Either the lineage does not exist in this Project, or it is frozen. + // Both are reported as a frozen-or-absent refusal rather than as a + // success, so a caller never believes an association it did not get. + return r.lineageRefusal(ctx, projectID, lineageID) + } + return nil +} + +// SetLineageSupersededBy records an explicit supersession edge. Nothing is +// deleted: the superseded lineage stops granting access and stays fully visible +// in history. +func (r *Repository) SetLineageSupersededBy(ctx context.Context, projectID, lineageID, supersededBy string, now time.Time) error { + if lineageID == supersededBy { + return billingcustomer.ErrConflict + } + tag, err := r.pool.Exec(ctx, + `UPDATE purchase_lineages SET superseded_by_lineage_id=NULLIF($3,''), updated_at=$4 + WHERE id=$1 AND project_id=$2`, lineageID, projectID, supersededBy, now) + if err != nil { + if isForeignKeyViolation(err) { + // The successor is not a lineage of this Project. Migration 00043 + // makes that composite reference the schema's job. + return billingcustomer.ErrNotFound + } + return fmt.Errorf("set lineage supersession: %w", err) + } + if tag.RowsAffected() == 0 { + return billingcustomer.ErrNotFound + } + return nil +} + +// SetLineageFrozen is the projection freeze switch of OD-10. +func (r *Repository) SetLineageFrozen(ctx context.Context, projectID, lineageID string, frozen bool, diagnostic string, now time.Time) error { + if diagnostic == "" { + diagnostic = "none" + } + tag, err := r.pool.Exec(ctx, + `UPDATE purchase_lineages SET projection_frozen=$3, diagnostic_status=$4, updated_at=$5 + WHERE id=$1 AND project_id=$2`, lineageID, projectID, frozen, diagnostic, now) + if err != nil { + return fmt.Errorf("set lineage freeze: %w", err) + } + if tag.RowsAffected() == 0 { + return billingcustomer.ErrNotFound + } + return nil +} + +// lineageRefusal distinguishes "no such lineage here" from "frozen", which are +// the only two reasons a scoped lineage write affects no rows. +func (r *Repository) lineageRefusal(ctx context.Context, projectID, lineageID string) error { + var frozen bool + err := r.pool.QueryRow(ctx, + `SELECT projection_frozen FROM purchase_lineages WHERE id=$1 AND project_id=$2`, + lineageID, projectID).Scan(&frozen) + switch { + case errors.Is(err, pgx.ErrNoRows): + return billingcustomer.ErrNotFound + case err != nil: + return fmt.Errorf("read purchase lineage state: %w", err) + case frozen: + return billingcustomer.ErrFrozen + default: + return billingcustomer.ErrNotFound + } +} diff --git a/apps/api/internal/platform/billingcustomerpostgres/repository.go b/apps/api/internal/platform/billingcustomerpostgres/repository.go new file mode 100644 index 00000000..1768c174 --- /dev/null +++ b/apps/api/internal/platform/billingcustomerpostgres/repository.go @@ -0,0 +1,749 @@ +// Package billingcustomerpostgres is the PostgreSQL implementation of the +// billing-identity persistence port (plan §5, §5a; OD-3, OD-4, OD-7, OD-10). +// +// Three rules run through every statement in this package. +// +// Authorization is expressed in SQL next to the data it protects, exactly as +// billingpostgres does, so no read can reach another tenant by forgetting a +// check in Go. Absent membership is reported as ErrNotFound rather than +// ErrForbidden: answering "this Project exists but is not yours" is an +// existence oracle over the tenant list. +// +// The database is the arbiter of every identity uniqueness rule. A second live +// resolution for one alias digest, and a second open conflict for one disputed +// subject, are rejected by partial unique indexes rather than by a pre-check in +// Go that a concurrent request can lose. +// +// No alias value and no alias digest is ever logged or returned on an operator +// surface. An alias digest is still a stable per-person identifier, so exposing +// it would let one tenant's export be joined against another's. Digests are +// read only where the resolver itself consumes them. +package billingcustomerpostgres + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" +) + +// Repository is the PostgreSQL billing-identity repository. +type Repository struct { + pool *pgxpool.Pool +} + +// New builds the repository over an existing pool. The pool is the composition +// root's, so the repository owns no connection lifecycle of its own. +func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +var _ billingcustomer.Repository = (*Repository)(nil) + +type queryer interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +// trustedActorPrefix is how billingcustomer's trusted-server surface renders a +// secret server key as an actor (see trusted.go's trustedActor). +const trustedActorPrefix = "apikey:" + +// requireRole enforces the tenant boundary for one operator-facing read or +// write, in SQL, and returns the owning organization. +// +// Two principal shapes reach identity, and both are checked against the +// database rather than trusted from the caller: +// +// - A human operator, identified by an actor id, must be a member of the +// Project's organization with the owner or admin role. Billing is not a +// member-level surface: identity conflicts decide who is granted a paid +// entitlement, which is the same authority level the 9A credential and +// ledger surfaces already require. +// - A trusted server principal, rendered by the identity service as +// "apikey:", must present a secret server key that still resolves into +// this very Project and has not been revoked. Without this branch every +// trusted-server surface (alias listing, conflict detail, manual sync) +// would fail closed, because an API key is deliberately not an +// organization member. +// +// Every absent-authorization path returns ErrNotFound. ErrForbidden is reserved +// for a principal that is demonstrably inside the tenant but holds too low a +// role, which tells the caller nothing it did not already know. +func requireRole(ctx context.Context, q queryer, actor billingcustomer.Actor, projectID string, roles ...string) (string, error) { + actorID := strings.TrimSpace(actor.ID) + if actorID == "" || strings.TrimSpace(projectID) == "" { + return "", billingcustomer.ErrUnauthenticated + } + + if keyID, found := strings.CutPrefix(actorID, trustedActorPrefix); found { + var organizationID string + err := q.QueryRow(ctx, + `SELECT p.organization_id + FROM api_keys k + JOIN environments e ON e.id = k.environment_id + JOIN projects p ON p.id = e.project_id + WHERE k.id = $1 AND p.id = $2 AND k.kind = 'secret_server' AND k.revoked_at IS NULL`, + keyID, projectID).Scan(&organizationID) + if errors.Is(err, pgx.ErrNoRows) { + return "", billingcustomer.ErrNotFound + } + if err != nil { + return "", fmt.Errorf("resolve billing identity key scope: %w", err) + } + return organizationID, nil + } + + var role, organizationID string + err := q.QueryRow(ctx, + `SELECT m.role, p.organization_id 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, &organizationID) + if errors.Is(err, pgx.ErrNoRows) { + return "", billingcustomer.ErrNotFound + } + if err != nil { + return "", fmt.Errorf("resolve billing identity role: %w", err) + } + for _, allowed := range roles { + if role == allowed { + return organizationID, nil + } + } + return "", billingcustomer.ErrForbidden +} + +// operatorRoles is the single place the identity role set is stated. +var operatorRoles = []string{"owner", "admin"} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +// BillingEnabled reports the Project's opt-in. An absent row means never +// configured, which is off: Mosaic Billing is opt-in and the identity service +// fails closed on top of this. +func (r *Repository) BillingEnabled(ctx context.Context, projectID string) (bool, error) { + var enabled bool + err := r.pool.QueryRow(ctx, + `SELECT billing_enabled FROM billing_project_settings WHERE project_id = $1`, projectID).Scan(&enabled) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read billing settings: %w", err) + } + return enabled, nil +} + +// --------------------------------------------------------------------------- +// Customers +// --------------------------------------------------------------------------- + +const customerColumns = `id, project_id, status, current_projection_version, last_projected_at, + diagnostics_status, created_at, updated_at` + +func scanCustomer(row pgx.Row) (billingcustomer.Customer, error) { + var customer billingcustomer.Customer + err := row.Scan(&customer.ID, &customer.ProjectID, &customer.Status, + &customer.CurrentProjectionVersion, &customer.LastProjectedAt, &customer.DiagnosticsStatus, + &customer.CreatedAt, &customer.UpdatedAt) + return customer, err +} + +// CreateCustomer inserts a customer. It performs no authorization: it is +// reachable only from the two lazy-creation paths of plan §5a, both of which +// established the tenant before calling — a trusted server key, or a validated +// fact whose Project came from the credential that ingested it. +func (r *Repository) CreateCustomer(ctx context.Context, customer billingcustomer.Customer) (billingcustomer.Customer, error) { + if customer.Status == "" { + customer.Status = billingcustomer.StatusActive + } + if customer.DiagnosticsStatus == "" { + customer.DiagnosticsStatus = "none" + } + created, err := scanCustomer(r.pool.QueryRow(ctx, + `INSERT INTO billing_customers(id, project_id, status, diagnostics_status, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6) + RETURNING `+customerColumns, + customer.ID, customer.ProjectID, customer.Status, customer.DiagnosticsStatus, + customer.CreatedAt, customer.UpdatedAt)) + if err != nil { + if isUniqueViolation(err) { + return billingcustomer.Customer{}, billingcustomer.ErrConflict + } + return billingcustomer.Customer{}, fmt.Errorf("insert billing customer: %w", err) + } + return created, nil +} + +func (r *Repository) Customer(ctx context.Context, actor billingcustomer.Actor, projectID, customerID string) (billingcustomer.Customer, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, operatorRoles...); err != nil { + return billingcustomer.Customer{}, err + } + customer, err := scanCustomer(r.pool.QueryRow(ctx, + `SELECT `+customerColumns+` FROM billing_customers WHERE id=$1 AND project_id=$2`, + customerID, projectID)) + if errors.Is(err, pgx.ErrNoRows) { + return billingcustomer.Customer{}, billingcustomer.ErrNotFound + } + if err != nil { + return billingcustomer.Customer{}, fmt.Errorf("read billing customer: %w", err) + } + return customer, nil +} + +// CustomerForAlias resolves the customer an alias currently points at. +// +// It takes no actor because it is an internal resolution step, not an operator +// read: the caller supplies a digest it computed from a value it was already +// authorized to assert, and the Project is part of the predicate so a digest +// from one tenant cannot select another's customer. +func (r *Repository) CustomerForAlias(ctx context.Context, projectID, aliasType string, digest []byte) (billingcustomer.Customer, error) { + customer, err := scanCustomer(r.pool.QueryRow(ctx, + `SELECT `+prefixed("c", customerColumns)+` + FROM billing_customer_aliases a + JOIN billing_customers c ON c.id = a.billing_customer_id AND c.project_id = a.project_id + WHERE a.project_id=$1 AND a.alias_type=$2 AND a.alias_digest=$3 AND a.effective_end IS NULL`, + projectID, aliasType, digest)) + if errors.Is(err, pgx.ErrNoRows) { + return billingcustomer.Customer{}, billingcustomer.ErrNotFound + } + if err != nil { + return billingcustomer.Customer{}, fmt.Errorf("resolve customer for alias: %w", err) + } + return customer, nil +} + +// ListCustomers pages a Project's customers newest first. +// +// The ordering is (created_at DESC, id ASC), which is exactly +// `billing_customers_project_idx`, so paging is an index scan rather than a +// sort. The keyset predicate is written out rather than as a row comparison +// because the two halves sort in opposite directions and `(a,b) < (x,y)` cannot +// express that. +func (r *Repository) ListCustomers(ctx context.Context, actor billingcustomer.Actor, projectID string, limit int, cursor string) ([]billingcustomer.Customer, string, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, operatorRoles...); err != nil { + return nil, "", err + } + limit = pageLimit(limit) + position := decodeCursor(cursor) + rows, err := r.pool.Query(ctx, + `SELECT `+customerColumns+` FROM billing_customers + WHERE project_id=$1 + AND ($2::timestamptz IS NULL + OR created_at < $2::timestamptz + OR (created_at = $2::timestamptz AND id > $3)) + ORDER BY created_at DESC, id + LIMIT $4`, projectID, position.At, position.ID, limit+1) + if err != nil { + return nil, "", fmt.Errorf("list billing customers: %w", err) + } + defer rows.Close() + customers := make([]billingcustomer.Customer, 0, limit) + for rows.Next() { + customer, err := scanCustomer(rows) + if err != nil { + return nil, "", fmt.Errorf("scan billing customer: %w", err) + } + customers = append(customers, customer) + } + if err := rows.Err(); err != nil { + return nil, "", fmt.Errorf("read billing customers: %w", err) + } + next := "" + if len(customers) > limit { + customers = customers[:limit] + last := customers[limit-1] + next = encodeCursor(last.CreatedAt, last.ID) + } + return customers, next, nil +} + +// SetCustomerStatus moves a customer between active and frozen (and, for the +// erasure path, anonymized). +// +// The anonymized timestamp is written in the same statement as the status +// because migration 00030 declares them equivalent by CHECK: setting one +// without the other is a constraint violation, and setting them in two +// statements would leave a window where the row is unwritable. +func (r *Repository) SetCustomerStatus(ctx context.Context, projectID, customerID, status string, now time.Time) error { + tag, err := r.pool.Exec(ctx, + `UPDATE billing_customers + SET status=$3, + anonymized_at = CASE WHEN $3 = 'anonymized' THEN COALESCE(anonymized_at, $4) ELSE NULL END, + updated_at=$4 + WHERE id=$1 AND project_id=$2`, customerID, projectID, status, now) + if err != nil { + return fmt.Errorf("set billing customer status: %w", err) + } + if tag.RowsAffected() == 0 { + return billingcustomer.ErrNotFound + } + return nil +} + +// PurchaseAnchoredOnly reports whether a customer exists solely to hold a +// purchase (plan §5a rules 1 and 2) and has never been identified by anything. +// +// The excluded evidence types are the ones a purchase-anchored customer +// acquires *because* it was anchored rather than because anyone identified it: +// the anchor row itself, the prior association every subsequent renewal +// re-derives from it, and installation observations, which are attribution-only +// and can never select a customer at all. Anything else that resolved — a +// submission, a correlator, a restore link, an operator repair, an earlier +// adoption — means a person is behind this customer, and taking its purchase +// away becomes an operator decision rather than a resolver one. +// +// Aliases are checked without a type filter. Any alias at all is somebody +// having said who this is. +func (r *Repository) PurchaseAnchoredOnly(ctx context.Context, projectID, customerID string) (bool, error) { + if customerID == "" { + return false, nil + } + var anchored bool + err := r.pool.QueryRow(ctx, + `SELECT + EXISTS (SELECT 1 FROM billing_association_evidence + WHERE project_id=$1 AND billing_customer_id=$2 + AND evidence_type='purchase_anchor') + AND NOT EXISTS (SELECT 1 FROM billing_association_evidence + WHERE project_id=$1 AND billing_customer_id=$2 + AND outcome='resolved' + AND evidence_type NOT IN ( + 'purchase_anchor', 'prior_lineage_association', + 'installation_observation')) + AND NOT EXISTS (SELECT 1 FROM billing_customer_aliases + WHERE project_id=$1 AND billing_customer_id=$2 + AND effective_end IS NULL)`, + projectID, customerID).Scan(&anchored) + if err != nil { + return false, fmt.Errorf("read purchase-anchored customer state: %w", err) + } + return anchored, nil +} + +// LineageCountForCustomer counts the purchase lineages a customer still holds, +// across every Environment. +func (r *Repository) LineageCountForCustomer(ctx context.Context, projectID, customerID string) (int, error) { + var count int + if err := r.pool.QueryRow(ctx, + `SELECT count(*) FROM purchase_lineages WHERE project_id=$1 AND billing_customer_id=$2`, + projectID, customerID).Scan(&count); err != nil { + return 0, fmt.Errorf("count purchase lineages for customer: %w", err) + } + return count, nil +} + +// --------------------------------------------------------------------------- +// Aliases +// --------------------------------------------------------------------------- + +// aliasColumns deliberately omits alias_digest. Nothing an operator or a +// trusted backend reads back needs it, and a column that is never selected +// cannot leak into a response or a log line. +const aliasColumns = `id, project_id, billing_customer_id, alias_type, source_authority, + verification_status, effective_start, effective_end, created_at` + +func scanAlias(row pgx.Row) (billingcustomer.Alias, error) { + var alias billingcustomer.Alias + err := row.Scan(&alias.ID, &alias.ProjectID, &alias.BillingCustomerID, &alias.AliasType, + &alias.SourceAuthority, &alias.VerificationStatus, &alias.EffectiveStart, + &alias.EffectiveEnd, &alias.CreatedAt) + return alias, err +} + +// AttachAlias records one alias. +// +// `billing_customer_aliases_active_resolution_idx` is a partial unique index +// over (project, alias type, digest) where the row is still live, so a second +// live resolution loses at the database. There is deliberately no "does this +// digest already resolve?" read before the insert: a check-then-act pair loses +// to a concurrent login, and losing means one person's purchases split across +// two customers. +func (r *Repository) AttachAlias(ctx context.Context, alias billingcustomer.Alias) (billingcustomer.Alias, error) { + digest := alias.Digest() + if len(digest) != sha256.Size { + return billingcustomer.Alias{}, billingcustomer.ErrInvalidAlias + } + if alias.VerificationStatus == "" { + alias.VerificationStatus = "asserted" + } + stored, err := scanAlias(r.pool.QueryRow(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,$6,$7,$8,$9) + RETURNING `+aliasColumns, + alias.ID, alias.ProjectID, alias.BillingCustomerID, alias.AliasType, digest, + alias.SourceAuthority, alias.VerificationStatus, alias.EffectiveStart, alias.CreatedAt)) + if err != nil { + if isUniqueViolation(err) { + return billingcustomer.Alias{}, billingcustomer.ErrConflict + } + if isForeignKeyViolation(err) { + // The named customer does not exist inside this Project. Reported as + // not-found rather than as a database error so the caller cannot use + // the difference to probe another tenant's customer identifiers. + return billingcustomer.Alias{}, billingcustomer.ErrNotFound + } + return billingcustomer.Alias{}, fmt.Errorf("attach billing customer alias: %w", err) + } + // The digest is carried back on the domain value, not on the JSON surface; + // it is the caller's own input, so nothing new is disclosed. + return stored.WithDigest(digest), nil +} + +// RevokeAlias end-dates the live resolution. Nothing is deleted: the row stays +// so the history of who was linked when survives a sign-out. +func (r *Repository) RevokeAlias(ctx context.Context, actor billingcustomer.Actor, projectID, aliasID string, now time.Time) error { + if _, err := requireRole(ctx, r.pool, actor, projectID, operatorRoles...); err != nil { + return err + } + tag, err := r.pool.Exec(ctx, + `UPDATE billing_customer_aliases + SET effective_end = GREATEST($3, effective_start), revoked_by_actor_id = NULLIF($4,'') + WHERE id=$1 AND project_id=$2 AND effective_end IS NULL`, + aliasID, projectID, now, actor.ID) + if err != nil { + return fmt.Errorf("revoke billing customer alias: %w", err) + } + if tag.RowsAffected() == 0 { + return billingcustomer.ErrNotFound + } + return nil +} + +func (r *Repository) ListAliases(ctx context.Context, actor billingcustomer.Actor, projectID, customerID string) ([]billingcustomer.Alias, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, operatorRoles...); err != nil { + return nil, err + } + rows, err := r.pool.Query(ctx, + `SELECT `+aliasColumns+` FROM billing_customer_aliases + WHERE project_id=$1 AND billing_customer_id=$2 + ORDER BY effective_start DESC, id`, projectID, customerID) + if err != nil { + return nil, fmt.Errorf("list billing customer aliases: %w", err) + } + defer rows.Close() + aliases := make([]billingcustomer.Alias, 0, 4) + for rows.Next() { + alias, err := scanAlias(rows) + if err != nil { + return nil, fmt.Errorf("scan billing customer alias: %w", err) + } + aliases = append(aliases, alias) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read billing customer aliases: %w", err) + } + return aliases, nil +} + +// ActiveAliasResolutions is the lookup the pure resolver consumes. +// +// The map is keyed by `string(digest)` — the raw bytes, not a hex rendering — +// because that is exactly how `billingcustomer.Resolve` indexes it +// (`activeAliases[string(observation.Digest)]`). A hex or base64 key would +// silently resolve nothing and every purchase would look unidentified. +// +// The alias type is not part of the key: `AliasDigest` folds the type into the +// hash under its own domain separation, so two alias families cannot collide on +// one digest. +func (r *Repository) ActiveAliasResolutions(ctx context.Context, projectID string, digests [][]byte) (map[string]string, error) { + resolutions := make(map[string]string, len(digests)) + if len(digests) == 0 { + return resolutions, nil + } + rows, err := r.pool.Query(ctx, + `SELECT alias_digest, billing_customer_id FROM billing_customer_aliases + WHERE project_id=$1 AND effective_end IS NULL AND alias_digest = ANY($2::bytea[])`, + projectID, digests) + if err != nil { + return nil, fmt.Errorf("read active alias resolutions: %w", err) + } + defer rows.Close() + for rows.Next() { + var digest []byte + var customerID string + if err := rows.Scan(&digest, &customerID); err != nil { + return nil, fmt.Errorf("scan active alias resolution: %w", err) + } + resolutions[string(digest)] = customerID + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read active alias resolutions: %w", err) + } + return resolutions, nil +} + +// --------------------------------------------------------------------------- +// Association evidence +// --------------------------------------------------------------------------- + +const evidenceColumns = `id, project_id, COALESCE(environment_id,''), COALESCE(purchase_lineage_id,''), + evidence_type, evidence_digest, COALESCE(raw_input_id,''), transaction_reference_digest, + COALESCE(billing_customer_id,''), resolver_version, outcome, COALESCE(diagnostic_code,''), + observed_at, created_at` + +func scanEvidence(row pgx.Row) (billingcustomer.Evidence, error) { + var evidence billingcustomer.Evidence + err := row.Scan(&evidence.ID, &evidence.ProjectID, &evidence.EnvironmentID, + &evidence.PurchaseLineageID, &evidence.EvidenceType, &evidence.EvidenceDigest, + &evidence.RawInputID, &evidence.TransactionReferenceDigest, &evidence.BillingCustomerID, + &evidence.ResolverVersion, &evidence.Outcome, &evidence.DiagnosticCode, + &evidence.ObservedAt, &evidence.CreatedAt) + return evidence, err +} + +// RecordEvidence appends one observation. The table carries an append-only +// trigger, so this is the only way a row ever changes. +func (r *Repository) RecordEvidence(ctx context.Context, evidence billingcustomer.Evidence) error { + if evidence.ResolverVersion <= 0 { + evidence.ResolverVersion = billingcustomer.ResolverVersion + } + _, err := r.pool.Exec(ctx, + `INSERT INTO billing_association_evidence( + id, project_id, environment_id, purchase_lineage_id, evidence_type, evidence_digest, + raw_input_id, transaction_reference_digest, billing_customer_id, resolver_version, + outcome, diagnostic_code, observed_at, created_at) + VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,$6,NULLIF($7,''),$8,NULLIF($9,''),$10, + $11,NULLIF($12,''),$13,$14) + ON CONFLICT (id) DO NOTHING`, + evidence.ID, evidence.ProjectID, evidence.EnvironmentID, evidence.PurchaseLineageID, + evidence.EvidenceType, nullBytes(evidence.EvidenceDigest), evidence.RawInputID, + nullBytes(evidence.TransactionReferenceDigest), evidence.BillingCustomerID, + evidence.ResolverVersion, evidence.Outcome, evidence.DiagnosticCode, + evidence.ObservedAt, evidence.CreatedAt) + if err != nil { + return fmt.Errorf("record association evidence: %w", err) + } + return nil +} + +func (r *Repository) PriorLineageCustomers(ctx context.Context, projectID, lineageID string) ([]string, error) { + rows, err := r.pool.Query(ctx, + `SELECT DISTINCT billing_customer_id + FROM billing_association_evidence + WHERE project_id=$1 AND purchase_lineage_id=$2 + AND evidence_type='prior_lineage_association' + AND billing_customer_id IS NOT NULL + ORDER BY billing_customer_id`, projectID, lineageID) + if err != nil { + return nil, fmt.Errorf("read prior lineage customers: %w", err) + } + defer rows.Close() + customers := make([]string, 0, 2) + for rows.Next() { + var customerID string + if err := rows.Scan(&customerID); err != nil { + return nil, fmt.Errorf("scan prior lineage customer: %w", err) + } + customers = append(customers, customerID) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read prior lineage customers: %w", err) + } + return customers, nil +} + +func (r *Repository) AdoptionRecorded(ctx context.Context, projectID, lineageID, adopterID string) (bool, error) { + var recorded bool + err := r.pool.QueryRow(ctx, + `SELECT EXISTS( + SELECT 1 FROM billing_association_evidence + WHERE project_id=$1 AND purchase_lineage_id=$2 + AND evidence_type='anchored_customer_adoption' + AND billing_customer_id=$3 + )`, projectID, lineageID, adopterID).Scan(&recorded) + if err != nil { + return false, fmt.Errorf("read anchored customer adoption: %w", err) + } + return recorded, nil +} + +// EvidenceForReference reads every observation recorded against one transaction +// reference digest, oldest first. +// +// Fact provenance is first-writer-wins and therefore not authoritative about +// which input asserted an association, which is why authority is reconstructed +// by scanning inputs here rather than by trusting a fact's own source input. +func (r *Repository) EvidenceForReference(ctx context.Context, projectID string, referenceDigest []byte) ([]billingcustomer.Evidence, error) { + if len(referenceDigest) == 0 { + return nil, nil + } + rows, err := r.pool.Query(ctx, + `SELECT `+evidenceColumns+` FROM billing_association_evidence + WHERE project_id=$1 AND transaction_reference_digest=$2 + ORDER BY observed_at, id`, projectID, referenceDigest) + if err != nil { + return nil, fmt.Errorf("read association evidence for reference: %w", err) + } + defer rows.Close() + entries := make([]billingcustomer.Evidence, 0, 4) + for rows.Next() { + evidence, err := scanEvidence(rows) + if err != nil { + return nil, fmt.Errorf("scan association evidence: %w", err) + } + entries = append(entries, evidence) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read association evidence: %w", err) + } + return entries, nil +} + +// --------------------------------------------------------------------------- +// Audit +// --------------------------------------------------------------------------- + +// RecordAudit writes one identity action into the shared audit log, resolving +// the organization from the Project exactly as the projection repository's +// writeAudit does. +// +// The actor falls back to "system" because several identity paths are triggered +// by a validated fact rather than by a person, and `audit_events.actor_id` is +// NOT NULL. Metadata carries identifiers and enumerations only — never an alias +// value, a digest, or a correlator. +func (r *Repository) RecordAudit(ctx context.Context, actor billingcustomer.Actor, projectID, action, resourceType, resourceID string, metadata map[string]string, now time.Time) error { + encoded := []byte("{}") + if len(metadata) > 0 { + if raw, err := json.Marshal(metadata); err == nil { + encoded = raw + } + } + var organizationID string + if err := r.pool.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id=$1`, projectID). + Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return billingcustomer.ErrNotFound + } + return fmt.Errorf("read organization for billing identity audit: %w", err) + } + _, err := r.pool.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,COALESCE(NULLIF($2,''),'system'),$3,$4,NULL,$5,$6,$7,$8,$9) + ON CONFLICT (id) DO NOTHING`, + "aud_"+hashID(resourceID, action, now.UnixNano()), actor.ID, organizationID, projectID, + action, resourceType, resourceID, encoded, now) + if err != nil { + return fmt.Errorf("write billing identity audit event: %w", err) + } + return nil +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +const ( + defaultPageLimit = 50 + maxPageLimit = 100 +) + +func pageLimit(limit int) int { + if limit <= 0 { + return defaultPageLimit + } + if limit > maxPageLimit { + return maxPageLimit + } + return limit +} + +// listCursor is a keyset position: the ordering timestamp of the last row +// returned plus its id as the tie-break. Both halves are required — billing +// identifiers are sixteen random bytes and carry no time order, so a cursor +// holding only an id cannot express "after this row in timestamp order". +type listCursor struct { + At *time.Time + ID string +} + +// encodeCursor renders a keyset position as one opaque token: base64url over +// ":", because it travels in a query string. Callers forward it +// unchanged and must never parse it. +// +// The resolution is microseconds, not the milliseconds billingpostgres uses. +// PostgreSQL stores timestamptz at microsecond precision, so a millisecond +// cursor is rounded *down* from the row it describes — and the tie-break half +// of the predicate then never matches, because the next row's `created_at` +// compares greater than the cursor it was derived from. The observable effect +// is a second page that silently omits every customer created in the same +// millisecond as the last one on page one, which on a lazily-created identity +// table is precisely the rows a bulk import produces. +func encodeCursor(at time.Time, id string) string { + return base64.RawURLEncoding.EncodeToString( + []byte(strconv.FormatInt(at.UTC().UnixMicro(), 10) + ":" + id)) +} + +// decodeCursor parses an opaque cursor. A malformed value yields the zero +// cursor, which starts from the beginning: a caller that mangles a cursor gets +// the first page rather than a silently truncated list. +func decodeCursor(raw string) listCursor { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return listCursor{} + } + decoded, err := base64.RawURLEncoding.DecodeString(trimmed) + if err != nil { + return listCursor{} + } + millis, id, found := strings.Cut(string(decoded), ":") + if !found || id == "" { + return listCursor{} + } + value, err := strconv.ParseInt(millis, 10, 64) + if err != nil { + return listCursor{} + } + at := time.UnixMicro(value).UTC() + return listCursor{At: &at, ID: id} +} + +// prefixed qualifies a bare column list with a table alias, so one column +// constant can be reused in a join without being restated. +func prefixed(alias, columns string) string { + parts := strings.Split(columns, ",") + for index, part := range parts { + parts[index] = alias + "." + strings.TrimSpace(part) + } + return strings.Join(parts, ", ") +} + +func nullBytes(value []byte) any { + if len(value) == 0 { + return nil + } + return value +} + +// hashID derives a deterministic identifier so a retried write produces the +// same row id and the ON CONFLICT clauses stay meaningful. +func hashID(parts ...any) string { + hasher := sha256.New() + for _, part := range parts { + // hash.Hash never reports a write error. + _, _ = fmt.Fprintf(hasher, "%v\x00", part) + } + return fmt.Sprintf("%x", hasher.Sum(nil))[:24] +} + +func isUniqueViolation(err error) bool { return hasSQLState(err, "23505") } + +func isForeignKeyViolation(err error) bool { return hasSQLState(err, "23503") } + +func hasSQLState(err error, code string) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == code +} diff --git a/apps/api/internal/platform/billingdiagnosticspostgres/metrics.go b/apps/api/internal/platform/billingdiagnosticspostgres/metrics.go new file mode 100644 index 00000000..d91b251e --- /dev/null +++ b/apps/api/internal/platform/billingdiagnosticspostgres/metrics.go @@ -0,0 +1,103 @@ +package billingdiagnosticspostgres + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const metricTimeout = 5 * time.Second + +// phase9BTables are the tables whose row counts are published from day one. +// +// Plan §15 decided snapshot retention without a drill baseline to extrapolate +// from, because Phase 8 drills 4 and 5 were never run and nothing is +// partitioned. The mitigation recorded there is this: publish per-table row +// counts from the first deployment so the first post-9B drill has a trend +// rather than a single reading. Adding the metric after growth becomes a +// problem is exactly the mistake provider_sync_jobs already made once. +// +// The list is explicit rather than derived from the catalog so a table added +// later is a deliberate decision to observe it, and so this gauge can never +// start reporting a table that holds something it should not. +var phase9BTables = []string{ + "billing_customers", + "billing_customer_aliases", + "billing_association_evidence", + "billing_identity_conflicts", + "purchase_lineages", + "subscription_instances", + "one_time_purchase_instances", + "subscription_snapshots", + "subscription_snapshot_facts", + "subscription_timeline_entries", + "projection_checkpoints", + "projection_rule_versions", + "projection_jobs", + "projection_attempts", + "product_entitlement_grant_versions", + "entitlement_sources", + "customer_entitlement_snapshots", + "customer_entitlement_snapshot_entries", + "customer_entitlement_pointers", + "customer_access_tokens", + "webhook_destinations", + "webhook_signing_secrets", + "webhook_events", + "webhook_deliveries", + "webhook_delivery_attempts", + "restore_sync_jobs", + "restore_sync_job_inputs", + "purchase_chain_digest_links", +} + +// RegisterRowCountMetrics publishes an approximate row count per Phase 9B table. +// +// The counts come from the planner statistics (`pg_class.reltuples`) rather +// than from `count(*)`. An exact count of every table on every scrape is a +// sequential scan of the whole 9B schema on a fixed interval, which is a +// self-inflicted load problem on the largest tables — and the question this +// metric answers ("is this table growing in a way retention has to catch up +// with?") is a trend question that an estimate answers just as well. +func (r *Repository) RegisterRowCountMetrics() error { + meter := otel.Meter("mosaic/billingdiagnostics") + rows, err := meter.Int64ObservableGauge("mosaic.billing.table.rows", + metric.WithDescription("Approximate row count of a Mosaic Billing table, from planner statistics.")) + if err != nil { + return fmt.Errorf("register billing table row gauge: %w", err) + } + + _, err = meter.RegisterCallback(func(ctx context.Context, observer metric.Observer) error { + ctx, cancel := context.WithTimeout(ctx, metricTimeout) + defer cancel() + result, err := r.pool.Query(ctx, + `SELECT relname, GREATEST(reltuples, 0)::bigint + FROM pg_class + WHERE relkind = 'r' AND relname = ANY($1)`, phase9BTables) + if err != nil { + // A metric scrape must never surface as an error the collector + // retries in a tight loop; the next scrape will try again. + return nil + } + defer result.Close() + for result.Next() { + var table string + var count int64 + if err := result.Scan(&table, &count); err != nil { + continue + } + observer.ObserveInt64(rows, count, metric.WithAttributes( + attribute.String("phase", "9b"), + attribute.String("table", table))) + } + return nil + }, rows) + if err != nil { + return fmt.Errorf("register billing table row metric callback: %w", err) + } + return nil +} diff --git a/apps/api/internal/platform/billingdiagnosticspostgres/repository.go b/apps/api/internal/platform/billingdiagnosticspostgres/repository.go new file mode 100644 index 00000000..d399e668 --- /dev/null +++ b/apps/api/internal/platform/billingdiagnosticspostgres/repository.go @@ -0,0 +1,244 @@ +// Package billingdiagnosticspostgres is the PostgreSQL implementation of the +// Phase 9B projection health port. +// +// Every number this package produces is a count or a timestamp read straight +// from the tables that hold the state. Nothing is inferred from a queue gauge: +// a gauge reports what was enqueued, and the failure this surface exists to +// make visible is precisely the one where an enqueue never happened. +package billingdiagnosticspostgres + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingdiagnostics" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +var _ billingdiagnostics.Repository = (*Repository)(nil) + +func (r *Repository) BillingEnabled(ctx context.Context, projectID string) (bool, error) { + var enabled bool + err := r.pool.QueryRow(ctx, + `SELECT billing_enabled FROM billing_project_settings WHERE project_id = $1`, projectID). + Scan(&enabled) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read billing enablement: %w", err) + } + return enabled, nil +} + +// requireRole resolves the actor's organization role for the Project. +// +// Absent membership is reported as not-found rather than forbidden, matching +// every other Mosaic surface: telling a caller that a Project exists but is not +// theirs is an existence oracle over other tenants' Projects. +func (r *Repository) requireRole(ctx context.Context, actor billingdiagnostics.Actor, projectID string) error { + if strings.TrimSpace(actor.ID) == "" { + return billingdiagnostics.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 billingdiagnostics.ErrNotFound + } + if err != nil { + return fmt.Errorf("resolve projection health role: %w", err) + } + switch role { + case "owner", "admin": + return nil + default: + return billingdiagnostics.ErrForbidden + } +} + +// ProjectionHealth reads the whole summary. +// +// It is deliberately one round trip. The counts are read together so they +// describe one instant: an operator comparing "backlog" against "stale +// customers" across two statements would be comparing two different moments, +// and during an incident those are exactly the two numbers being compared. +func (r *Repository) ProjectionHealth(ctx context.Context, actor billingdiagnostics.Actor, + projectID, environmentID string) (billingdiagnostics.ProjectionHealth, error) { + + if err := r.requireRole(ctx, actor, projectID); err != nil { + return billingdiagnostics.ProjectionHealth{}, 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 billingdiagnostics.ProjectionHealth{}, billingdiagnostics.ErrNotFound + } + if err != nil { + return billingdiagnostics.ProjectionHealth{}, fmt.Errorf("resolve projection health environment: %w", err) + } + + health := billingdiagnostics.ProjectionHealth{ + EnvironmentID: environmentID, + ObservedAt: time.Now().UTC(), + } + enabled, err := r.BillingEnabled(ctx, projectID) + if err != nil { + return billingdiagnostics.ProjectionHealth{}, err + } + health.BillingEnabled = enabled + + staleBefore := health.ObservedAt.Add(-billingdiagnostics.StaleAfter) + failuresSince := health.ObservedAt.Add(-time.Hour) + + err = r.pool.QueryRow(ctx, + `SELECT + (SELECT COALESCE(max(version),0) FROM projection_rule_versions WHERE status='active'), + (SELECT count(*) FROM projection_rule_versions), + + (SELECT count(*) FROM projection_jobs + WHERE environment_id=$1 AND status IN ('queued','leased')), + (SELECT COALESCE(max(extract(epoch from (now()-created_at))),0) FROM projection_jobs + WHERE environment_id=$1 AND status IN ('queued','leased')), + (SELECT count(*) FROM projection_jobs WHERE environment_id=$1 AND status='failed'), + (SELECT count(*) FROM projection_attempts a + JOIN projection_jobs j ON j.id = a.projection_job_id + WHERE j.environment_id=$1 AND a.outcome='failed' AND a.completed_at >= $3), + + -- A customer is Project-scoped but its committed state is per + -- Environment, so staleness is asked of the pointer, not the customer. + (SELECT count(*) FROM customer_entitlement_pointers + WHERE environment_id=$1 AND updated_at < $2), + (SELECT count(*) FROM billing_customers c + WHERE c.project_id=$4 AND c.last_projected_at IS NULL), + + (SELECT count(*) FROM billing_identity_conflicts + WHERE project_id=$4 AND status='open'), + (SELECT count(*) FROM purchase_lineages + WHERE environment_id=$1 AND projection_frozen), + (SELECT count(*) FROM purchase_lineages + WHERE environment_id=$1 + AND (billing_customer_id IS NULL + OR diagnostic_status IN ('identity_unresolved','product_unresolved'))), + + -- Unknown entries on the *current* snapshot only: historical + -- snapshots record what was true then and are not an open problem. + (SELECT count(*) FROM customer_entitlement_snapshot_entries e + JOIN customer_entitlement_pointers p + ON p.current_snapshot_id = e.customer_entitlement_snapshot_id + WHERE p.environment_id=$1 AND e.state='unknown'), + + (SELECT count(*) FROM restore_sync_jobs + WHERE environment_id=$1 AND status IN ('queued','leased')), + (SELECT count(*) FROM restore_sync_jobs + WHERE environment_id=$1 AND status='failed'), + (SELECT count(*) FROM webhook_deliveries + WHERE environment_id=$1 AND status='pending'), + (SELECT count(*) FROM webhook_deliveries + WHERE environment_id=$1 AND status='exhausted'), + (SELECT count(*) FROM webhook_destinations + WHERE environment_id=$1 AND status='active'), + + (SELECT max(created_at) FROM customer_entitlement_snapshots WHERE environment_id=$1)`, + environmentID, staleBefore, failuresSince, projectID). + Scan(&health.ActiveRuleVersion, &health.RuleVersionCount, + &health.ProjectionQueueDepth, &health.ProjectionOldestAgeSecs, + &health.ProjectionFailedJobs, &health.ProjectionFailuresLastHour, + &health.StaleCustomers, &health.NeverProjectedCustomers, + &health.OpenIdentityConflicts, &health.FrozenLineages, &health.UnresolvedLineages, + &health.UnknownEntitlementEntries, + &health.RestoreBacklog, &health.RestoreFailedJobs, + &health.WebhookBacklog, &health.WebhookExhausted, &health.WebhookDestinations, + &health.LastProjectionCommittedAt) + if err != nil { + return billingdiagnostics.ProjectionHealth{}, fmt.Errorf("read projection health: %w", err) + } + if health.LastProjectionCommittedAt != nil { + utc := health.LastProjectionCommittedAt.UTC() + health.LastProjectionCommittedAt = &utc + } + return health, nil +} + +// AuthorizeReplay guards the one state change this package can trigger. +// +// It is deliberately a separate method from the health authorization rather +// than a shared helper with a boolean: a reader and a writer of the same +// resource should not share one permission check, because widening the read +// later would silently widen the write. +func (r *Repository) AuthorizeReplay(ctx context.Context, actor billingdiagnostics.Actor, + projectID, environmentID string) error { + + if err := r.requireRole(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 billingdiagnostics.ErrNotFound + } + if err != nil { + return fmt.Errorf("resolve replay environment: %w", err) + } + return nil +} + +// RecordReplayAudit records that a replay ran and what it moved. +// +// The count of changed scopes is on the audit entry rather than only in the +// response, because the response is seen once by the operator who asked and the +// audit trail is what an investigation reads months later — and "a replay ran +// and changed nothing" and "a replay ran and rewrote four hundred customers" +// are the two answers such an investigation is actually asking about. +func (r *Repository) RecordReplayAudit(ctx context.Context, actor billingdiagnostics.Actor, + projectID, environmentID string, ruleVersion, scopes, changed int, now time.Time) error { + + var organizationID string + if err := r.pool.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id=$1`, projectID). + Scan(&organizationID); err != nil { + return fmt.Errorf("read organization for replay audit: %w", err) + } + metadata, err := json.Marshal(map[string]any{ + "projectionRuleVersion": ruleVersion, + "scopesReplayed": scopes, + "scopesChanged": changed, + }) + if err != nil { + return fmt.Errorf("encode replay audit metadata: %w", err) + } + _, err = r.pool.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.projection.replayed','billing_projection',$5,$6,$7)`, + "aud_"+auditID(projectID, environmentID, now), actor.ID, organizationID, projectID, + environmentID, metadata, now) + if err != nil { + return fmt.Errorf("insert replay audit event: %w", err) + } + return nil +} + +func auditID(parts ...any) string { + hasher := sha256.New() + for _, part := range parts { + fmt.Fprintf(hasher, "%v\x00", part) + } + return fmt.Sprintf("%x", hasher.Sum(nil))[:24] +} diff --git a/apps/api/internal/platform/billinggrantpostgres/grant_integration_test.go b/apps/api/internal/platform/billinggrantpostgres/grant_integration_test.go new file mode 100644 index 00000000..369779e4 --- /dev/null +++ b/apps/api/internal/platform/billinggrantpostgres/grant_integration_test.go @@ -0,0 +1,324 @@ +package billinggrantpostgres + +import ( + "context" + "database/sql" + "errors" + "os" + "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/billinggrant" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +func testPool(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + 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(), 60*time.Second) + t.Cleanup(cancel) + + db, err := sql.Open("pgx", databaseURL) + 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.Fatalf("apply migrations: %v", err) + } + _ = db.Close() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + return pool, ctx +} + +type catalog struct { + projectID string + productID string + entitlementID string + actorID string +} + +func seedCatalog(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) catalog { + t.Helper() + now := time.Now().UTC() + c := catalog{ + projectID: "proj_grant_" + suffix, + productID: "prod_grant_" + suffix, + entitlementID: "ent_grant_" + suffix, + actorID: "actor_grant_" + suffix, + } + organizationID := "org_grant_" + suffix + cleanupCatalog(ctx, pool, c, organizationID) + + statements := []struct { + query string + args []any + }{ + {`INSERT INTO organizations(id,name,created_at,updated_at) VALUES ($1,'Grant Test',$2,$2) + ON CONFLICT (id) DO NOTHING`, []any{organizationID, now}}, + {`INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) + VALUES ($1,$2,'owner',$3,$3) ON CONFLICT (organization_id,actor_id) DO UPDATE SET role='owner'`, + []any{organizationID, c.actorID, now}}, + {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES ($1,$2,$3,'Grant','active',$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{c.projectID, organizationID, "grant-" + suffix, now}}, + {`INSERT INTO billing_project_settings(project_id,billing_enabled,updated_by_actor_id,created_at,updated_at) + VALUES ($1,true,'seed',$2,$2) ON CONFLICT (project_id) DO UPDATE SET billing_enabled=true`, + []any{c.projectID, now}}, + {`INSERT INTO products(id,project_id,key,internal_name,type,status,metadata_source, + readiness_ready,created_at,updated_at) + VALUES ($1,$2,$3,'Pro','subscription','connected','mock',true,$4,$4)`, + []any{c.productID, c.projectID, "pro-" + suffix, now}}, + {`INSERT INTO entitlements(id,project_id,key,name,created_at,updated_at) + VALUES ($1,$2,$3,'Pro Access',$4,$4)`, + []any{c.entitlementID, c.projectID, "pro-access-" + suffix, now}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatalf("seed catalog: %v", err) + } + } + t.Cleanup(func() { + cleanupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cleanupCatalog(cleanupContext, pool, c, organizationID) + }) + return c +} + +func cleanupCatalog(ctx context.Context, pool *pgxpool.Pool, c catalog, organizationID string) { + // Grant versions are append-only apart from their closing instant, so the + // fixture is torn down with the trigger disabled rather than by weakening + // the schema the tests are here to verify. + _, _ = pool.Exec(ctx, + `ALTER TABLE product_entitlement_grant_versions DISABLE TRIGGER product_entitlement_grant_versions_append_only`) + _, _ = pool.Exec(ctx, + `ALTER TABLE product_entitlement_grants DISABLE TRIGGER product_entitlement_grants_versioned_delete`) + for _, statement := range []string{ + `DELETE FROM projection_jobs WHERE project_id=$1`, + `DELETE FROM product_entitlement_grant_versions WHERE project_id=$1`, + `DELETE FROM product_entitlement_grants WHERE project_id=$1`, + `DELETE FROM entitlements WHERE project_id=$1`, + `DELETE FROM products WHERE project_id=$1`, + `DELETE FROM audit_events WHERE project_id=$1`, + `DELETE FROM billing_project_settings WHERE project_id=$1`, + `DELETE FROM projects WHERE id=$1`, + } { + _, _ = pool.Exec(ctx, statement, c.projectID) + } + _, _ = pool.Exec(ctx, `DELETE FROM organization_members WHERE organization_id=$1`, organizationID) + _, _ = pool.Exec(ctx, `DELETE FROM organizations WHERE id=$1`, organizationID) + _, _ = pool.Exec(ctx, + `ALTER TABLE product_entitlement_grants ENABLE TRIGGER product_entitlement_grants_versioned_delete`) + _, _ = pool.Exec(ctx, + `ALTER TABLE product_entitlement_grant_versions ENABLE TRIGGER product_entitlement_grant_versions_append_only`) +} + +func fullAccess() billingprojection.Policy { + return billingprojection.Policy{ + GrantsInActive: true, GrantsInTrial: true, GrantsInGrace: true, GrantsInOneTime: true, + } +} + +// Publishing a grant version supersedes the previous one and writes the audit +// trail in one transaction. +// +// The property that matters is that the two intervals abut exactly: the closed +// version ends at the instant the new one begins. A gap would strand every +// purchase made inside it with no applicable grant — the projection engine would +// report `unknown` for customers who bought during it — and an overlap would +// make which version applies a function of row order. +// +// This is an integration test because the close is an UPDATE the append-only +// trigger scrutinises, the insert is guarded by the one-open-version partial +// unique index, and the atomicity is the transaction. None of the three exists +// in Go. +func TestPublishSupersedesThePreviousVersionAtomically(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + c := seedCatalog(t, ctx, pool, "publish") + actor := billinggrant.Actor{ID: c.actorID} + service := billinggrant.NewService(repository) + + firstStart := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + first, err := service.Publish(ctx, actor, c.projectID, billinggrant.PublishInput{ + ProductID: c.productID, EntitlementID: c.entitlementID, + EffectiveStart: firstStart, Policy: fullAccess(), Reason: "initial grant", + }) + if err != nil { + t.Fatalf("first publish: %v", err) + } + if first.Version != 1 || !first.Current() { + t.Fatalf("first published version is %d (current=%t), want 1 and current", first.Version, first.Current()) + } + + secondStart := firstStart.Add(24 * time.Hour) + narrowed := fullAccess() + narrowed.GrantsInGrace = false + second, err := service.Publish(ctx, actor, c.projectID, billinggrant.PublishInput{ + ProductID: c.productID, EntitlementID: c.entitlementID, + EffectiveStart: secondStart, Policy: narrowed, Reason: "stop granting during grace", + }) + if err != nil { + t.Fatalf("second publish: %v", err) + } + if second.Version != 2 || !second.Current() { + t.Fatalf("second published version is %d (current=%t), want 2 and current", second.Version, second.Current()) + } + + versions, err := repository.ListVersions(ctx, c.projectID, billinggrant.ListFilter{ + ProductID: c.productID, EntitlementID: c.entitlementID, + }) + if err != nil { + t.Fatal(err) + } + if len(versions) != 2 { + t.Fatalf("%d recorded versions, want 2", len(versions)) + } + closed := versions[1] + if closed.Version != 1 { + closed = versions[0] + } + if closed.EffectiveEnd == nil { + t.Fatal("version 1 was not closed; the pair now has two open versions") + } + if !closed.EffectiveEnd.Equal(secondStart) { + t.Fatalf("version 1 closed at %s, want the successor's start %s — the two intervals must abut", + closed.EffectiveEnd, secondStart) + } + + // The engine that derives access must agree that exactly one version applies + // at every instant, which is the property the abutment exists to produce. + engineVersions := []billingprojection.GrantVersion{ + {ID: closed.ID, ProductID: c.productID, EntitlementID: c.entitlementID, Version: 1, + EffectiveStart: closed.EffectiveStart, EffectiveEnd: closed.EffectiveEnd, + SupportedPurchaseTypes: closed.SupportedPurchaseTypes, Policy: closed.Policy}, + {ID: second.ID, ProductID: c.productID, EntitlementID: c.entitlementID, Version: 2, + EffectiveStart: second.EffectiveStart, SupportedPurchaseTypes: second.SupportedPurchaseTypes, + Policy: second.Policy}, + } + for _, probe := range []struct { + at time.Time + want int + }{ + {firstStart.Add(time.Minute), 1}, + {secondStart.Add(-time.Nanosecond), 1}, + {secondStart, 2}, + {secondStart.Add(time.Hour), 2}, + } { + selected := billingprojection.SelectGrantVersions(engineVersions, c.productID, probe.at, + billinggrant.PurchaseTypeAutoRenewable) + if len(selected) != 1 { + t.Fatalf("%d versions apply at %s, want exactly 1", len(selected), probe.at) + } + if selected[0].Version != probe.want { + t.Fatalf("version %d applies at %s, want %d", selected[0].Version, probe.at, probe.want) + } + } + + var auditRows int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM audit_events + WHERE project_id=$1 AND action='product.entitlement_grant_version_published'`, + c.projectID).Scan(&auditRows); err != nil { + t.Fatal(err) + } + if auditRows != 2 { + t.Fatalf("%d audit entries for two publishes, want 2", auditRows) + } +} + +// A published grant version is immutable apart from the instant it closes. +// +// This is the guarantee that makes historical access reproducible: the grant +// version a purchase selected is chosen by the purchase's own effective time, so +// rewriting a version's policy or moving its boundary changes what a customer +// was entitled to at a moment that has already passed. Nothing in Go can enforce +// it — a direct UPDATE from a migration, a psql session, or a future repository +// method bypasses every application check — so it is enforced by the database +// and verified here. +func TestPublishedGrantVersionIsImmutableApartFromClosing(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + c := seedCatalog(t, ctx, pool, "immutable") + actor := billinggrant.Actor{ID: c.actorID} + service := billinggrant.NewService(repository) + + start := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + published, err := service.Publish(ctx, actor, c.projectID, billinggrant.PublishInput{ + ProductID: c.productID, EntitlementID: c.entitlementID, + EffectiveStart: start, Policy: fullAccess(), Reason: "initial grant", + }) + if err != nil { + t.Fatal(err) + } + + for _, attempt := range []struct { + name string + query string + args []any + }{ + {"rewriting the access policy", + `UPDATE product_entitlement_grant_versions SET grants_in_grace = false WHERE id = $1`, + []any{published.ID}}, + {"moving the effective start", + `UPDATE product_entitlement_grant_versions SET effective_start = $2 WHERE id = $1`, + []any{published.ID, start.Add(-48 * time.Hour)}}, + {"rewriting the reason", + `UPDATE product_entitlement_grant_versions SET reason = 'something else' WHERE id = $1`, + []any{published.ID}}, + {"deleting the version", + `DELETE FROM product_entitlement_grant_versions WHERE id = $1`, + []any{published.ID}}, + } { + if _, err := pool.Exec(ctx, attempt.query, attempt.args...); err == nil { + t.Fatalf("%s was permitted; a published grant version must be immutable", attempt.name) + } + } + + // Closing is the one permitted change, and only once. + closeAt := start.Add(24 * time.Hour) + if _, err := pool.Exec(ctx, + `UPDATE product_entitlement_grant_versions SET effective_end = $2 WHERE id = $1`, + published.ID, closeAt); err != nil { + t.Fatalf("closing an open version was refused: %v", err) + } + if _, err := pool.Exec(ctx, + `UPDATE product_entitlement_grant_versions SET effective_end = $2 WHERE id = $1`, + published.ID, closeAt.Add(time.Hour)); err == nil { + t.Fatal("re-closing a closed version was permitted; the boundary between two versions " + + "would move and every purchase between the old and new instant would change grant") + } + if _, err := pool.Exec(ctx, + `UPDATE product_entitlement_grant_versions SET effective_end = NULL WHERE id = $1`, + published.ID); err == nil { + t.Fatal("reopening a closed version was permitted") + } + + // The application answers the same refusal as a clean domain error rather + // than a constraint failure. + _, err = service.Publish(ctx, actor, c.projectID, billinggrant.PublishInput{ + ProductID: c.productID, EntitlementID: c.entitlementID, + EffectiveStart: start.Add(time.Hour), Retroactive: true, Policy: fullAccess(), + Reason: "inside the closed interval", + }) + if !errors.Is(err, billinggrant.ErrOverlap) { + t.Fatalf("publishing inside a closed interval returned %v, want ErrOverlap", err) + } +} diff --git a/apps/api/internal/platform/billinggrantpostgres/repository.go b/apps/api/internal/platform/billinggrantpostgres/repository.go new file mode 100644 index 00000000..6e68d20d --- /dev/null +++ b/apps/api/internal/platform/billinggrantpostgres/repository.go @@ -0,0 +1,497 @@ +// Package billinggrantpostgres is the PostgreSQL implementation of the +// grant-version management port. +// +// Two properties shape everything here. Every read and write filters on +// project_id, so tenant isolation is a property of the query rather than of a +// caller remembering to check. And a publish is one transaction holding the +// pair's advisory lock from before the history is read until after the version, +// the audit event, and the reprojection work are written — so the decision +// cannot be made against a history that has already moved. +package billinggrantpostgres + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billinggrant" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +var _ billinggrant.Repository = (*Repository)(nil) + +func (r *Repository) BillingEnabled(ctx context.Context, projectID string) (bool, error) { + var enabled bool + err := r.pool.QueryRow(ctx, + `SELECT billing_enabled FROM billing_project_settings WHERE project_id = $1`, projectID). + Scan(&enabled) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read billing enablement: %w", err) + } + return enabled, nil +} + +// Role resolves the actor's organization role for the Project. +func (r *Repository) Role(ctx context.Context, actor billinggrant.Actor, projectID string) (string, error) { + if strings.TrimSpace(actor.ID) == "" { + return "", billinggrant.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 "", billinggrant.ErrNotFound + } + if err != nil { + return "", fmt.Errorf("resolve grant-version role: %w", err) + } + return role, nil +} + +// versionColumns is the single projection every version read uses, so a column +// added to one read cannot be forgotten by another. +// +// `effective_start < created_at` is how a retroactive publish is recognised on +// read: a version whose meaning began before it existed was backdated. Storing +// a flag would be a second source of truth for a fact the two timestamps +// already state exactly. +const versionColumns = `v.id, v.project_id, v.product_id, p.key, v.entitlement_id, e.key, + v.version, v.grant_policy_version, v.effective_start, v.effective_end, + v.supported_purchase_types, v.grants_in_active, v.grants_in_trial, v.grants_in_grace, + v.grants_in_billing_retry, v.grants_in_one_time_ownership, + v.created_at, coalesce(v.created_by_actor_id, ''), v.reason, + (v.effective_start < v.created_at) AS retroactive` + +func scanVersion(row pgx.Row) (billinggrant.Version, error) { + var version billinggrant.Version + var policy billingprojection.Policy + err := row.Scan(&version.ID, &version.ProjectID, &version.ProductID, &version.ProductKey, + &version.EntitlementID, &version.EntitlementKey, &version.Version, + &version.GrantPolicyVersion, &version.EffectiveStart, &version.EffectiveEnd, + &version.SupportedPurchaseTypes, &policy.GrantsInActive, &policy.GrantsInTrial, + &policy.GrantsInGrace, &policy.GrantsInBillingRetry, &policy.GrantsInOneTime, + &version.CreatedAt, &version.CreatedByActorID, &version.Reason, &version.Retroactive) + version.Policy = policy + return version, err +} + +const versionFrom = `FROM product_entitlement_grant_versions v + JOIN products p ON p.id = v.product_id AND p.project_id = v.project_id + JOIN entitlements e ON e.id = v.entitlement_id AND e.project_id = v.project_id` + +func (r *Repository) ListVersions(ctx context.Context, projectID string, + filter billinggrant.ListFilter) ([]billinggrant.Version, error) { + + filter = filter.Bounded() + rows, err := r.pool.Query(ctx, + `SELECT `+versionColumns+` `+versionFrom+` + WHERE v.project_id = $1 + AND ($2 = '' OR v.id = $2) + AND ($3 = '' OR v.product_id = $3) + AND ($4 = '' OR v.entitlement_id = $4) + AND (NOT $5::boolean OR v.effective_end IS NULL) + ORDER BY v.entitlement_id, v.version DESC + LIMIT $6`, + projectID, filter.VersionID, filter.ProductID, filter.EntitlementID, + filter.CurrentOnly, filter.Limit) + if err != nil { + return nil, fmt.Errorf("list grant versions: %w", err) + } + defer rows.Close() + + versions := make([]billinggrant.Version, 0, filter.Limit) + for rows.Next() { + version, err := scanVersion(rows) + if err != nil { + return nil, fmt.Errorf("scan grant version: %w", err) + } + versions = append(versions, version) + } + return versions, rows.Err() +} + +func (r *Repository) CurrentVersion(ctx context.Context, projectID, productID, entitlementID string) ( + billinggrant.Version, bool, error) { + + version, err := scanVersion(r.pool.QueryRow(ctx, + `SELECT `+versionColumns+` `+versionFrom+` + WHERE v.project_id = $1 AND v.product_id = $2 AND v.entitlement_id = $3 + AND v.effective_end IS NULL`, + projectID, productID, entitlementID)) + if errors.Is(err, pgx.ErrNoRows) { + return billinggrant.Version{}, false, nil + } + if err != nil { + return billinggrant.Version{}, false, fmt.Errorf("read current grant version: %w", err) + } + return version, true, nil +} + +// Impact counts what a change to one pair would touch. +// +// Everything is counted from *current* state — the snapshot each customer's +// pointer names, not the whole snapshot history. A preview that included +// superseded snapshots would report a number no operator action can change, and +// on a busy Project it would be a much larger number, which is the worst +// possible combination for a confirmation dialog. +// +// It is one round trip so every count describes one instant. An operator +// weighing "customers" against "sources currently granting" across two +// statements would be comparing two different moments. +func (r *Repository) Impact(ctx context.Context, projectID, productID, entitlementID string) ( + billinggrant.Impact, error) { + + var exists bool + err := r.pool.QueryRow(ctx, + `SELECT true FROM products WHERE id = $1 AND project_id = $2`, productID, projectID).Scan(&exists) + if errors.Is(err, pgx.ErrNoRows) { + return billinggrant.Impact{}, billinggrant.ErrNotFound + } + if err != nil { + return billinggrant.Impact{}, fmt.Errorf("resolve impact Product: %w", err) + } + err = r.pool.QueryRow(ctx, + `SELECT true FROM entitlements WHERE id = $1 AND project_id = $2`, entitlementID, projectID).Scan(&exists) + if errors.Is(err, pgx.ErrNoRows) { + return billinggrant.Impact{}, billinggrant.ErrNotFound + } + if err != nil { + return billinggrant.Impact{}, fmt.Errorf("resolve impact Entitlement: %w", err) + } + + impact := billinggrant.Impact{ProductID: productID, EntitlementID: entitlementID} + err = r.pool.QueryRow(ctx, + `WITH cited AS ( + SELECT s.billing_customer_id, s.entitlement_id, s.product_id, s.source_state + FROM entitlement_sources s + JOIN customer_entitlement_pointers ptr + ON ptr.current_snapshot_id = s.customer_entitlement_snapshot_id + AND ptr.billing_customer_id = s.billing_customer_id + WHERE s.project_id = $1 + ), + affected AS ( + SELECT DISTINCT billing_customer_id FROM cited WHERE product_id = $2 + ) + SELECT + (SELECT count(*) FROM affected), + (SELECT count(*) FROM cited WHERE product_id = $2 AND source_state = 'active'), + -- Reprojecting an affected customer re-derives every Entitlement and + -- every Product their current snapshot cites, not only the pair being + -- changed. Reporting the narrower number would understate the blast + -- radius of the confirmation the operator is about to give. + (SELECT count(DISTINCT c.entitlement_id) FROM cited c + JOIN affected a ON a.billing_customer_id = c.billing_customer_id), + (SELECT count(DISTINCT c.product_id) FROM cited c + JOIN affected a ON a.billing_customer_id = c.billing_customer_id), + -- Lineages resolved to the Product regardless of whether a customer + -- has been resolved yet: purchases that the change affects and that + -- the customer count cannot see. + (SELECT count(*) FROM subscription_instances + WHERE project_id = $1 AND current_mosaic_product_id = $2) + + (SELECT count(*) FROM one_time_purchase_instances + WHERE project_id = $1 AND mosaic_product_id = $2)`, + projectID, productID). + Scan(&impact.ImpactedCustomers, &impact.ImpactedActiveSources, + &impact.ImpactedEntitlements, &impact.ImpactedProducts, &impact.ImpactedLineages) + if err != nil { + return billinggrant.Impact{}, fmt.Errorf("count grant-version impact: %w", err) + } + // A pair with no committed sources still touches its own Product and + // Entitlement; reporting zero would read as "this change does nothing". + if impact.ImpactedProducts == 0 { + impact.ImpactedProducts = 1 + } + if impact.ImpactedEntitlements == 0 { + impact.ImpactedEntitlements = 1 + } + return impact, nil +} + +// Publish applies one validated proposal atomically. +func (r *Repository) Publish(ctx context.Context, actor billinggrant.Actor, projectID string, + input billinggrant.PublishInput, + plan func(existing []billinggrant.Version, at time.Time) (billinggrant.Plan, error), + now time.Time) (billinggrant.Version, error) { + + tx, err := r.pool.Begin(ctx) + if err != nil { + return billinggrant.Version{}, fmt.Errorf("begin grant version publish: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // The pair's advisory lock, following the accepted LockScope pattern. It is + // taken before the history is read, so two concurrent publishes for one pair + // serialize rather than both deciding against the same "current" version and + // both trying to close it. + lockScope := "billing-grant-version:" + projectID + ":" + input.ProductID + ":" + input.EntitlementID + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, lockScope); err != nil { + return billinggrant.Version{}, fmt.Errorf("lock grant version pair: %w", err) + } + + var organizationID string + if err := tx.QueryRow(ctx, + `SELECT p.organization_id FROM products pr + JOIN projects p ON p.id = pr.project_id + WHERE pr.id = $1 AND pr.project_id = $2`, input.ProductID, projectID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return billinggrant.Version{}, billinggrant.ErrNotFound + } + return billinggrant.Version{}, fmt.Errorf("resolve grant version Product: %w", err) + } + var entitlementKey string + if err := tx.QueryRow(ctx, `SELECT key FROM entitlements WHERE id = $1 AND project_id = $2`, + input.EntitlementID, projectID).Scan(&entitlementKey); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return billinggrant.Version{}, billinggrant.ErrNotFound + } + return billinggrant.Version{}, fmt.Errorf("resolve grant version Entitlement: %w", err) + } + if err := requireActiveEntitlement(ctx, tx, projectID, input.EntitlementID); err != nil { + return billinggrant.Version{}, err + } + + existing, err := versionsForPair(ctx, tx, projectID, input.ProductID, input.EntitlementID) + if err != nil { + return billinggrant.Version{}, err + } + decision, err := plan(existing, now) + if err != nil { + return billinggrant.Version{}, err + } + + if decision.SupersededVersionID != "" { + // The one permitted update: closing an open interval. The trigger from + // migration 00047 refuses everything else, and the WHERE clause refuses + // to close an interval that has been closed since the history was read — + // which cannot happen under the lock, and is checked anyway because the + // alternative failure is silent. + tag, err := tx.Exec(ctx, + `UPDATE product_entitlement_grant_versions + SET effective_end = $3 + WHERE id = $1 AND project_id = $2 AND effective_end IS NULL`, + decision.SupersededVersionID, projectID, decision.SupersededAt) + if err != nil { + return billinggrant.Version{}, translate(err, "close superseded grant version") + } + if tag.RowsAffected() != 1 { + return billinggrant.Version{}, billinggrant.ErrConflict + } + } + + versionID := "pegv_" + shortHash(projectID, input.ProductID, input.EntitlementID, decision.NextVersion) + inserted, err := scanVersion(tx.QueryRow(ctx, + `WITH inserted AS ( + INSERT INTO product_entitlement_grant_versions( + id, project_id, product_id, entitlement_id, version, grant_policy_version, + effective_start, effective_end, supported_purchase_types, + grants_in_active, grants_in_trial, grants_in_grace, grants_in_billing_retry, + grants_in_paused, grants_in_one_time_ownership, + created_at, created_by_actor_id, reason) + VALUES ($1,$2,$3,$4,$5,$6,$7,NULL,$8,$9,$10,$11,$12,false,$13,$14,$15,$16) + RETURNING * + ) + SELECT `+versionColumns+` + FROM inserted v + JOIN products p ON p.id = v.product_id AND p.project_id = v.project_id + JOIN entitlements e ON e.id = v.entitlement_id AND e.project_id = v.project_id`, + versionID, projectID, input.ProductID, input.EntitlementID, decision.NextVersion, + billinggrant.GrantPolicyVersion, input.EffectiveStart.UTC(), input.SupportedPurchaseTypes, + input.Policy.GrantsInActive, input.Policy.GrantsInTrial, input.Policy.GrantsInGrace, + input.Policy.GrantsInBillingRetry, input.Policy.GrantsInOneTime, + now.UTC(), actor.ID, strings.TrimSpace(input.Reason))) + if err != nil { + return billinggrant.Version{}, translate(err, "insert grant version") + } + + // The legacy unversioned grant row is kept in step so the catalog surface + // that still reads it does not disagree with the versioned history. It is + // the projection of the versioned truth, not a second truth. + if _, err := tx.Exec(ctx, + `INSERT INTO product_entitlement_grants(product_id, entitlement_id, project_id, created_at) + VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, + input.ProductID, input.EntitlementID, projectID, now.UTC()); err != nil { + return billinggrant.Version{}, translate(err, "align legacy grant row") + } + + if err := recordAudit(ctx, tx, organizationID, projectID, actor.ID, inserted, decision, now); err != nil { + return billinggrant.Version{}, err + } + enqueued, err := enqueueReprojection(ctx, tx, projectID, input.ProductID, now) + if err != nil { + return billinggrant.Version{}, err + } + _ = enqueued + + if err := tx.Commit(ctx); err != nil { + return billinggrant.Version{}, fmt.Errorf("commit grant version publish: %w", err) + } + return inserted, nil +} + +// requireActiveEntitlement refuses a grant version for an archived Entitlement. +// Archiving preserves historical meaning; publishing new meaning onto it would +// quietly un-archive it for every future purchase. +func requireActiveEntitlement(ctx context.Context, tx pgx.Tx, projectID, entitlementID string) error { + var lifecycle string + if err := tx.QueryRow(ctx, + `SELECT lifecycle_state FROM entitlements WHERE id = $1 AND project_id = $2`, + entitlementID, projectID).Scan(&lifecycle); err != nil { + return fmt.Errorf("read Entitlement lifecycle: %w", err) + } + if lifecycle != "active" { + return fmt.Errorf("%w: the Entitlement is archived", billinggrant.ErrInvalid) + } + return nil +} + +func versionsForPair(ctx context.Context, tx pgx.Tx, projectID, productID, entitlementID string) ( + []billinggrant.Version, error) { + + rows, err := tx.Query(ctx, + `SELECT `+versionColumns+` `+versionFrom+` + WHERE v.project_id = $1 AND v.product_id = $2 AND v.entitlement_id = $3 + ORDER BY v.version`, + projectID, productID, entitlementID) + if err != nil { + return nil, fmt.Errorf("read grant version history: %w", err) + } + defer rows.Close() + + versions := make([]billinggrant.Version, 0, 8) + for rows.Next() { + version, err := scanVersion(rows) + if err != nil { + return nil, fmt.Errorf("scan grant version history: %w", err) + } + versions = append(versions, version) + } + return versions, rows.Err() +} + +// recordAudit writes the change into the audit trail inside the publish +// transaction. The actor, the reason, and what the change did to the previous +// version are all on the entry, because an investigation months later reads the +// audit trail and not the response the operator saw once. +func recordAudit(ctx context.Context, tx pgx.Tx, organizationID, projectID, actorID string, + version billinggrant.Version, decision billinggrant.Plan, now time.Time) error { + + metadata, err := json.Marshal(map[string]any{ + "entitlementId": version.EntitlementID, + "entitlementKey": version.EntitlementKey, + "productId": version.ProductID, + "grantVersion": version.Version, + "grantPolicyVersion": version.GrantPolicyVersion, + "effectiveStart": version.EffectiveStart.UTC().Format(time.RFC3339Nano), + "retroactive": version.Retroactive, + "supersededVersionId": decision.SupersededVersionID, + "reason": version.Reason, + "grantsInActive": version.Policy.GrantsInActive, + "grantsInTrial": version.Policy.GrantsInTrial, + "grantsInGrace": version.Policy.GrantsInGrace, + "grantsInBillingRetry": version.Policy.GrantsInBillingRetry, + "grantsInOneTime": version.Policy.GrantsInOneTime, + }) + if err != nil { + return fmt.Errorf("encode grant version audit metadata: %w", err) + } + if _, err := tx.Exec(ctx, + `INSERT INTO audit_events(id, actor_id, organization_id, project_id, + action, resource_type, resource_id, metadata, created_at) + VALUES ($1,$2,$3,$4,'product.entitlement_grant_version_published', + 'product_entitlement_grant_version',$5,$6,$7)`, + "aud_"+shortHash(version.ID, now.UnixNano()), actorID, organizationID, projectID, + version.ID, metadata, now.UTC()); err != nil { + return fmt.Errorf("insert grant version audit event: %w", err) + } + return nil +} + +// enqueueReprojection queues one projection job per affected customer. +// +// It is inside the publish transaction on purpose. A grant version that is +// recorded but never applied is worse than one that was never published: every +// surface reports the new meaning while every customer keeps the old access, and +// nothing in the system is in a state that would ever retry. Committing the +// version and the work to apply it together makes that state unreachable. +// +// The insert is set-based and coalesces on the existing partial uniqueness, so a +// customer with projection work already queued absorbs the trigger rather than +// accumulating a second job. +func enqueueReprojection(ctx context.Context, tx pgx.Tx, projectID, productID string, now time.Time) (int64, error) { + tag, err := tx.Exec(ctx, + `INSERT INTO projection_jobs( + id, project_id, environment_id, scope_key, kind, detail, status, + attempt_count, max_attempts, available_at, created_at, updated_at) + SELECT 'pjb_' || md5(scoped.environment_id || ':' || scoped.billing_customer_id || ':' || $3::text), + $1, scoped.environment_id, 'customer:' || scoped.billing_customer_id, + $4, jsonb_build_object('customerId', scoped.billing_customer_id, 'lineageId', ''), + 'queued', 0, 8, $5, $5, $5 + FROM ( + SELECT DISTINCT s.environment_id, s.billing_customer_id + FROM entitlement_sources s + JOIN customer_entitlement_pointers ptr + ON ptr.current_snapshot_id = s.customer_entitlement_snapshot_id + AND ptr.billing_customer_id = s.billing_customer_id + WHERE s.project_id = $1 AND s.product_id = $2 + ) scoped + ON CONFLICT DO NOTHING`, + projectID, productID, now.UTC().Format(time.RFC3339Nano), + billingprojection.KindGrantVersionPublished, now.UTC()) + if err != nil { + return 0, translate(err, "enqueue grant-version reprojection") + } + return tag.RowsAffected(), nil +} + +func shortHash(parts ...any) string { + hasher := sha256.New() + for _, part := range parts { + fmt.Fprintf(hasher, "%v\x00", part) + } + return fmt.Sprintf("%x", hasher.Sum(nil))[:24] +} + +// translate maps the constraint and trigger failures this package can provoke +// onto stable domain errors, so an operator gets a sentence about grants rather +// than a constraint name. +func translate(err error, context string) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch { + case pgErr.Code == "55000": + // The append-only trigger refused a rewrite. + return billinggrant.ErrImmutable + case pgErr.Code == "23505" && + strings.Contains(pgErr.ConstraintName, "product_entitlement_grant_versions_open_idx"): + // Two open-ended versions for one pair. Under the advisory lock this + // is unreachable; if it is reached, the history moved. + return billinggrant.ErrConflict + case pgErr.Code == "23505": + return billinggrant.ErrConflict + case pgErr.Code == "23514": + return fmt.Errorf("%w: the proposed version violates a grant invariant", billinggrant.ErrInvalid) + case pgErr.Code == "23503": + return billinggrant.ErrNotFound + } + } + return fmt.Errorf("%s: %w", context, err) +} diff --git a/apps/api/internal/platform/billingkeys/authenticators.go b/apps/api/internal/platform/billingkeys/authenticators.go new file mode 100644 index 00000000..4508d706 --- /dev/null +++ b/apps/api/internal/platform/billingkeys/authenticators.go @@ -0,0 +1,96 @@ +// Package billingkeys bridges Mosaic's single API-key authentication into the +// key ports the Phase 9B billing modules declare. +// +// Each 9B module declares its own narrow authenticator port rather than +// importing a sibling module's, which is what keeps identity from depending on +// access and access from depending on restore. The cost of that is a small +// amount of translation at the composition root, and this package is where it +// lives — in one place, so there is exactly one implementation of "is this key +// valid?" behind every one of those ports. A second implementation would +// eventually disagree with the first about which keys are valid, and the +// disagreement would be a tenant boundary. +// +// Nothing here decides anything. It authenticates and copies a scope; every +// authorization decision belongs to the service that received it. +package billingkeys + +import ( + "context" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" + "github.com/Mujhtech/mosaic/apps/api/internal/billingrestore" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingpostgres" +) + +// Authenticator wraps the ingestion repository, which owns the one prefix +// lookup and constant-time digest comparison Mosaic performs on an API key. +type Authenticator struct { + repository *billingpostgres.Repository +} + +func New(repository *billingpostgres.Repository) Authenticator { + return Authenticator{repository: repository} +} + +// Identity adapts the authenticator to the billing-identity port. +// +// Only the secret server key is exposed. The identity module has no public-key +// path at all: an application-user alias is assertable only by the customer's +// own backend, so an authenticator that could resolve a public SDK key would be +// an impersonate-anyone vulnerability regardless of what the handler in front +// of it checked. +func (a Authenticator) Identity() billingcustomer.ServerKeyAuthenticator { + return billingcustomer.ServerKeyAuthenticatorFunc( + func(ctx context.Context, raw string) (billingcustomer.KeyScope, error) { + scope, err := a.repository.AuthenticateServerKey(ctx, raw) + if err != nil { + return billingcustomer.KeyScope{}, billingcustomer.ErrUnauthenticated + } + return billingcustomer.KeyScope{ + APIKeyID: scope.APIKeyID, + OrganizationID: scope.OrganizationID, + ProjectID: scope.ProjectID, + EnvironmentID: scope.EnvironmentID, + EnvironmentMode: scope.EnvironmentMode, + ApplicationID: scope.ApplicationID, + }, nil + }) +} + +// Restore adapts the authenticator to the restore port, which needs both key +// classes: a restore may be requested by an application backend or by an SDK, +// and in neither case does the key select a customer — identity is resolved +// server-side from validated store lineage. +func (a Authenticator) Restore() billingrestore.KeyAuthenticator { return restoreKeys(a) } + +type restoreKeys Authenticator + +var _ billingrestore.KeyAuthenticator = restoreKeys{} + +func (k restoreKeys) AuthenticateServerKey(ctx context.Context, raw string) (billingrestore.KeyScope, error) { + scope, err := k.repository.AuthenticateServerKey(ctx, raw) + if err != nil { + return billingrestore.KeyScope{}, billingrestore.ErrUnauthenticated + } + return restoreScope(scope), nil +} + +func (k restoreKeys) AuthenticateSDKKey(ctx context.Context, raw string) (billingrestore.KeyScope, error) { + scope, err := k.repository.AuthenticateSDKKey(ctx, raw) + if err != nil { + return billingrestore.KeyScope{}, billingrestore.ErrUnauthenticated + } + return restoreScope(scope), nil +} + +func restoreScope(scope billing.ObservationScope) billingrestore.KeyScope { + return billingrestore.KeyScope{ + APIKeyID: scope.APIKeyID, + OrganizationID: scope.OrganizationID, + ProjectID: scope.ProjectID, + EnvironmentID: scope.EnvironmentID, + EnvironmentMode: scope.EnvironmentMode, + ApplicationID: scope.ApplicationID, + } +} diff --git a/apps/api/internal/platform/billingoperatorpostgres/operator_integration_test.go b/apps/api/internal/platform/billingoperatorpostgres/operator_integration_test.go new file mode 100644 index 00000000..60da4043 --- /dev/null +++ b/apps/api/internal/platform/billingoperatorpostgres/operator_integration_test.go @@ -0,0 +1,615 @@ +package billingoperatorpostgres_test + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "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/billingcustomer" + "github.com/Mujhtech/mosaic/apps/api/internal/billingoperator" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingaccesspostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingcustomerpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingoperatorpostgres" + billingoperatorhttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingoperator" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +// These tests cover the guarantees that only exist once a real browser-session +// principal reaches real SQL, and that a unit test with a fake repository would +// report as passing while the shipped surface was wrong: +// +// - the operator surface is permission-checked server-side (plan §15), at the +// owner/admin bar the 9A billing operator pages use, and it does not leak +// across Projects or Environments; +// - the customer lookup is structurally incapable of creating anything, which +// is the whole reason it is not the trusted create-or-get identify call; +// - conflict resolution requires a reason, audits it, and reprojects both +// candidates — the loser included, because the loser is the one holding a +// committed snapshot that still grants the disputed purchase (OD-10); +// - no operator response carries an alias value or an alias digest. +// +// Column mapping, cursor encoding, and view construction are not tested here: +// they are either exercised through these paths or are not risks worth a +// database round trip. + +func testPool(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + 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(), 120*time.Second) + t.Cleanup(cancel) + + db, err := sql.Open("pgx", databaseURL) + 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.Fatalf("apply migrations: %v", err) + } + _ = db.Close() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + return pool, ctx +} + +// recordingReprojector stands in for the projection service. The operator +// surface must be able to prove it *asked* for a recomputation; running one is +// the projection module's own tested behaviour. +type recordingReprojector struct { + scopes []billingprojection.Scope + failures int +} + +func (r *recordingReprojector) Enqueue(_ context.Context, scope billingprojection.Scope, _ string) error { + r.scopes = append(r.scopes, scope) + if r.failures > 0 { + r.failures-- + return errors.New("injected projection enqueue failure") + } + return nil +} + +type tenant struct { + organizationID string + projectID string + environmentID string + applicationID string + ownerActor string + memberActor string + firstCustomer string + secondCustomer string + lineageID string + conflictID string + // applicationUserValue and installationValue are the raw identifiers. They + // exist only in the test: Mosaic stores their digests. + applicationUserValue string + installationValue string +} + +func seedTenant(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) tenant { + t.Helper() + now := time.Now().UTC() + scope := tenant{ + organizationID: "org_op_" + suffix, + projectID: "proj_op_" + suffix, + environmentID: "env_op_" + suffix, + applicationID: "app_op_" + suffix, + ownerActor: "actor_op_owner_" + suffix, + memberActor: "actor_op_member_" + suffix, + firstCustomer: "bcu_op_" + suffix + "_a", + secondCustomer: "bcu_op_" + suffix + "_b", + lineageID: "bpl_op_" + suffix, + conflictID: "bic_op_" + suffix, + applicationUserValue: "user-" + suffix + "-secret-value", + installationValue: "install-" + suffix + "-secret-value", + } + cleanupTenant(ctx, pool, scope) + + aliasDigest := billingcustomer.AliasDigest(billingcustomer.AliasApplicationUser, scope.applicationUserValue) + installationDigest := billingcustomer.AliasDigest(billingcustomer.AliasInstallation, scope.installationValue) + lineageKey := billingcustomer.AliasDigest("lineage", scope.lineageID) + + statements := []struct { + query string + args []any + }{ + {`INSERT INTO organizations(id,name,created_at,updated_at) VALUES ($1,'Operator Test',$2,$2) + ON CONFLICT (id) DO NOTHING`, []any{scope.organizationID, now}}, + {`INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) + VALUES ($1,$2,'owner',$3,$3) ON CONFLICT (organization_id,actor_id) DO UPDATE SET role='owner'`, + []any{scope.organizationID, scope.ownerActor, now}}, + {`INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) + VALUES ($1,$2,'member',$3,$3) ON CONFLICT (organization_id,actor_id) DO UPDATE SET role='member'`, + []any{scope.organizationID, scope.memberActor, now}}, + {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES ($1,$2,$3,'Operator','active',$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{scope.projectID, scope.organizationID, "operator-" + suffix, now}}, + {`INSERT INTO billing_project_settings(project_id,billing_enabled,updated_by_actor_id,created_at,updated_at) + VALUES ($1,true,'seed',$2,$2) ON CONFLICT (project_id) DO UPDATE SET billing_enabled=true`, + []any{scope.projectID, 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{scope.environmentID, scope.projectID, now}}, + {`INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES ($1,$2,'Operator App','ios',$3,$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{scope.applicationID, scope.projectID, "com.mosaic.operator." + suffix, now}}, + {`INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) + VALUES ($1,$2,'active','none',$3,$3) ON CONFLICT (id) DO NOTHING`, + []any{scope.firstCustomer, scope.projectID, now}}, + {`INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) + VALUES ($1,$2,'active','none',$3,$3) ON CONFLICT (id) DO NOTHING`, + []any{scope.secondCustomer, scope.projectID, now}}, + {`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,'application_user_id',$4,'trusted_server','verified',$5,$5)`, + []any{"bca_op_" + suffix, scope.projectID, scope.firstCustomer, aliasDigest, now}}, + {`INSERT INTO billing_association_evidence(id,project_id,environment_id,evidence_type,evidence_digest, + billing_customer_id,resolver_version,outcome,diagnostic_code,observed_at,created_at) + VALUES ($1,$2,$3,'installation_observation',$4,$5,1,'unsupported','installation_is_evidence_only',$6,$6)`, + []any{"bae_op_" + suffix, scope.projectID, scope.environmentID, installationDigest, + scope.firstCustomer, now}}, + // A frozen lineage the conflict disputes: the incumbent is the first + // customer, the challenger the second. + {`INSERT INTO purchase_lineages(id,project_id,environment_id,environment_mode,application_id,provider, + store_environment,lineage_key_digest,lineage_type,billing_customer_id,projection_frozen, + diagnostic_status,created_at,updated_at) + VALUES ($1,$2,$3,'production',$4,'app_store','production',$5,'subscription',$6,true, + 'identity_conflict',$7,$7)`, + []any{scope.lineageID, scope.projectID, scope.environmentID, scope.applicationID, + lineageKey, scope.firstCustomer, now}}, + {`INSERT INTO billing_identity_conflicts(id,project_id,conflict_scope,purchase_lineage_id,status, + first_customer_id,second_customer_id,detail,opened_at) + VALUES ($1,$2,'lineage',$3,'open',$4,$5, + jsonb_build_object('diagnosticCode','reassignment_requires_operator_resolution'),$6)`, + []any{scope.conflictID, scope.projectID, scope.lineageID, + scope.firstCustomer, scope.secondCustomer, now}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatalf("seed operator tenant: %v", err) + } + } + t.Cleanup(func() { cleanupTenant(context.Background(), pool, scope) }) + return scope +} + +func cleanupTenant(ctx context.Context, pool *pgxpool.Pool, scope tenant) { + // Association evidence carries an append-only trigger, disabled for teardown + // of test data only. No code path under test touches the trigger. + _, _ = pool.Exec(ctx, + `ALTER TABLE billing_association_evidence DISABLE TRIGGER billing_association_evidence_append_only`) + for _, statement := range []string{ + `DELETE FROM audit_events WHERE project_id=$1`, + `DELETE FROM billing_identity_conflicts WHERE project_id=$1`, + `DELETE FROM billing_association_evidence WHERE project_id=$1`, + `DELETE FROM billing_customer_aliases WHERE project_id=$1`, + `DELETE FROM restore_sync_jobs WHERE project_id=$1`, + `DELETE FROM purchase_lineages WHERE project_id=$1`, + `DELETE FROM billing_customers WHERE project_id=$1`, + } { + _, _ = pool.Exec(ctx, statement, scope.projectID) + } + _, _ = pool.Exec(ctx, + `ALTER TABLE billing_association_evidence ENABLE TRIGGER billing_association_evidence_append_only`) + _, _ = pool.Exec(ctx, `DELETE FROM environments WHERE project_id=$1`, scope.projectID) + _, _ = pool.Exec(ctx, `DELETE FROM applications WHERE project_id=$1`, scope.projectID) + _, _ = pool.Exec(ctx, `DELETE FROM billing_project_settings WHERE project_id=$1`, scope.projectID) + _, _ = pool.Exec(ctx, `DELETE FROM projects WHERE id=$1`, scope.projectID) + _, _ = pool.Exec(ctx, `DELETE FROM organization_members WHERE organization_id=$1`, scope.organizationID) + _, _ = pool.Exec(ctx, `DELETE FROM organizations WHERE id=$1`, scope.organizationID) +} + +// surface mounts the operator routes exactly as the router does: inside the +// principal middleware, under /v1/projects/{projectId}. Testing through the +// HTTP boundary is what proves the *shipped* authorization, because the defect +// this batch corrects was a routing mistake — the service was always right, +// and the routes were registered where no browser session could reach them. +type surface struct { + handler http.Handler + reprojector *recordingReprojector + actorID *string +} + +func newSurface(t *testing.T, pool *pgxpool.Pool) *surface { + t.Helper() + reprojector := &recordingReprojector{} + identity := billingcustomer.NewService(billingcustomerpostgres.New(pool), nil, reprojector) + service := billingoperator.NewService( + billingoperatorpostgres.New(pool), billingaccesspostgres.New(pool), identity) + + 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) { + billingoperatorhttp.RegisterProjectRoutes(project, service) + // The Environment-scoped half is registered into a subrouter the + // composition owns, because three modules publish routes under that one + // path and chi refuses to Mount() twice on it (defect D-3). This mirrors + // what httpserver.NewWithDependencies does. + project.Route("/environments/{environmentId}/billing", func(environment chi.Router) { + billingoperatorhttp.RegisterEnvironmentRoutes(environment, service) + }) + }) + return &surface{handler: router, reprojector: reprojector, actorID: &actorID} +} + +func (s *surface) as(actorID string) *surface { + *s.actorID = actorID + return s +} + +func (s *surface) do(t *testing.T, method, path, body string) (int, map[string]any) { + t.Helper() + var reader *strings.Reader + if body == "" { + reader = strings.NewReader("") + } else { + reader = strings.NewReader(body) + } + request := httptest.NewRequest(method, path, reader) + 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 +} + +func (s *surface) raw(t *testing.T, method, path, body string) (int, string) { + 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) + return recorder.Code, recorder.Body.String() +} + +// The dashboard could not reach any 9B customer state before this batch, so the +// risk being protected is that it now reaches *too much*: every route must +// refuse a session that is authenticated but not an owner or admin, and must +// refuse an unauthenticated one, with the standard Mosaic error envelope so the +// dashboard renders a real message rather than a blank failure. +func TestOperatorSurfaceRequiresOwnerOrAdmin(t *testing.T) { + pool, ctx := testPool(t) + scope := seedTenant(t, ctx, pool, "authz") + s := newSurface(t, pool) + + base := "/v1/projects/" + scope.projectID + environment := base + "/environments/" + scope.environmentID + "/billing" + routes := []struct { + method string + path string + body string + }{ + {http.MethodGet, environment + "/customers", ""}, + {http.MethodPost, environment + "/customer-lookups", + `{"identifierType":"application_user_id","identifierValue":"x"}`}, + {http.MethodGet, environment + "/customers/" + scope.firstCustomer, ""}, + {http.MethodGet, environment + "/customers/" + scope.firstCustomer + "/entitlements", ""}, + {http.MethodGet, environment + "/customers/" + scope.firstCustomer + "/subscriptions", ""}, + {http.MethodPost, environment + "/customers/" + scope.firstCustomer + "/sync-requests", ""}, + {http.MethodGet, environment + "/restore-jobs", ""}, + {http.MethodGet, base + "/billing/identity-conflicts", ""}, + {http.MethodGet, base + "/billing/identity-conflicts/" + scope.conflictID, ""}, + {http.MethodPost, base + "/billing/identity-conflicts/" + scope.conflictID + "/resolution", + `{"action":"keep_existing","reason":"support ticket 12"}`}, + } + + for _, route := range routes { + status, payload := s.as(scope.memberActor).do(t, route.method, route.path, route.body) + if status != http.StatusForbidden { + t.Fatalf("%s %s as member: status %d, want 403", route.method, route.path, status) + } + errorPayload, ok := payload["error"].(map[string]any) + if !ok || errorPayload["code"] != "forbidden" { + t.Fatalf("%s %s as member: error envelope %v, want code forbidden", route.method, route.path, payload) + } + + status, _ = s.as("").do(t, route.method, route.path, route.body) + if status != http.StatusUnauthorized { + t.Fatalf("%s %s unauthenticated: status %d, want 401", route.method, route.path, status) + } + } + + // The owner reaches the same routes. Without this the test would pass on a + // surface that refused everyone. + if status, _ := s.as(scope.ownerActor).do(t, http.MethodGet, environment+"/customers", ""); status != http.StatusOK { + t.Fatalf("owner customer list: status %d, want 200", status) + } +} + +// A member of one Project must not read another's billing state, and pairing a +// Project with an Environment that belongs to someone else must not work +// either — the role check would pass on the first and every subsequent query +// filters on the second. Both are reported as absent rather than forbidden, so +// the surface is not an existence oracle over other tenants. +func TestOperatorSurfaceRefusesCrossProjectAndCrossEnvironment(t *testing.T) { + pool, ctx := testPool(t) + first := seedTenant(t, ctx, pool, "xproj1") + second := seedTenant(t, ctx, pool, "xproj2") + s := newSurface(t, pool).as(first.ownerActor) + + // First tenant's owner naming the second tenant's Project. + status, _ := s.do(t, http.MethodGet, + "/v1/projects/"+second.projectID+"/environments/"+second.environmentID+"/billing/customers", "") + if status != http.StatusNotFound { + t.Fatalf("cross-project customer list: status %d, want 404", status) + } + + // Own Project paired with the other tenant's Environment. + status, _ = s.do(t, http.MethodGet, + "/v1/projects/"+first.projectID+"/environments/"+second.environmentID+"/billing/customers", "") + if status != http.StatusNotFound { + t.Fatalf("cross-environment customer list: status %d, want 404", status) + } + + // A customer that exists, in another Project, read through this Project. + status, _ = s.do(t, http.MethodGet, + "/v1/projects/"+first.projectID+"/environments/"+first.environmentID+ + "/billing/customers/"+second.firstCustomer, "") + if status != http.StatusNotFound { + t.Fatalf("cross-project customer detail: status %d, want 404", status) + } + + // The other Project's conflict, through this Project. + status, _ = s.do(t, http.MethodGet, + "/v1/projects/"+first.projectID+"/billing/identity-conflicts/"+second.conflictID, "") + if status != http.StatusNotFound { + t.Fatalf("cross-project conflict detail: status %d, want 404", status) + } +} + +// The trusted identify endpoint is create-or-get: used as a search it would +// mint one Billing Customer per mistyped support query, which is the +// duplicate-customer trap plan §5a exists to avoid. The lookup must therefore +// leave the database byte-for-byte unchanged on a miss, and resolve a hit +// without writing either. +func TestCustomerLookupNeverCreatesAnything(t *testing.T) { + pool, ctx := testPool(t) + scope := seedTenant(t, ctx, pool, "lookup") + s := newSurface(t, pool).as(scope.ownerActor) + path := "/v1/projects/" + scope.projectID + "/environments/" + scope.environmentID + "/billing/customer-lookups" + + counts := func() (customers, aliases, evidence int) { + if err := pool.QueryRow(ctx, + `SELECT (SELECT count(*) FROM billing_customers WHERE project_id=$1), + (SELECT count(*) FROM billing_customer_aliases WHERE project_id=$1), + (SELECT count(*) FROM billing_association_evidence WHERE project_id=$1)`, + scope.projectID).Scan(&customers, &aliases, &evidence); err != nil { + t.Fatalf("count rows: %v", err) + } + return + } + beforeCustomers, beforeAliases, beforeEvidence := counts() + + // A miss on every identifier type. + for _, identifierType := range []string{"application_user_id", "installation_id", "billing_customer_id"} { + status, payload := s.do(t, http.MethodPost, path, + `{"identifierType":"`+identifierType+`","identifierValue":"nobody-has-this-value"}`) + if status != http.StatusOK { + t.Fatalf("lookup miss (%s): status %d, want 200", identifierType, status) + } + data, _ := payload["data"].(map[string]any) + if found, _ := data["found"].(bool); found { + t.Fatalf("lookup miss (%s): reported a match", identifierType) + } + } + + afterCustomers, afterAliases, afterEvidence := counts() + if afterCustomers != beforeCustomers || afterAliases != beforeAliases || afterEvidence != beforeEvidence { + t.Fatalf("lookup wrote rows: customers %d->%d, aliases %d->%d, evidence %d->%d", + beforeCustomers, afterCustomers, beforeAliases, afterAliases, beforeEvidence, afterEvidence) + } + + // Hits, through the alias digest and through installation evidence. + for _, hit := range []struct{ identifierType, value string }{ + {"application_user_id", scope.applicationUserValue}, + {"installation_id", scope.installationValue}, + {"billing_customer_id", scope.firstCustomer}, + } { + status, payload := s.do(t, http.MethodPost, path, + `{"identifierType":"`+hit.identifierType+`","identifierValue":"`+hit.value+`"}`) + if status != http.StatusOK { + t.Fatalf("lookup hit (%s): status %d, want 200", hit.identifierType, status) + } + data, _ := payload["data"].(map[string]any) + customer, _ := data["customer"].(map[string]any) + if customer["billingCustomerId"] != scope.firstCustomer { + t.Fatalf("lookup hit (%s): matched %v, want %s", hit.identifierType, + customer["billingCustomerId"], scope.firstCustomer) + } + } + + finalCustomers, finalAliases, finalEvidence := counts() + if finalCustomers != beforeCustomers || finalAliases != beforeAliases || finalEvidence != beforeEvidence { + t.Fatalf("lookup wrote rows on a hit: customers %d->%d, aliases %d->%d, evidence %d->%d", + beforeCustomers, finalCustomers, beforeAliases, finalAliases, beforeEvidence, finalEvidence) + } +} + +// OD-10: resolution is an explicit, justified, audited operator action, and it +// must reproject *both* candidates. Reprojecting only the winner is the +// stale-grant defect review finding I-10 named: the loser keeps a committed +// snapshot that still grants the purchase it no longer holds. +func TestConflictResolutionRequiresReasonAuditsAndReprojectsBothCandidates(t *testing.T) { + pool, ctx := testPool(t) + scope := seedTenant(t, ctx, pool, "resolve") + s := newSurface(t, pool).as(scope.ownerActor) + path := "/v1/projects/" + scope.projectID + "/billing/identity-conflicts/" + scope.conflictID + "/resolution" + + // A resolution without a reason is refused, and the conflict stays open. + status, _ := s.do(t, http.MethodPost, path, `{"action":"reassign_to_candidate"}`) + if status != http.StatusUnprocessableEntity { + t.Fatalf("resolution without a reason: status %d, want 422", status) + } + var openStatus string + if err := pool.QueryRow(ctx, `SELECT status FROM billing_identity_conflicts WHERE id=$1`, + scope.conflictID).Scan(&openStatus); err != nil { + t.Fatalf("read conflict: %v", err) + } + if openStatus != "open" { + t.Fatalf("conflict status after refused resolution: %q, want open", openStatus) + } + + status, payload := s.do(t, http.MethodPost, path, + `{"action":"reassign_to_candidate","reason":"support ticket 4412: receipts belong to the second account"}`) + if status != http.StatusOK { + t.Fatalf("resolution: status %d, want 200 (%v)", status, payload) + } + data, _ := payload["data"].(map[string]any) + if data["status"] != "resolved" || data["resolutionAction"] != billingoperator.ActionReassign { + t.Fatalf("resolved conflict: %v", data) + } + + // The lineage is unfrozen and moved to the challenger. + var frozen bool + var owner string + if err := pool.QueryRow(ctx, + `SELECT projection_frozen, COALESCE(billing_customer_id,'') FROM purchase_lineages WHERE id=$1`, + scope.lineageID).Scan(&frozen, &owner); err != nil { + t.Fatalf("read lineage: %v", err) + } + if frozen { + t.Fatal("lineage is still frozen after resolution") + } + if owner != scope.secondCustomer { + t.Fatalf("lineage owner after reassignment: %q, want %s", owner, scope.secondCustomer) + } + + // The reason is on the audit event, which is what an investigation reads. + var auditReason string + if err := pool.QueryRow(ctx, + `SELECT COALESCE(metadata->>'reason','') FROM audit_events + WHERE project_id=$1 AND action='billing.identity_conflict.resolved'`, + scope.projectID).Scan(&auditReason); err != nil { + t.Fatalf("read audit event: %v", err) + } + if !strings.Contains(auditReason, "support ticket 4412") { + t.Fatalf("audit reason %q does not record the operator's justification", auditReason) + } + + // Both candidates were reprojected. + reprojected := map[string]bool{} + for _, enqueued := range s.reprojector.scopes { + reprojected[enqueued.CustomerID] = true + } + if !reprojected[scope.firstCustomer] || !reprojected[scope.secondCustomer] { + t.Fatalf("reprojection scopes %v: both candidates must be recomputed", s.reprojector.scopes) + } +} + +// The conflict row and lineage reassignment commit before projection enqueue. +// If the queue is transiently unavailable, repeating the exact operator action +// must finish both aggregates rather than reject the already-resolved conflict +// and leave the previous customer with a stale grant. +func TestConflictResolutionRetryAfterReprojectorFailureConverges(t *testing.T) { + pool, ctx := testPool(t) + scope := seedTenant(t, ctx, pool, "resolve_retry") + s := newSurface(t, pool).as(scope.ownerActor) + s.reprojector.failures = 1 + path := "/v1/projects/" + scope.projectID + "/billing/identity-conflicts/" + scope.conflictID + "/resolution" + body := `{"action":"reassign_to_candidate","reason":"support ticket 5519: verified store ownership"}` + + status, _ := s.do(t, http.MethodPost, path, body) + if status != http.StatusServiceUnavailable { + t.Fatalf("first resolution status = %d, want 503", status) + } + status, payload := s.do(t, http.MethodPost, path, body) + if status != http.StatusOK { + t.Fatalf("retry resolution status = %d, want 200 (%v)", status, payload) + } + + reprojected := map[string]bool{} + for _, scope := range s.reprojector.scopes { + reprojected[scope.CustomerID] = true + } + if !reprojected[scope.firstCustomer] || !reprojected[scope.secondCustomer] { + t.Fatalf("retry projections = %v, want both candidates", s.reprojector.scopes) + } +} + +// Aliases are the erasable PII surface — the person-to-purchase link — and an +// alias digest is still a stable per-person identifier. Neither the raw value +// nor its digest may appear in any operator response, however the page is +// reached. +func TestOperatorResponsesNeverCarryAliasValuesOrDigests(t *testing.T) { + pool, ctx := testPool(t) + scope := seedTenant(t, ctx, pool, "privacy") + s := newSurface(t, pool).as(scope.ownerActor) + + digestHex := hexOf(billingcustomer.AliasDigest(billingcustomer.AliasApplicationUser, scope.applicationUserValue)) + installationHex := hexOf(billingcustomer.AliasDigest(billingcustomer.AliasInstallation, scope.installationValue)) + + base := "/v1/projects/" + scope.projectID + environment := base + "/environments/" + scope.environmentID + "/billing" + responses := []struct { + method, path, body string + }{ + {http.MethodGet, environment + "/customers", ""}, + {http.MethodGet, environment + "/customers/" + scope.firstCustomer, ""}, + {http.MethodPost, environment + "/customer-lookups", + `{"identifierType":"application_user_id","identifierValue":"` + scope.applicationUserValue + `"}`}, + {http.MethodGet, base + "/billing/identity-conflicts", ""}, + {http.MethodGet, base + "/billing/identity-conflicts/" + scope.conflictID, ""}, + } + for _, call := range responses { + status, body := s.raw(t, call.method, call.path, call.body) + if status != http.StatusOK { + t.Fatalf("%s %s: status %d, want 200 (%s)", call.method, call.path, status, body) + } + for _, forbidden := range []string{ + scope.applicationUserValue, scope.installationValue, digestHex, installationHex, + } { + if strings.Contains(body, forbidden) { + t.Fatalf("%s %s leaked %q into the response body", call.method, call.path, forbidden) + } + } + } +} + +func hexOf(digest []byte) string { + const alphabet = "0123456789abcdef" + out := make([]byte, 0, len(digest)*2) + for _, b := range digest { + out = append(out, alphabet[b>>4], alphabet[b&0x0f]) + } + return string(out) +} diff --git a/apps/api/internal/platform/billingoperatorpostgres/repository.go b/apps/api/internal/platform/billingoperatorpostgres/repository.go new file mode 100644 index 00000000..876cfc56 --- /dev/null +++ b/apps/api/internal/platform/billingoperatorpostgres/repository.go @@ -0,0 +1,518 @@ +// Package billingoperatorpostgres is the PostgreSQL read model behind Mosaic's +// Phase 9B operator surface. +// +// Every statement in this file is a SELECT. There is no INSERT, no UPDATE, and +// no DELETE anywhere in the package, which is what makes "the operator lookup +// cannot mint a customer" a property of the code rather than a promise about +// it: the trusted identify path is create-or-get, and reusing it as a search +// would create one Billing Customer per mistyped support query. +package billingoperatorpostgres + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingoperator" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +var _ billingoperator.Repository = (*Repository)(nil) + +// --------------------------------------------------------------------------- +// Authorization +// --------------------------------------------------------------------------- + +// AuthorizeProject resolves the actor's organization role for the Project and +// requires owner or admin. +// +// Absent membership is reported as not-found rather than forbidden, matching +// every other Mosaic surface: telling a caller that a Project exists but is not +// theirs is an existence oracle over other tenants' Projects. Billing customer +// state is the most sensitive read surface Mosaic has — it names who bought +// what — so it sits at the same role bar as the 9A ledger and quarantine pages +// rather than at plain membership. +func (r *Repository) AuthorizeProject(ctx context.Context, actor billingoperator.Actor, projectID string) error { + if strings.TrimSpace(actor.ID) == "" { + return billingoperator.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 billingoperator.ErrNotFound + } + if err != nil { + return fmt.Errorf("resolve billing operator role: %w", err) + } + switch role { + case "owner", "admin": + return nil + default: + return billingoperator.ErrForbidden + } +} + +// Authorize adds the Environment containment check. +// +// The Environment must belong to the Project named in the route. Without this, +// a member of Project A could read Project B's Environment by pairing their own +// projectId with B's environmentId — the role check would pass and every +// subsequent query, which filters on environment_id, would answer about B. +func (r *Repository) Authorize(ctx context.Context, actor billingoperator.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 billingoperator.ErrNotFound + } + if err != nil { + return fmt.Errorf("resolve billing operator environment: %w", err) + } + return nil +} + +func (r *Repository) BillingEnabled(ctx context.Context, projectID string) (bool, error) { + var enabled bool + err := r.pool.QueryRow(ctx, + `SELECT billing_enabled FROM billing_project_settings WHERE project_id = $1`, projectID).Scan(&enabled) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read billing enablement: %w", err) + } + return enabled, nil +} + +// --------------------------------------------------------------------------- +// Lookup +// --------------------------------------------------------------------------- + +// CustomerIDForAliasDigest resolves the single active alias resolution for a +// digest. It reads the same partial unique index the attach path writes +// against, so a lookup and an attachment can never disagree about who holds an +// identifier. +func (r *Repository) CustomerIDForAliasDigest(ctx context.Context, projectID, aliasType string, digest []byte) (string, error) { + var customerID string + err := r.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, aliasType, digest).Scan(&customerID) + if errors.Is(err, pgx.ErrNoRows) { + return "", billingoperator.ErrNotFound + } + if err != nil { + return "", fmt.Errorf("resolve customer for alias digest: %w", err) + } + return customerID, nil +} + +// CustomerIDForInstallationDigest resolves an installation identifier through +// association evidence. +// +// There is no alias resolution to read: an installation id is recorded as +// evidence and never anchors or selects a customer (plan §5a rule 2a, OD-4(a)). +// Reading the evidence backwards for a support lookup is a read of recorded +// history by an authorized operator, which is a different act from letting a +// client-asserted identifier select a customer at request time — and the two +// stay different because this method exists only behind the operator +// authorization above. +// +// The most recent resolving observation wins. Evidence is append-only, so an +// installation that was later seen against a different customer (a shared +// device, a reinstall) has both rows, and the newest is the one an operator is +// asking about. +func (r *Repository) CustomerIDForInstallationDigest(ctx context.Context, projectID string, digest []byte) (string, error) { + var customerID string + err := r.pool.QueryRow(ctx, + `SELECT billing_customer_id FROM billing_association_evidence + WHERE project_id=$1 AND evidence_type='installation_observation' + AND evidence_digest=$2 AND billing_customer_id IS NOT NULL + ORDER BY observed_at DESC, id + LIMIT 1`, projectID, digest).Scan(&customerID) + if errors.Is(err, pgx.ErrNoRows) { + return "", billingoperator.ErrNotFound + } + if err != nil { + return "", fmt.Errorf("resolve customer for installation evidence: %w", err) + } + return customerID, nil +} + +// --------------------------------------------------------------------------- +// Customer list and summary +// --------------------------------------------------------------------------- + +// summarySelect is the shared projection behind the list and the single +// summary, so the customer header on the detail page cannot disagree with the +// row the operator clicked. +// +// `identified` is the presence of an active application-user alias, which is +// the only alias family a person's own backend asserts. `purchase_anchored` is +// the presence of a lineage in this Environment. The two are computed +// separately because a customer can be both, either, or neither, and the ones +// that are exactly one are the interesting ones (plan §5a). +const summarySelect = ` + SELECT c.id, c.project_id, c.status, c.diagnostics_status, + c.current_projection_version, c.last_projected_at, c.created_at, c.updated_at, + EXISTS (SELECT 1 FROM billing_customer_aliases a + WHERE a.billing_customer_id = c.id AND a.project_id = c.project_id + AND a.alias_type = 'application_user_id' AND a.effective_end IS NULL) AS identified, + EXISTS (SELECT 1 FROM purchase_lineages l + WHERE l.billing_customer_id = c.id AND l.environment_id = $2) AS purchase_anchored, + EXISTS (SELECT 1 FROM billing_identity_conflicts k + WHERE k.project_id = c.project_id AND k.status = 'open' + AND (k.first_customer_id = c.id OR k.second_customer_id = c.id)) AS has_open_conflict, + (SELECT count(*) FROM purchase_lineages l + WHERE l.billing_customer_id = c.id AND l.environment_id = $2 AND l.projection_frozen) AS frozen_lineages, + p.snapshot_version, p.updated_at AS snapshot_updated_at + FROM billing_customers c + LEFT JOIN customer_entitlement_pointers p + ON p.billing_customer_id = c.id AND p.environment_id = $2` + +func scanSummary(row pgx.Row, environmentID string) (billingoperator.CustomerSummary, error) { + var summary billingoperator.CustomerSummary + err := row.Scan(&summary.ID, &summary.ProjectID, &summary.Status, &summary.DiagnosticsStatus, + &summary.CurrentProjectionVersion, &summary.LastProjectedAt, &summary.CreatedAt, &summary.UpdatedAt, + &summary.Identified, &summary.PurchaseAnchored, &summary.HasOpenConflict, &summary.FrozenLineageCount, + &summary.SnapshotVersion, &summary.SnapshotUpdatedAt) + summary.EnvironmentID = environmentID + summary.CreatedAt = summary.CreatedAt.UTC() + summary.UpdatedAt = summary.UpdatedAt.UTC() + summary.LastProjectedAt = utcOrNil(summary.LastProjectedAt) + summary.SnapshotUpdatedAt = utcOrNil(summary.SnapshotUpdatedAt) + return summary, err +} + +func (r *Repository) CustomerSummary(ctx context.Context, projectID, environmentID, customerID string) (billingoperator.CustomerSummary, error) { + summary, err := scanSummary(r.pool.QueryRow(ctx, + summarySelect+` WHERE c.id = $3 AND c.project_id = $1`, projectID, environmentID, customerID), environmentID) + if errors.Is(err, pgx.ErrNoRows) { + return billingoperator.CustomerSummary{}, billingoperator.ErrNotFound + } + if err != nil { + return billingoperator.CustomerSummary{}, fmt.Errorf("read billing customer summary: %w", err) + } + return summary, nil +} + +// ListCustomers pages the Environment's customers, newest first. +// +// The Environment predicate admits a customer that holds a pointer or a lineage +// here, and additionally a customer that holds a lineage in no Environment at +// all. That last clause is not a loophole: a customer created by a trusted +// identify and not yet party to any purchase belongs to the Project and to no +// Environment, and hiding it from every Environment list would make the +// customer an operator just created invisible. +// +// The ordering is (created_at DESC, id ASC) — exactly +// `billing_customers_project_idx` — so paging is an index scan rather than a +// sort. The keyset predicate is written out rather than as a row comparison +// because the two halves sort in opposite directions. +func (r *Repository) ListCustomers(ctx context.Context, projectID, environmentID string, + filter billingoperator.CustomerFilter, limit int, cursor string) ([]billingoperator.CustomerSummary, string, error) { + + position := decodeCursor(cursor) + identified := (*bool)(nil) + if filter.Identified != nil { + value := *filter.Identified + identified = &value + } + rows, err := r.pool.Query(ctx, summarySelect+` + WHERE c.project_id = $1 + AND (p.billing_customer_id IS NOT NULL + OR EXISTS (SELECT 1 FROM purchase_lineages l + WHERE l.billing_customer_id = c.id AND l.environment_id = $2) + OR NOT EXISTS (SELECT 1 FROM purchase_lineages l + WHERE l.billing_customer_id = c.id)) + AND ($3::text = '' OR c.status = $3) + AND ($4::boolean IS NULL OR $4::boolean = EXISTS ( + SELECT 1 FROM billing_customer_aliases a + WHERE a.billing_customer_id = c.id AND a.project_id = c.project_id + AND a.alias_type = 'application_user_id' AND a.effective_end IS NULL)) + AND (NOT $5::boolean OR EXISTS ( + SELECT 1 FROM billing_identity_conflicts k + WHERE k.project_id = c.project_id AND k.status = 'open' + AND (k.first_customer_id = c.id OR k.second_customer_id = c.id))) + AND ($6::timestamptz IS NULL + OR c.created_at < $6::timestamptz + OR (c.created_at = $6::timestamptz AND c.id > $7)) + ORDER BY c.created_at DESC, c.id + LIMIT $8`, + projectID, environmentID, filter.Status, identified, filter.ConflictedOnly, + position.At, position.ID, limit+1) + if err != nil { + return nil, "", fmt.Errorf("list billing customers: %w", err) + } + defer rows.Close() + + customers := make([]billingoperator.CustomerSummary, 0, limit) + for rows.Next() { + summary, scanErr := scanSummary(rows, environmentID) + if scanErr != nil { + return nil, "", fmt.Errorf("scan billing customer summary: %w", scanErr) + } + customers = append(customers, summary) + } + if err := rows.Err(); err != nil { + return nil, "", fmt.Errorf("read billing customers: %w", err) + } + next := "" + if len(customers) > limit { + customers = customers[:limit] + last := customers[limit-1] + next = encodeCursor(last.CreatedAt, last.ID) + } + return customers, next, nil +} + +// --------------------------------------------------------------------------- +// Customer detail components +// --------------------------------------------------------------------------- + +func (r *Repository) Lineages(ctx context.Context, projectID, environmentID, customerID string) ([]billingoperator.LineageView, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, environment_id, provider, store_environment, lineage_type, + projection_frozen, diagnostic_status, COALESCE(superseded_by_lineage_id,''), + created_at, updated_at + FROM purchase_lineages + WHERE project_id=$1 AND environment_id=$2 AND billing_customer_id=$3 + ORDER BY created_at DESC, id + LIMIT 200`, projectID, environmentID, customerID) + if err != nil { + return nil, fmt.Errorf("list customer purchase lineages: %w", err) + } + defer rows.Close() + lineages := make([]billingoperator.LineageView, 0, 4) + for rows.Next() { + var lineage billingoperator.LineageView + if err := rows.Scan(&lineage.PurchaseLineageID, &lineage.EnvironmentID, &lineage.Provider, + &lineage.StoreEnvironment, &lineage.LineageType, &lineage.ProjectionFrozen, + &lineage.DiagnosticStatus, &lineage.SupersededByLineageID, + &lineage.CreatedAt, &lineage.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan customer purchase lineage: %w", err) + } + lineage.CreatedAt, lineage.UpdatedAt = lineage.CreatedAt.UTC(), lineage.UpdatedAt.UTC() + lineages = append(lineages, lineage) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read customer purchase lineages: %w", err) + } + return lineages, nil +} + +func (r *Repository) OneTimePurchases(ctx context.Context, projectID, environmentID, customerID string) ([]billingoperator.OneTimePurchaseView, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, purchase_lineage_id, provider, COALESCE(mosaic_product_id,''), + COALESCE(provider_product_identifier,''), acquired_at, validity_state, + refund_effective_at, revocation_effective_at + FROM one_time_purchase_instances + WHERE project_id=$1 AND environment_id=$2 AND billing_customer_id=$3 + ORDER BY acquired_at DESC, id + LIMIT 200`, projectID, environmentID, customerID) + if err != nil { + return nil, fmt.Errorf("list customer one-time purchases: %w", err) + } + defer rows.Close() + purchases := make([]billingoperator.OneTimePurchaseView, 0, 4) + for rows.Next() { + var purchase billingoperator.OneTimePurchaseView + if err := rows.Scan(&purchase.InstanceID, &purchase.PurchaseLineageID, &purchase.Provider, + &purchase.MosaicProductID, &purchase.ProviderProductIdentifier, &purchase.AcquiredAt, + &purchase.ValidityState, &purchase.RefundEffectiveAt, &purchase.RevocationEffectiveAt); err != nil { + return nil, fmt.Errorf("scan customer one-time purchase: %w", err) + } + purchase.AcquiredAt = purchase.AcquiredAt.UTC() + purchase.RefundEffectiveAt = utcOrNil(purchase.RefundEffectiveAt) + purchase.RevocationEffectiveAt = utcOrNil(purchase.RevocationEffectiveAt) + purchases = append(purchases, purchase) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read customer one-time purchases: %w", err) + } + return purchases, nil +} + +// CustomerConflicts returns the conflicts this customer is party to, on either +// side. Both sides are returned because a customer is just as affected by +// losing a disputed purchase as by claiming one, and a page that showed only +// the claims would leave the losing customer's freeze unexplained. +// +// alias_digest is not selected. A column that is never read cannot leak into a +// response or a log line. +func (r *Repository) CustomerConflicts(ctx context.Context, projectID, customerID string) ([]billingoperator.ConflictView, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, project_id, conflict_scope, status, COALESCE(purchase_lineage_id,''), + COALESCE(alias_type,''), first_customer_id, second_customer_id, + COALESCE(detail->>'diagnosticCode',''), opened_at, resolved_at, + COALESCE(resolution_action,''), COALESCE(detail->>'resolutionReason','') + FROM billing_identity_conflicts + WHERE project_id=$1 AND ($2 IN (first_customer_id, second_customer_id)) + ORDER BY opened_at DESC, id + LIMIT 100`, projectID, customerID) + if err != nil { + return nil, fmt.Errorf("list customer identity conflicts: %w", err) + } + defer rows.Close() + conflicts := make([]billingoperator.ConflictView, 0, 2) + for rows.Next() { + var conflict billingoperator.ConflictView + var storedAction string + if err := rows.Scan(&conflict.ConflictID, &conflict.ProjectID, &conflict.Scope, &conflict.Status, + &conflict.PurchaseLineageID, &conflict.AliasType, &conflict.FirstCustomerID, + &conflict.SecondCustomerID, &conflict.DiagnosticCode, &conflict.OpenedAt, + &conflict.ResolvedAt, &storedAction, &conflict.ResolutionReason); err != nil { + return nil, fmt.Errorf("scan customer identity conflict: %w", err) + } + conflict.OpenedAt = conflict.OpenedAt.UTC() + conflict.ResolvedAt = utcOrNil(conflict.ResolvedAt) + conflict.ResolutionAction = billingoperator.OperatorAction(storedAction) + conflicts = append(conflicts, conflict) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read customer identity conflicts: %w", err) + } + return conflicts, nil +} + +// --------------------------------------------------------------------------- +// Restore and sync jobs +// --------------------------------------------------------------------------- + +const restoreColumns = `id, environment_id, COALESCE(billing_customer_id,''), store_platform, + status, COALESCE(outcome,''), provider_outcome, uncertainty_reason, + observed_transaction_count, pending_validation_count, + baseline_snapshot_version, snapshot_version, attempt_count, max_attempts, + requested_at, updated_at, completed_at` + +func scanRestore(row pgx.Row) (billingoperator.RestoreJobView, error) { + var job billingoperator.RestoreJobView + err := row.Scan(&job.RestoreID, &job.EnvironmentID, &job.BillingCustomerID, &job.StorePlatform, + &job.Status, &job.Outcome, &job.ProviderOutcome, &job.UncertaintyReason, + &job.ObservedTransactionCount, &job.PendingValidationCount, + &job.BaselineSnapshotVersion, &job.SnapshotVersion, &job.AttemptCount, &job.MaxAttempts, + &job.RequestedAt, &job.UpdatedAt, &job.CompletedAt) + job.RequestedAt, job.UpdatedAt = job.RequestedAt.UTC(), job.UpdatedAt.UTC() + job.CompletedAt = utcOrNil(job.CompletedAt) + return job, err +} + +func (r *Repository) ListRestoreJobs(ctx context.Context, projectID, environmentID, customerID string, + limit int, cursor string) ([]billingoperator.RestoreJobView, string, error) { + + position := decodeCursor(cursor) + rows, err := r.pool.Query(ctx, + `SELECT `+restoreColumns+` + FROM restore_sync_jobs + WHERE project_id=$1 AND environment_id=$2 + AND ($3::text = '' OR billing_customer_id = $3) + AND ($4::timestamptz IS NULL + OR requested_at < $4::timestamptz + OR (requested_at = $4::timestamptz AND id > $5)) + ORDER BY requested_at DESC, id + LIMIT $6`, projectID, environmentID, customerID, position.At, position.ID, limit+1) + if err != nil { + return nil, "", fmt.Errorf("list restore jobs: %w", err) + } + defer rows.Close() + jobs := make([]billingoperator.RestoreJobView, 0, limit) + for rows.Next() { + job, scanErr := scanRestore(rows) + if scanErr != nil { + return nil, "", fmt.Errorf("scan restore job: %w", scanErr) + } + jobs = append(jobs, job) + } + if err := rows.Err(); err != nil { + return nil, "", fmt.Errorf("read restore jobs: %w", err) + } + next := "" + if len(jobs) > limit { + jobs = jobs[:limit] + last := jobs[limit-1] + next = encodeCursor(last.RequestedAt, last.RestoreID) + } + return jobs, next, nil +} + +func (r *Repository) RestoreJob(ctx context.Context, projectID, environmentID, restoreID string) (billingoperator.RestoreJobView, error) { + job, err := scanRestore(r.pool.QueryRow(ctx, + `SELECT `+restoreColumns+` FROM restore_sync_jobs + WHERE id=$3 AND project_id=$1 AND environment_id=$2`, projectID, environmentID, restoreID)) + if errors.Is(err, pgx.ErrNoRows) { + return billingoperator.RestoreJobView{}, billingoperator.ErrNotFound + } + if err != nil { + return billingoperator.RestoreJobView{}, fmt.Errorf("read restore job: %w", err) + } + return job, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// listCursor is a keyset position: the ordering timestamp of the last row plus +// its id as the tie-break. Microsecond resolution, for the reason documented on +// billingcustomerpostgres.encodeCursor: PostgreSQL stores timestamptz at +// microsecond precision, and a coarser cursor silently drops every row created +// in the same tick as the last one on the previous page. +type listCursor struct { + At *time.Time + ID string +} + +func encodeCursor(at time.Time, id string) string { + return base64.RawURLEncoding.EncodeToString( + []byte(strconv.FormatInt(at.UTC().UnixMicro(), 10) + ":" + id)) +} + +// decodeCursor parses an opaque cursor. A malformed value yields the zero +// cursor, which starts from the beginning: a caller that mangles a cursor gets +// the first page rather than a silently truncated list. +func decodeCursor(raw string) listCursor { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return listCursor{} + } + decoded, err := base64.RawURLEncoding.DecodeString(trimmed) + if err != nil { + return listCursor{} + } + micros, id, found := strings.Cut(string(decoded), ":") + if !found || id == "" { + return listCursor{} + } + value, err := strconv.ParseInt(micros, 10, 64) + if err != nil { + return listCursor{} + } + at := time.UnixMicro(value).UTC() + return listCursor{At: &at, ID: id} +} + +func utcOrNil(value *time.Time) *time.Time { + if value == nil { + return nil + } + utc := value.UTC() + return &utc +} diff --git a/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go b/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go index a8975365..7bbd2231 100644 --- a/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go +++ b/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go @@ -49,11 +49,14 @@ func TestKeyringRotationCoversEveryEnvelopeTable(t *testing.T) { } // provider_connection_credentials is rotated by the Provider Connection - // path in cloudworkspacepostgres; the two billing tables are rotated here. + // path in cloudworkspacepostgres; the billing tables are rotated here. + // webhook_signing_secrets joined them in Phase 9B (ADR-0024) and is sealed + // under the webhook_signing_secret SubjectKind. rotatable := map[string]bool{ "provider_connection_credentials": true, "store_server_credentials": true, "billing_raw_inputs": true, + "webhook_signing_secrets": true, } for table := range found { if !rotatable[table] { @@ -773,6 +776,87 @@ func TestQuarantineListCursorWalksEveryRecord(t *testing.T) { } } +// 9A correction — a keyset cursor must not round the page boundary away. +// +// `timestamptz` is microsecond-resolution and the cursor used to encode +// milliseconds. Encoding the boundary row at `…:00.123456Z` as `…:00.123000Z` +// and then applying `(occurred_at, id) < (cursor_time, cursor_id)` excludes +// every row in the discarded sub-millisecond remainder: the page after the +// boundary silently loses rows, with a well-formed cursor and a plausible page. +// One worker pass writes several ledger entries inside the same millisecond +// routinely, so this is the ordinary case rather than a contrived one. +// +// The listing under test is the ledger because its rows need no foreign keys, +// but the encoder is shared by facts, attempts, ledger, quarantine, +// reconciliation runs, and replay jobs, so one boundary walk protects all six. +func TestLedgerListCursorKeepsSubMillisecondRows(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, _ := seed(t, ctx, pool, "microcursor") + + // Two entries inside one millisecond, straddling a page boundary of one. + base := time.Now().UTC().Truncate(time.Millisecond).Add(-time.Hour) + expected := map[string]time.Time{ + "bled_micro_late": base.Add(456 * time.Microsecond), + "bled_micro_early": base.Add(123 * time.Microsecond), + } + for id, at := range expected { + if _, err := pool.Exec(ctx, + `INSERT INTO billing_ledger_entries(id, project_id, environment_id, entry_type, + correlation_id, occurred_at) + VALUES ($1,$2,$3,'input_received',$4,$5)`, + id, projectID, environmentID, "corr-"+id, at); err != nil { + t.Fatal(err) + } + } + + var organizationID string + if err := pool.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id=$1`, projectID). + Scan(&organizationID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) + VALUES ($1,'actor_owner_microcursor','owner',$2,$2) + ON CONFLICT (organization_id,actor_id) DO UPDATE SET role=EXCLUDED.role`, + organizationID, time.Now().UTC()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cleanupContext, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _, _ = pool.Exec(cleanupContext, + `DELETE FROM organization_members WHERE organization_id=$1 AND actor_id='actor_owner_microcursor'`, + organizationID) + }) + actor := billing.Actor{ID: "actor_owner_microcursor"} + + seen := map[string]int{} + cursor := "" + for page := 0; page < 8; page++ { + result, err := repository.ListLedger(ctx, actor, projectID, environmentID, + billing.ListOptions{Limit: 1, Cursor: cursor}) + if err != nil { + t.Fatal(err) + } + for _, entry := range result.Items { + seen[entry.ID]++ + } + if result.NextCursor == "" { + break + } + cursor = result.NextCursor + } + + for id := range expected { + if seen[id] != 1 { + t.Fatalf("ledger entry %s was returned %d times across the walk, want exactly 1; "+ + "a cursor that truncates to milliseconds drops rows sharing a millisecond with "+ + "the page boundary", id, seen[id]) + } + } +} + // A cursor is opaque: callers forward it unchanged and must never construct or // parse one. A mangled or stale value must start from the beginning rather than // erroring or silently truncating — the failure mode that hides records. diff --git a/apps/api/internal/platform/billingpostgres/jobs.go b/apps/api/internal/platform/billingpostgres/jobs.go index d5f1af70..7ff9012b 100644 --- a/apps/api/internal/platform/billingpostgres/jobs.go +++ b/apps/api/internal/platform/billingpostgres/jobs.go @@ -2,6 +2,8 @@ package billingpostgres import ( "context" + "encoding/hex" + "encoding/json" "errors" "fmt" "time" @@ -9,6 +11,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" ) // LeaseValidationJob claims one job with SELECT ... FOR UPDATE SKIP LOCKED, @@ -219,34 +222,14 @@ func (r *Repository) CompleteAttempt(ctx context.Context, job billing.Validation factRecorded := false if fact := outcome.Fact; fact != nil { - tag, err := tx.Exec(ctx, - `INSERT INTO billing_transaction_facts( - id, project_id, environment_id, environment_mode, application_id, provider, store_environment, - provider_transaction_id, provider_original_transaction_id, purchase_chain_digest, - supersedes_chain_digest, transaction_type, fact_kind, occurred_at, period_start_at, - period_end_at, revoked_at, refunded_at, renewal_expected, is_test_transaction, - provider_product_identifier, provider_base_plan_identifier, provider_offer_identifier, - resolution_state, mosaic_product_id, provider_product_mapping_id, resolved_mapping_version, - validator_version, fact_version, source_raw_input_id, validation_attempt_id, fact_digest, recorded_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NULLIF($9,''),$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, - $21,NULLIF($22,''),NULLIF($23,''),$24,NULLIF($25,''),NULLIF($26,''),$27,$28,$29,$30,$31,$32,$33) - ON CONFLICT (environment_id, fact_digest) DO NOTHING`, - fact.ID, fact.ProjectID, fact.EnvironmentID, fact.EnvironmentMode, fact.ApplicationID, - fact.Provider, fact.StoreEnvironment, fact.ProviderTransactionID, - fact.ProviderOriginalTransactionID, nullBytes(fact.PurchaseChainDigest), - nullBytes(fact.SupersedesChainDigest), fact.TransactionType, fact.FactKind, fact.OccurredAt, - fact.PeriodStartAt, fact.PeriodEndAt, fact.RevokedAt, fact.RefundedAt, fact.RenewalExpected, - fact.IsTestTransaction, fact.ProviderProductIdentifier, fact.ProviderBasePlanIdentifier, - fact.ProviderOfferIdentifier, fact.ResolutionState, fact.MosaicProductID, - fact.ProviderProductMappingID, fact.ResolvedMappingVersion, fact.ValidatorVersion, - fact.FactVersion, fact.SourceRawInputID, fact.ValidationAttemptID, fact.FactDigest, fact.RecordedAt) + recorded, err := insertFact(ctx, tx, *fact) if err != nil { - return fmt.Errorf("append transaction fact: %w", err) + return err } // Zero rows means the identical fact already exists. That is the replay // no-op, and it is recorded as a deduplication rather than silently // dropped so the ledger shows the pipeline ran and found nothing new. - factRecorded = tag.RowsAffected() == 1 + factRecorded = recorded if !factRecorded { if err := insertLedger(ctx, tx, billing.LedgerEntry{ ID: "ble_" + hashID(fact.SourceRawInputID, "dedup", now), ProjectID: fact.ProjectID, @@ -259,6 +242,27 @@ func (r *Repository) CompleteAttempt(ctx context.Context, job billing.Validation } } + // A supersession fact rides in the same transaction as the state fact it + // was derived from, and is recorded in the ledger only when this write is + // the first observation of the link. + if fact := outcome.Supersession; fact != nil { + recorded, err := insertFact(ctx, tx, *fact) + if err != nil { + return err + } + if recorded { + if err := insertLedger(ctx, tx, billing.LedgerEntry{ + ID: "ble_" + hashID(fact.SourceRawInputID, "supersession", now), ProjectID: fact.ProjectID, + EnvironmentID: fact.EnvironmentID, EntryType: billing.LedgerFactRecorded, + RawInputID: fact.SourceRawInputID, ValidationAttemptID: attempt.ID, + TransactionFactID: fact.ID, + CorrelationID: attempt.CorrelationID, OccurredAt: now, + }); err != nil { + return err + } + } + } + for _, entry := range outcome.Ledger { if entry.EntryType == billing.LedgerFactRecorded { if !factRecorded { @@ -290,6 +294,32 @@ func (r *Repository) CompleteAttempt(ctx context.Context, job billing.Validation } } + // A committed fact enqueues its projection in the same transaction that + // records it. Enqueueing after the commit would leave a window in which a + // crash loses the trigger and the fact never reaches anyone's access; + // enqueueing inside means the trigger is exactly as durable as the fact. + // + // The job is scoped to the lineage the fact belongs to. It coalesces onto + // the scope key, so a burst of facts for one purchase produces one + // projection rather than one per fact. + // + // The lineage and its projection instance are materialized first, in this + // same transaction. Nothing used to create either, so the trigger below + // found no lineage and treated that as silence — a fact reached no + // projection, and the whole Phase 9B read model was unreachable from a + // 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. + if factRecorded && outcome.Fact != nil { + lineage, err := materializeLineage(ctx, tx, *outcome.Fact, now) + if err != nil { + return err + } + if err := enqueueProjectionForFact(ctx, tx, *outcome.Fact, lineage, now); err != nil { + return err + } + } + status := outcome.JobStatus if status == "" { status = "completed" @@ -312,6 +342,248 @@ func (r *Repository) CompleteAttempt(ctx context.Context, job billing.Validation return nil } +// materializeLineage creates the Purchase Lineage a newly recorded fact belongs +// to, and the projection instance that lineage owns. +// +// Three rules are load-bearing here. +// +// *The lineage is keyed on the chain root, not on the fact's own digest.* A +// Google plan change hands the subscription a new purchase token and states the +// old one as `linkedPurchaseToken`; the fact for the successor therefore carries +// a different `purchase_chain_digest` from its predecessor. Keying on it would +// mint a fresh lineage on every plan change and fragment one subscription's +// history into unconnected pieces — and the projection loader would not put it +// back together, because it walks supersession edges *forward from the root*. +// The root is resolved here by walking those edges backwards. +// +// *The digest domain is the fact's own.* Every fact-to-lineage join in the +// codebase compares `purchase_chain_digest` to `lineage_key_digest`, so any +// other domain produces a lineage that can never join to the facts it was +// created for. +// +// *Neither write may disturb an existing row.* ON CONFLICT DO NOTHING on both, +// because a lineage's customer association and an instance's projection state +// are owned by other writers, and a fact arriving is not new information about +// either. +func materializeLineage(ctx context.Context, tx pgx.Tx, fact billing.TransactionFact, now time.Time) (lineage materializedLineage, err error) { + if len(fact.PurchaseChainDigest) == 0 { + return materializedLineage{}, nil + } + rootDigest, err := chainRootDigest(ctx, tx, fact) + if err != nil { + return materializedLineage{}, err + } + + lineageType := billingcustomer.LineageSubscription + if fact.TransactionType == billing.TypeNonConsumable { + lineageType = billingcustomer.LineageOneTime + } + // The identifier is derived rather than random so a retry of this + // transaction proposes the same row and the unique constraint recognises it. + lineageID := "bpl_" + hashID(fact.EnvironmentID, fact.Provider, hex.EncodeToString(rootDigest)) + if _, err := tx.Exec(ctx, + `INSERT INTO purchase_lineages( + id, project_id, environment_id, environment_mode, application_id, provider, + store_environment, lineage_key_digest, lineage_type, projection_frozen, + diagnostic_status, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,false,'identity_unresolved',$10,$10) + ON CONFLICT (environment_id, provider, lineage_key_digest) DO NOTHING`, + lineageID, fact.ProjectID, fact.EnvironmentID, fact.EnvironmentMode, fact.ApplicationID, + fact.Provider, fact.StoreEnvironment, rootDigest, lineageType, now); err != nil { + return materializedLineage{}, fmt.Errorf("materialize purchase lineage: %w", err) + } + + // Re-read rather than trusting the proposed id: another transaction may have + // created this lineage first, under its own identifier. + var resolvedID, customerID string + if err := tx.QueryRow(ctx, + `SELECT id, COALESCE(billing_customer_id,'') FROM purchase_lineages + WHERE environment_id=$1 AND provider=$2 AND lineage_key_digest=$3`, + fact.EnvironmentID, fact.Provider, rootDigest).Scan(&resolvedID, &customerID); err != nil { + return materializedLineage{}, fmt.Errorf("read materialized purchase lineage: %w", err) + } + lineage = materializedLineage{ + ID: resolvedID, CustomerID: customerID, RootDigest: rootDigest, Type: lineageType, + } + + acquiredAt := fact.OccurredAt + if fact.PeriodStartAt != nil && fact.PeriodStartAt.Before(acquiredAt) { + acquiredAt = *fact.PeriodStartAt + } + if lineageType == billingcustomer.LineageOneTime { + if _, err := tx.Exec(ctx, + `INSERT INTO one_time_purchase_instances( + id, project_id, environment_id, application_id, purchase_lineage_id, provider, + acquired_at, validity_state, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,'owned',$8,$8) + ON CONFLICT (purchase_lineage_id) DO NOTHING`, + "otp_"+hashID(resolvedID, "instance", ""), fact.ProjectID, fact.EnvironmentID, + fact.ApplicationID, resolvedID, fact.Provider, acquiredAt, now); err != nil { + return materializedLineage{}, fmt.Errorf("materialize one-time purchase instance: %w", err) + } + return lineage, nil + } + if _, err := tx.Exec(ctx, + `INSERT INTO subscription_instances( + id, project_id, environment_id, application_id, purchase_lineage_id, provider, + created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$7) + ON CONFLICT (purchase_lineage_id) DO NOTHING`, + "sbi_"+hashID(resolvedID, "instance", ""), fact.ProjectID, fact.EnvironmentID, + fact.ApplicationID, resolvedID, fact.Provider, now); err != nil { + return materializedLineage{}, fmt.Errorf("materialize subscription instance: %w", err) + } + return lineage, nil +} + +// materializedLineage is what the fact-commit transaction learned about the +// lineage it just ensured exists. +type materializedLineage struct { + ID string + CustomerID string + RootDigest []byte + Type string +} + +// chainRootDigest walks supersession edges backwards from a fact's own chain +// digest to the root of its purchase chain. +// +// The walk is bounded and cycle-safe for the same reason the pure helper in the +// identity module is: provider data cannot contain a cycle, so reaching one +// means the data is already wrong and the safe answer is the deepest node +// reached rather than a hang. +func chainRootDigest(ctx context.Context, tx pgx.Tx, fact billing.TransactionFact) ([]byte, error) { + var root []byte + err := tx.QueryRow(ctx, + `WITH RECURSIVE walk(digest, depth) AS ( + SELECT $3::bytea, 0 + UNION ALL + SELECT f.supersedes_chain_digest, walk.depth + 1 + FROM walk + JOIN LATERAL ( + SELECT supersedes_chain_digest + FROM billing_transaction_facts + WHERE project_id = $1 AND environment_id = $2 + AND purchase_chain_digest = walk.digest + AND supersedes_chain_digest IS NOT NULL + AND supersedes_chain_digest <> walk.digest + LIMIT 1 + ) f ON true + WHERE walk.depth < 32 + ) + SELECT digest FROM walk ORDER BY depth DESC LIMIT 1`, + fact.ProjectID, fact.EnvironmentID, fact.PurchaseChainDigest).Scan(&root) + if err != nil { + return nil, fmt.Errorf("walk purchase chain root: %w", err) + } + if len(root) == 0 { + return fact.PurchaseChainDigest, nil + } + return root, nil +} + +// enqueueProjectionForFact queues a projection for the lineage a newly +// recorded fact belongs to. +// +// The lineage always exists by the time this runs: materializeLineage created +// it a few statements earlier, in this same transaction. An unassociated lineage +// still enqueues — at lineage scope — so the subscription state advances while +// the identity half of the seam is still deciding who owns it. +func enqueueProjectionForFact(ctx context.Context, tx pgx.Tx, fact billing.TransactionFact, + lineage materializedLineage, now time.Time) error { + + if lineage.ID == "" { + // A fact with no provider chain digest names no purchase chain, so there + // is nothing to project. Nothing else reaches here now that the lineage + // is materialized in this same transaction — before it was, a missing + // lineage was the ordinary case and this function was silence (defect + // D-1). + return nil + } + + // A customer snapshot may only ever be minted from *all* of the customer's + // lineages, so a job that names a customer must never also name a lineage + // (defect D-4). + // + // The detail used to carry both. `loadLineages` filters on the lineage when + // one is present, while `Compute` branches on the customer being present and + // mints a full customer aggregate — so committing a fact on one of a + // customer's lineages rewrote their authoritative snapshot from that lineage + // alone. Every other Entitlement Source vanished and any Entitlement that + // depended on one flipped to inactive: no refund, no revocation, no expiry, + // just sources that were never loaded. A customer holding a subscription and + // a lifetime purchase lost the lifetime purchase on the subscription's next + // renewal. + // + // The two scopes are now disjoint. A resolved lineage enqueues customer + // scope and nothing else; an unresolved one enqueues lineage scope, which + // advances the subscription state and mints no customer snapshot at all. + // That also restores the coalescing index's meaning: `customer:…` and + // `lineage:…` keys can no longer stand for two different amounts of work. + scopeKey := "lineage:" + lineage.ID + detail := map[string]string{"lineageId": lineage.ID} + if lineage.CustomerID != "" { + scopeKey = "customer:" + lineage.CustomerID + detail = map[string]string{"customerId": lineage.CustomerID} + } + encodedDetail, err := json.Marshal(detail) + if err != nil { + return fmt.Errorf("encode projection job detail: %w", err) + } + // ON CONFLICT DO NOTHING against the scope-key partial unique index is the + // coalescing: a scope that already has queued or leased work absorbs this + // trigger rather than creating a second job. + if _, err := tx.Exec(ctx, + `INSERT INTO projection_jobs( + id, project_id, environment_id, scope_key, kind, detail, status, + attempt_count, max_attempts, available_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,'fact_committed',$5,'queued',0,8,$6,$6,$6) + ON CONFLICT DO NOTHING`, + "pjb_"+hashID(scopeKey, "fact_committed", fact.ID), fact.ProjectID, fact.EnvironmentID, + scopeKey, encodedDetail, now); err != nil { + return fmt.Errorf("enqueue projection for committed fact: %w", err) + } + return nil +} + +// insertFact appends one Transaction Fact, reporting whether the row was new. +// The ON CONFLICT target is the fact-identity constraint, so a recomputed +// identical fact is a structural no-op. +func insertFact(ctx context.Context, tx pgx.Tx, fact billing.TransactionFact) (bool, error) { + tag, err := tx.Exec(ctx, + `INSERT INTO billing_transaction_facts( + id, project_id, environment_id, environment_mode, application_id, provider, store_environment, + provider_transaction_id, provider_original_transaction_id, purchase_chain_digest, + supersedes_chain_digest, transaction_type, fact_kind, occurred_at, period_start_at, + period_end_at, revoked_at, refunded_at, renewal_expected, is_test_transaction, + provider_product_identifier, provider_base_plan_identifier, provider_offer_identifier, + resolution_state, mosaic_product_id, provider_product_mapping_id, resolved_mapping_version, + validator_version, fact_version, source_raw_input_id, validation_attempt_id, fact_digest, recorded_at, + grace_period_expires_at, billing_retry_active, auto_renew_product_identifier, is_upgraded, + revocation_reason, refund_type, in_app_ownership_type, subscription_group_identifier, + provider_event_occurred_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NULLIF($9,''),$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, + $21,NULLIF($22,''),NULLIF($23,''),$24,NULLIF($25,''),NULLIF($26,''),$27,$28,$29,$30,$31,$32,$33, + $34,$35,NULLIF($36,''),$37,$38,NULLIF($39,''),NULLIF($40,''),NULLIF($41,''),$42) + ON CONFLICT (environment_id, fact_digest) DO NOTHING`, + fact.ID, fact.ProjectID, fact.EnvironmentID, fact.EnvironmentMode, fact.ApplicationID, + fact.Provider, fact.StoreEnvironment, fact.ProviderTransactionID, + fact.ProviderOriginalTransactionID, nullBytes(fact.PurchaseChainDigest), + nullBytes(fact.SupersedesChainDigest), fact.TransactionType, fact.FactKind, fact.OccurredAt, + fact.PeriodStartAt, fact.PeriodEndAt, fact.RevokedAt, fact.RefundedAt, fact.RenewalExpected, + fact.IsTestTransaction, fact.ProviderProductIdentifier, fact.ProviderBasePlanIdentifier, + fact.ProviderOfferIdentifier, fact.ResolutionState, fact.MosaicProductID, + fact.ProviderProductMappingID, fact.ResolvedMappingVersion, fact.ValidatorVersion, + fact.FactVersion, fact.SourceRawInputID, fact.ValidationAttemptID, fact.FactDigest, fact.RecordedAt, + fact.GracePeriodExpiresAt, fact.BillingRetryActive, fact.AutoRenewProductIdentifier, + fact.IsUpgraded, fact.RevocationReason, fact.RefundType, fact.InAppOwnershipType, + fact.SubscriptionGroupIdentifier, fact.ProviderEventOccurredAt) + if err != nil { + return false, fmt.Errorf("append transaction fact: %w", err) + } + return tag.RowsAffected() == 1, nil +} + // MappingCandidates returns every mapping in scope regardless of status. // // Filtering by status in SQL would hide exactly the rows the resolver needs: @@ -419,3 +691,24 @@ 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 ddbf9f52..7934db8f 100644 --- a/apps/api/internal/platform/billingpostgres/keyring.go +++ b/apps/api/internal/platform/billingpostgres/keyring.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/Mujhtech/mosaic/apps/api/internal/billing" "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" ) @@ -52,7 +53,9 @@ func (r *Repository) EnvelopeCountsByKeyID(ctx context.Context) (map[string]int6 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_raw_inputs WHERE body_state = 'stored' AND key_id IS NOT NULL GROUP BY key_id`) + SELECT key_id, count(*) FROM billing_raw_inputs WHERE body_state = 'stored' AND key_id IS NOT NULL GROUP BY key_id + UNION ALL + SELECT key_id, count(*) FROM webhook_signing_secrets WHERE status = 'active' GROUP BY key_id`) if err != nil { return nil, fmt.Errorf("count billing envelopes: %w", err) } @@ -107,6 +110,40 @@ func (r *Repository) EnvelopesNotUnderKey(ctx context.Context, keyID string, lim 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. + secretRows, err := r.pool.Query(ctx, + `SELECT s.id, p.organization_id, s.project_id, s.envelope_version, s.algorithm, s.key_id, + s.nonce, s.ciphertext, s.fingerprint + FROM webhook_signing_secrets s + JOIN projects p ON p.id = s.project_id + WHERE s.key_id <> $1 AND s.status = 'active' ORDER BY s.id LIMIT $2`, + keyID, limit-len(envelopes)) + if err != nil { + return nil, fmt.Errorf("read webhook signing secret envelopes: %w", err) + } + for secretRows.Next() { + envelope := BillingEnvelope{ + Table: "webhook_signing_secrets", SubjectKind: providercredential.SubjectWebhookSigningSecret, + CredentialClass: billing.ClassWebhookSigningSecret, + } + if err := secretRows.Scan(&envelope.RowID, &envelope.OrganizationID, &envelope.ProjectID, + &envelope.Version, &envelope.Algorithm, &envelope.KeyID, &envelope.Nonce, + &envelope.Ciphertext, &envelope.Fingerprint); err != nil { + secretRows.Close() + return nil, fmt.Errorf("scan webhook signing secret envelope: %w", err) + } + envelopes = append(envelopes, envelope) + } + secretRows.Close() + if err := secretRows.Err(); err != nil { + return nil, fmt.Errorf("read webhook signing secret envelopes: %w", err) + } + if len(envelopes) >= limit { + return envelopes, nil + } + remaining := limit - len(envelopes) bodyRows, err := r.pool.Query(ctx, `SELECT id, organization_id, project_id, envelope_version, algorithm, key_id, nonce, ciphertext, fingerprint @@ -161,6 +198,13 @@ func (r *Repository) ReplaceEnvelopes(ctx context.Context, envelopes []BillingEn SET envelope_version=$2, algorithm=$3, key_id=$4, nonce=$5, ciphertext=$6, fingerprint=$7, envelope_rotated_at=$8 WHERE id=$1` + case "webhook_signing_secrets": + // $8 is accepted and discarded so every branch shares one argument + // list; the table records no rotation timestamp of its own. + 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` default: return fmt.Errorf("unsupported billing envelope table %q", envelope.Table) } diff --git a/apps/api/internal/platform/billingpostgres/queries.go b/apps/api/internal/platform/billingpostgres/queries.go index fa59b8ae..b86f346e 100644 --- a/apps/api/internal/platform/billingpostgres/queries.go +++ b/apps/api/internal/platform/billingpostgres/queries.go @@ -71,11 +71,26 @@ type listCursor struct { // // It is opaque on purpose: callers forward it unchanged and must not construct // or parse it, so the ordering key can change without a client change. The -// encoding is base64url over ":" rather than JSON, because it +// encoding is base64url over ":" rather than JSON, because it // travels in a query string. +// +// Microseconds, not milliseconds, and that is the whole point of this comment. +// PostgreSQL `timestamptz` has microsecond resolution, so a millisecond cursor +// rounds the boundary row's timestamp down and the next page's +// `(ordering_time, id) < (cursor_time, cursor_id)` predicate then excludes every +// row whose real timestamp falls in the discarded sub-millisecond remainder — +// silently, with a well-formed cursor and a plausible-looking page. Facts, +// attempts, and ledger entries written by one worker pass routinely arrive +// inside the same millisecond, so this was not a theoretical boundary. The +// Phase 9B customer listing already encoded microseconds; this is the same +// encoding applied to the Phase 9A listings that shipped before it (9A +// correction). A cursor minted by the previous build and presented across the +// deploy decodes to a 1970 position and yields an empty page — a paging session +// held open across a deploy restarts, which is the safe direction: it can show +// nothing, never the wrong rows. func encodeCursor(at time.Time, id string) string { return base64.RawURLEncoding.EncodeToString( - []byte(strconv.FormatInt(at.UTC().UnixMilli(), 10) + ":" + id)) + []byte(strconv.FormatInt(at.UTC().UnixMicro(), 10) + ":" + id)) } // decodeCursor parses an opaque cursor. A malformed or stale value yields the @@ -90,15 +105,15 @@ func decodeCursor(raw string) listCursor { if err != nil { return listCursor{} } - millis, id, found := strings.Cut(string(decoded), ":") + micros, id, found := strings.Cut(string(decoded), ":") if !found || id == "" { return listCursor{} } - value, err := strconv.ParseInt(millis, 10, 64) + value, err := strconv.ParseInt(micros, 10, 64) if err != nil { return listCursor{} } - at := time.UnixMilli(value).UTC() + at := time.UnixMicro(value).UTC() return listCursor{At: &at, ID: id} } @@ -117,7 +132,11 @@ func (r *Repository) ListFacts(ctx context.Context, actor billing.Actor, project COALESCE(provider_offer_identifier,''), resolution_state, COALESCE(mosaic_product_id,''), COALESCE(provider_product_mapping_id,''), resolved_mapping_version, validator_version, fact_version, - source_raw_input_id, validation_attempt_id, recorded_at + source_raw_input_id, validation_attempt_id, recorded_at, + grace_period_expires_at, billing_retry_active, + COALESCE(auto_renew_product_identifier,''), is_upgraded, revocation_reason, + COALESCE(refund_type,''), COALESCE(in_app_ownership_type,''), + COALESCE(subscription_group_identifier,''), provider_event_occurred_at FROM billing_transaction_facts WHERE environment_id=$1 AND ($2::timestamptz IS NULL OR (occurred_at, id) < ($2::timestamptz, $3)) @@ -141,7 +160,10 @@ func (r *Repository) ListFacts(ctx context.Context, actor billing.Actor, project &fact.ProviderBasePlanIdentifier, &fact.ProviderOfferIdentifier, &fact.ResolutionState, &fact.MosaicProductID, &fact.ProviderProductMappingID, &fact.ResolvedMappingVersion, &fact.ValidatorVersion, &fact.FactVersion, &fact.SourceRawInputID, - &fact.ValidationAttemptID, &fact.RecordedAt); err != nil { + &fact.ValidationAttemptID, &fact.RecordedAt, + &fact.GracePeriodExpiresAt, &fact.BillingRetryActive, &fact.AutoRenewProductIdentifier, + &fact.IsUpgraded, &fact.RevocationReason, &fact.RefundType, &fact.InAppOwnershipType, + &fact.SubscriptionGroupIdentifier, &fact.ProviderEventOccurredAt); err != nil { return billing.Page[billing.TransactionFact]{}, fmt.Errorf("scan transaction fact: %w", err) } items = append(items, fact) diff --git a/apps/api/internal/platform/billingprojectionpostgres/loader_integration_test.go b/apps/api/internal/platform/billingprojectionpostgres/loader_integration_test.go new file mode 100644 index 00000000..8f89c215 --- /dev/null +++ b/apps/api/internal/platform/billingprojectionpostgres/loader_integration_test.go @@ -0,0 +1,587 @@ +package billingprojectionpostgres + +import ( + "context" + "crypto/sha256" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +// These tests cover the parts of the projection that live in SQL rather than in +// Go: which lineages and facts a projection is allowed to read, and whether two +// projections of one customer can interleave. A unit test with a fake +// repository passes while every one of them is broken, because the thing under +// test is the query. + +// fixture builds a tenant with two Environments, an Application, a Product with +// a granted Entitlement, and the ingestion rows a Transaction Fact requires. +type fixture struct { + pool *pgxpool.Pool + ctx context.Context + organization string + project string + production string + staging string + application string + product string + entitlement string + mapping string + grantVersion string + customer string + now time.Time +} + +func newFixture(t *testing.T, suffix string) fixture { + t.Helper() + pool, ctx := testPool(t) + f := fixture{ + pool: pool, ctx: ctx, + organization: "org_load_" + suffix, + project: "proj_load_" + suffix, + production: "env_load_prod_" + suffix, + staging: "env_load_stage_" + suffix, + application: "app_load_" + suffix, + product: "prod_load_" + suffix, + entitlement: "ent_load_" + suffix, + mapping: "ppm_load_" + suffix, + grantVersion: "pegv_load_" + suffix, + customer: "bcu_load_" + suffix, + now: time.Now().UTC(), + } + f.clean() + t.Cleanup(func() { + f.ctx = context.Background() + f.clean() + }) + + f.exec(t, `INSERT INTO organizations(id,name,created_at,updated_at) VALUES ($1,'Loader',$2,$2) + ON CONFLICT (id) DO NOTHING`, f.organization, f.now) + f.exec(t, `INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES ($1,$2,$3,'Loader','active',$4,$4) ON CONFLICT (id) DO NOTHING`, + f.project, f.organization, "loader-"+suffix, f.now) + f.exec(t, `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`, + f.production, f.project, f.now) + f.exec(t, `INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) + VALUES ($1,$2,'staging','Staging','staging',$3,$3) ON CONFLICT (id) DO NOTHING`, + f.staging, f.project, f.now) + f.exec(t, `INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES ($1,$2,'Loader','ios',$3,$4,$4) ON CONFLICT (id) DO NOTHING`, + f.application, f.project, "com.mosaic.loader."+suffix, f.now) + f.exec(t, `INSERT INTO products(id,project_id,key,internal_name,description,type,status, + metadata_source,readiness_ready,created_at,updated_at) + VALUES ($1,$2,$3,'Pro','','subscription','connected','mock',true,$4,$4) + ON CONFLICT (id) DO NOTHING`, f.product, f.project, "pro-"+suffix, f.now) + f.exec(t, `INSERT INTO entitlements(id,project_id,key,name,description,created_at,updated_at) + VALUES ($1,$2,$3,'Pro','',$4,$4) ON CONFLICT (id) DO NOTHING`, + f.entitlement, f.project, "pro-"+suffix, f.now) + f.exec(t, `INSERT INTO provider_product_mappings( + id, project_id, product_id, application_id, provider, + provider_product_identifier, platform, status, created_at, updated_at) + VALUES ($1,$2,$3,$4,'app_store','com.mosaic.pro','ios','placeholder',$5,$5) + ON CONFLICT (id) DO NOTHING`, f.mapping, f.project, f.product, f.application, f.now) + f.exec(t, `INSERT INTO product_entitlement_grant_versions( + id, project_id, product_id, entitlement_id, version, effective_start, created_at) + VALUES ($1,$2,$3,$4,1,$5,$5) ON CONFLICT (id) DO NOTHING`, + f.grantVersion, f.project, f.product, f.entitlement, f.now.Add(-365*24*time.Hour)) + f.exec(t, `INSERT INTO billing_project_settings(project_id,billing_enabled,updated_by_actor_id,created_at,updated_at) + VALUES ($1,true,'loader',$2,$2) ON CONFLICT (project_id) DO UPDATE SET billing_enabled=true`, + f.project, f.now) + f.exec(t, `INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) + VALUES ($1,$2,'active','none',$3,$3) ON CONFLICT (id) DO NOTHING`, + f.customer, f.project, f.now) + return f +} + +func (f fixture) exec(t *testing.T, query string, args ...any) { + t.Helper() + if _, err := f.pool.Exec(f.ctx, query, args...); err != nil { + t.Fatalf("seed %q: %v", query[:min(48, len(query))], err) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func (f fixture) clean() { + for _, statement := range []string{ + `ALTER TABLE billing_transaction_facts DISABLE TRIGGER USER`, + `ALTER TABLE billing_validation_attempts DISABLE TRIGGER USER`, + `ALTER TABLE billing_raw_inputs DISABLE TRIGGER USER`, + `ALTER TABLE customer_entitlement_snapshots DISABLE TRIGGER customer_entitlement_snapshots_append_only`, + `ALTER TABLE customer_entitlement_snapshot_entries DISABLE TRIGGER customer_entitlement_snapshot_entries_append_only`, + `ALTER TABLE entitlement_sources DISABLE TRIGGER entitlement_sources_append_only`, + `ALTER TABLE subscription_snapshots DISABLE TRIGGER subscription_snapshots_append_only`, + `ALTER TABLE subscription_snapshot_facts DISABLE TRIGGER subscription_snapshot_facts_append_only`, + `ALTER TABLE subscription_timeline_entries DISABLE TRIGGER USER`, + `ALTER TABLE webhook_events DISABLE TRIGGER webhook_events_append_only`, + } { + _, _ = f.pool.Exec(f.ctx, statement) + } + for _, statement := range []string{ + `DELETE FROM webhook_events WHERE project_id=$1`, + `DELETE FROM projection_attempts WHERE project_id=$1`, + `DELETE FROM projection_jobs WHERE project_id=$1`, + `DELETE FROM projection_checkpoints WHERE project_id=$1`, + `DELETE FROM entitlement_sources WHERE project_id=$1`, + `DELETE FROM customer_entitlement_snapshot_entries WHERE project_id=$1`, + `DELETE FROM customer_entitlement_pointers WHERE project_id=$1`, + `DELETE FROM customer_entitlement_snapshots WHERE project_id=$1`, + `DELETE FROM subscription_timeline_entries WHERE project_id=$1`, + `UPDATE subscription_instances SET current_snapshot_id=NULL WHERE project_id=$1`, + `DELETE FROM subscription_snapshot_facts WHERE snapshot_id IN (SELECT id FROM subscription_snapshots WHERE project_id=$1)`, + `DELETE FROM subscription_snapshots WHERE project_id=$1`, + `DELETE FROM subscription_instances WHERE project_id=$1`, + `DELETE FROM one_time_purchase_instances WHERE project_id=$1`, + `DELETE FROM purchase_lineages WHERE project_id=$1`, + `DELETE FROM billing_transaction_facts WHERE project_id=$1`, + `DELETE FROM billing_ledger_entries WHERE project_id=$1`, + `DELETE FROM billing_product_resolutions WHERE project_id=$1`, + `DELETE FROM billing_validation_attempts WHERE project_id=$1`, + `DELETE FROM billing_raw_inputs WHERE project_id=$1`, + `DELETE FROM product_entitlement_grant_versions WHERE project_id=$1`, + `DELETE FROM provider_product_mappings WHERE project_id=$1`, + `DELETE FROM billing_customers WHERE project_id=$1`, + `DELETE FROM billing_project_settings WHERE project_id=$1`, + `DELETE FROM audit_events WHERE project_id=$1`, + } { + _, _ = f.pool.Exec(f.ctx, statement, f.project) + } + for _, statement := range []string{ + `ALTER TABLE billing_transaction_facts ENABLE TRIGGER USER`, + `ALTER TABLE billing_validation_attempts ENABLE TRIGGER USER`, + `ALTER TABLE billing_raw_inputs ENABLE TRIGGER USER`, + `ALTER TABLE customer_entitlement_snapshots ENABLE TRIGGER customer_entitlement_snapshots_append_only`, + `ALTER TABLE customer_entitlement_snapshot_entries ENABLE TRIGGER customer_entitlement_snapshot_entries_append_only`, + `ALTER TABLE entitlement_sources ENABLE TRIGGER entitlement_sources_append_only`, + `ALTER TABLE subscription_snapshots ENABLE TRIGGER subscription_snapshots_append_only`, + `ALTER TABLE subscription_snapshot_facts ENABLE TRIGGER subscription_snapshot_facts_append_only`, + `ALTER TABLE subscription_timeline_entries ENABLE TRIGGER USER`, + `ALTER TABLE webhook_events ENABLE TRIGGER webhook_events_append_only`, + } { + _, _ = f.pool.Exec(f.ctx, statement) + } +} + +func digestOf(value string) []byte { + sum := sha256.Sum256([]byte(value)) + return sum[:] +} + +// lineage seeds a subscription lineage and its instance in one Environment. +func (f fixture) lineage(t *testing.T, id, environmentID, mode, storeEnvironment, chainKey string) string { + t.Helper() + f.exec(t, `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 ($1,$2,$3,$4,$5,'app_store',$6,$7,'subscription',$8,$9,$9)`, + id, f.project, environmentID, mode, f.application, storeEnvironment, + digestOf(chainKey), f.customer, f.now) + instanceID := "sub_" + id + f.exec(t, `INSERT INTO subscription_instances( + id, project_id, environment_id, application_id, purchase_lineage_id, + billing_customer_id, provider, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,'app_store',$7,$7)`, + instanceID, f.project, environmentID, f.application, id, f.customer, f.now) + return instanceID +} + +// fact seeds one validated Transaction Fact together with the raw input and +// validation attempt its foreign keys require. +func (f fixture) fact(t *testing.T, id, environmentID, mode, storeEnvironment, chainKey, supersedes, kind string, start, end time.Time) { + t.Helper() + inputID, attemptID := "bri_"+id, "bva_"+id + f.exec(t, `INSERT INTO billing_raw_inputs( + id, project_id, organization_id, environment_id, environment_mode, provider, + source, source_authority, idempotency_key, content_digest, body_state, + authentication_result, store_environment, ingestion_status, correlation_id, + received_at, expires_at) + VALUES ($1,$2,$3,$4,$5,'app_store','apple_notification','store_notification', + $6,$7,'not_retained','verified_signature',$8,'accepted','loader',$9,$10)`, + inputID, f.project, f.organization, environmentID, mode, + digestOf("idem-"+id), digestOf("content-"+id), storeEnvironment, f.now, f.now.Add(time.Hour)) + f.exec(t, `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 ($1,$2,$3,$4,1,2,$5,$5,'validated',false,$6,1,'loader')`, + attemptID, f.project, environmentID, inputID, f.now, storeEnvironment) + + var supersedesDigest any + if supersedes != "" { + supersedesDigest = digestOf(supersedes) + } + f.exec(t, `INSERT INTO billing_transaction_facts( + id, project_id, environment_id, environment_mode, application_id, provider, + store_environment, provider_transaction_id, purchase_chain_digest, + supersedes_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 ($1,$2,$3,$4,$5,'app_store',$6,$7,$8,$9,'auto_renewable_subscription',$10,$11, + $12,$13,'com.mosaic.pro','active_mapping',$14,$15,1,2,$16,$17,$18,$11)`, + id, f.project, environmentID, mode, f.application, storeEnvironment, id, + digestOf(chainKey), supersedesDigest, kind, start, start, end, + f.product, f.mapping, inputID, attemptID, digestOf("fact-"+id)) +} + +// A lineage in another Environment must contribute nothing to a projection. +// +// Before Environment scoping, input selection filtered by Project alone: a +// staging purchase fed the production snapshot. That is not a cosmetic leak — +// Apple sandbox transactions are free and self-service, so it is a route to +// production entitlement anyone with a sandbox account can take. +func TestProjectionInputIsEnvironmentScoped(t *testing.T) { + f := newFixture(t, "envscope") + repository := New(f.pool) + + start := f.now.Add(-24 * time.Hour) + end := f.now.Add(24 * time.Hour) + f.lineage(t, "plin_prod", f.production, "production", "production", "chain-production") + f.fact(t, "btf_prod", f.production, "production", "production", "chain-production", "", "initial_purchase", start, end) + + f.lineage(t, "plin_stage", f.staging, "staging", "sandbox", "chain-staging") + f.fact(t, "btf_stage", f.staging, "staging", "sandbox", "chain-staging", "", "initial_purchase", start, end) + + input, err := repository.LoadInput(f.ctx, billingprojection.Scope{ + ProjectID: f.project, EnvironmentID: f.production, CustomerID: f.customer, + }) + if err != nil { + t.Fatalf("load production input: %v", err) + } + if len(input.Lineages) != 1 { + t.Fatalf("production projection loaded %d lineages, want only the production one", len(input.Lineages)) + } + if input.Lineages[0].LineageID != "plin_prod" { + t.Fatalf("production projection loaded lineage %q", input.Lineages[0].LineageID) + } + for _, fact := range input.Lineages[0].Facts { + if fact.ID != "btf_prod" { + t.Fatalf("production lineage loaded fact %q from another Environment", fact.ID) + } + } + + output := billingprojection.Compute(input, f.now) + if output.CustomerSnapshot == nil { + t.Fatal("production projection produced no snapshot") + } + for _, source := range output.CustomerSnapshot.Sources { + if source.PurchaseLineageID != "plin_prod" { + t.Fatalf("production snapshot cites source from lineage %q", source.PurchaseLineageID) + } + } +} + +// A Google purchase-token handover is one lineage keyed on its chain root, so +// the successor token's facts must load into the predecessor's lineage and the +// subscription must stay active. Reading the supersession edge with the +// opposite sign made every live Play plan change project as inactive. +func TestChainSuccessorFactsLoadIntoTheRootLineage(t *testing.T) { + f := newFixture(t, "chain") + repository := New(f.pool) + + rootStart := f.now.Add(-48 * time.Hour) + rootEnd := f.now.Add(-24 * time.Hour) + successorEnd := f.now.Add(24 * time.Hour) + + f.lineage(t, "plin_root", f.production, "production", "production", "chain-root") + f.fact(t, "btf_root", f.production, "production", "production", "chain-root", "", "initial_purchase", rootStart, rootEnd) + // The successor token carries its own chain digest and names the root as + // the chain it supersedes — exactly what the Google validator writes. + f.fact(t, "btf_edge", f.production, "production", "production", "chain-successor", "chain-root", + "purchase_superseded", rootEnd, rootEnd) + f.fact(t, "btf_successor", f.production, "production", "production", "chain-successor", "chain-root", + "renewal", rootEnd, successorEnd) + + input, err := repository.LoadInput(f.ctx, billingprojection.Scope{ + ProjectID: f.project, EnvironmentID: f.production, CustomerID: f.customer, + }) + if err != nil { + t.Fatalf("load input: %v", err) + } + if len(input.Lineages) != 1 { + t.Fatalf("loaded %d lineages, want the single root-keyed lineage", len(input.Lineages)) + } + if got := len(input.Lineages[0].Facts); got != 3 { + t.Fatalf("root lineage loaded %d facts, want all three in the chain", got) + } + + output := billingprojection.Compute(input, f.now) + if output.CustomerSnapshot == nil || len(output.CustomerSnapshot.Entries) != 1 { + t.Fatalf("chain projection produced %+v", output.CustomerSnapshot) + } + if state := output.CustomerSnapshot.Entries[0].State; state != billingprojection.AccessActive { + t.Fatalf("live successor after a token handover projected %q, want active", state) + } +} + +// A trigger arriving while a projection is already leased must create its own +// queued job. Coalescing onto leased work absorbed it, so a fact committed +// after the running job read its input waited for an unrelated later trigger — +// which, for an expiration or a refund, may never come. +func TestTriggerDuringLeasedProjectionQueuesNewWork(t *testing.T) { + f := newFixture(t, "coalesce2") + repository := New(f.pool) + scope := billingprojection.Scope{ + ProjectID: f.project, EnvironmentID: f.production, CustomerID: f.customer, + } + + if err := repository.Enqueue(f.ctx, scope, "fact_committed", f.now); err != nil { + t.Fatalf("enqueue: %v", err) + } + job, leased, err := repository.LeaseJob(f.ctx, "worker-a", f.now, f.now.Add(time.Minute)) + if err != nil || !leased { + t.Fatalf("lease: leased=%v err=%v", leased, err) + } + + // A fact commits while that job is running. + if err := repository.Enqueue(f.ctx, scope, "fact_committed", f.now.Add(time.Second)); err != nil { + t.Fatalf("enqueue during lease: %v", err) + } + + var queued int + if err := f.pool.QueryRow(f.ctx, + `SELECT count(*) FROM projection_jobs WHERE scope_key=$1 AND status='queued'`, + scope.Key()).Scan(&queued); err != nil { + t.Fatal(err) + } + if queued != 1 { + t.Fatalf("a trigger during a leased projection produced %d queued jobs, want one", queued) + } + + // Duplicate triggers still coalesce onto that one queued job. + if err := repository.Enqueue(f.ctx, scope, "fact_committed", f.now.Add(2*time.Second)); err != nil { + t.Fatalf("second enqueue during lease: %v", err) + } + if err := f.pool.QueryRow(f.ctx, + `SELECT count(*) FROM projection_jobs WHERE scope_key=$1 AND status='queued'`, + scope.Key()).Scan(&queued); err != nil { + t.Fatal(err) + } + if queued != 1 { + t.Fatalf("duplicate triggers produced %d queued jobs, want one coalesced job", queued) + } + _ = job +} + +// Two genuinely concurrent projections of one customer must serialize. The +// advisory lock is what makes the read-compute-commit sequence atomic across +// workers; without it both would read the same version, both would compute from +// the same facts, and one would silently overwrite the other's snapshot. +func TestConcurrentCustomerProjectionsSerialize(t *testing.T) { + f := newFixture(t, "concurrent") + repository := New(f.pool) + scope := billingprojection.Scope{ + ProjectID: f.project, EnvironmentID: f.production, CustomerID: f.customer, + } + + start := f.now.Add(-24 * time.Hour) + end := f.now.Add(24 * time.Hour) + f.lineage(t, "plin_conc", f.production, "production", "production", "chain-concurrent") + f.fact(t, "btf_conc", f.production, "production", "production", "chain-concurrent", "", "initial_purchase", start, end) + + service := billingprojection.NewService(repository) + + var wait sync.WaitGroup + results := make([]error, 2) + for index := range results { + wait.Add(1) + go func(slot int) { + defer wait.Done() + _, err := service.Project(context.Background(), scope, "") + results[slot] = err + }(index) + } + wait.Wait() + + // Exactly one snapshot may exist for the customer: either the second + // projection lost the compare-and-swap, or it read the first's committed + // state and found nothing to change. Both are correct; two snapshots at the + // same version, or a version gap, are not. + var snapshots int + if err := f.pool.QueryRow(f.ctx, + `SELECT count(*) FROM customer_entitlement_snapshots + WHERE billing_customer_id=$1 AND environment_id=$2`, + f.customer, f.production).Scan(&snapshots); err != nil { + t.Fatal(err) + } + if snapshots != 1 { + t.Fatalf("two concurrent projections produced %d snapshots, want exactly one", snapshots) + } + + var version, pointerVersion int64 + if err := f.pool.QueryRow(f.ctx, + `SELECT max(snapshot_version) FROM customer_entitlement_snapshots + WHERE billing_customer_id=$1 AND environment_id=$2`, + f.customer, f.production).Scan(&version); err != nil { + t.Fatal(err) + } + if err := f.pool.QueryRow(f.ctx, + `SELECT snapshot_version FROM customer_entitlement_pointers + WHERE billing_customer_id=$1 AND environment_id=$2`, + f.customer, f.production).Scan(&pointerVersion); err != nil { + t.Fatal(err) + } + if version != 1 || pointerVersion != 1 { + t.Fatalf("snapshot version %d and pointer version %d, want both at 1", version, pointerVersion) + } + for _, err := range results { + if err != nil && err != billingprojection.ErrVersionConflict { + t.Fatalf("concurrent projection failed with an unexpected error: %v", err) + } + } +} + +// oneTimeLineage seeds a non-consumable lineage and its instance. +func (f fixture) oneTimeLineage(t *testing.T, id, environmentID, mode, storeEnvironment, chainKey string) string { + t.Helper() + f.exec(t, `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 ($1,$2,$3,$4,$5,'app_store',$6,$7,'one_time',$8,$9,$9)`, + id, f.project, environmentID, mode, f.application, storeEnvironment, + digestOf(chainKey), f.customer, f.now) + instanceID := "otp_" + id + f.exec(t, `INSERT INTO one_time_purchase_instances( + id, project_id, environment_id, application_id, purchase_lineage_id, + billing_customer_id, provider, acquired_at, validity_state, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,'app_store',$7,'owned',$8,$8)`, + instanceID, f.project, environmentID, f.application, id, f.customer, + f.now.Add(-24*time.Hour), f.now) + return instanceID +} + +// oneTimeFact seeds one validated non-consumable purchase fact. +func (f fixture) oneTimeFact(t *testing.T, id, environmentID, mode, storeEnvironment, chainKey string, acquired time.Time) { + t.Helper() + inputID, attemptID := "bri_"+id, "bva_"+id + f.exec(t, `INSERT INTO billing_raw_inputs( + id, project_id, organization_id, environment_id, environment_mode, provider, + source, source_authority, idempotency_key, content_digest, body_state, + authentication_result, store_environment, ingestion_status, correlation_id, + received_at, expires_at) + VALUES ($1,$2,$3,$4,$5,'app_store','apple_notification','store_notification', + $6,$7,'not_retained','verified_signature',$8,'accepted','loader',$9,$10)`, + inputID, f.project, f.organization, environmentID, mode, + digestOf("idem-"+id), digestOf("content-"+id), storeEnvironment, f.now, f.now.Add(time.Hour)) + f.exec(t, `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 ($1,$2,$3,$4,1,2,$5,$5,'validated',false,$6,1,'loader')`, + attemptID, f.project, environmentID, inputID, f.now, storeEnvironment) + f.exec(t, `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, + 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 ($1,$2,$3,$4,$5,'app_store',$6,$7,$8,'non_consumable','one_time_purchase',$9,$9, + 'com.mosaic.lifetime','active_mapping',$10,$11,1,2,$12,$13,$14,$9)`, + id, f.project, environmentID, mode, f.application, storeEnvironment, id, + digestOf(chainKey), acquired, f.product, f.mapping, inputID, attemptID, digestOf("fact-"+id)) +} + +// A fact on one of a customer's lineages must never revoke the others. +// +// This is demonstration 5 of the Phase 9B integrated demonstration, reduced to +// its failing core (defect D-4). A customer holds a subscription and a lifetime +// non-consumable, both granting the same Entitlement. A fact commits on the +// subscription lineage. The job that trigger writes used to name *both* the +// customer and that one lineage: `loadLineages` filtered to the lineage while +// `Compute` still minted a full customer aggregate, so the lifetime purchase +// disappeared from the snapshot and the Entitlement read `inactive` while an +// unrefunded lifetime purchase sat in the database still marked `owned`. +// +// Nothing about that failure is loud. No error is raised, no constraint is +// violated, and the customer simply loses access they paid for — which is why +// this test asserts the source set and the entitlement state rather than the +// absence of an error. +func TestFactOnOneLineageDoesNotRevokeTheCustomersOthers(t *testing.T) { + f := newFixture(t, "d4revoke") + repository := New(f.pool) + service := billingprojection.NewService(repository) + + subscription := "bpl_d4_sub" + lifetime := "bpl_d4_life" + f.lineage(t, subscription, f.production, "production", "production", "chain-sub") + f.oneTimeLineage(t, lifetime, f.production, "production", "production", "chain-life") + f.fact(t, "btf_d4_sub", f.production, "production", "production", "chain-sub", "", + "initial_purchase", f.now.Add(-30*24*time.Hour), f.now.Add(30*24*time.Hour)) + f.oneTimeFact(t, "btf_d4_life", f.production, "production", "production", "chain-life", + f.now.Add(-20*24*time.Hour)) + + // The job exactly as the fact-commit trigger writes it: customer scope, and + // no lineage in the detail. + f.exec(t, `INSERT INTO projection_jobs( + id, project_id, environment_id, scope_key, kind, detail, status, + attempt_count, max_attempts, available_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,'fact_committed',$5,'queued',0,8,$6,$6,$6)`, + "pjb_d4", f.project, f.production, "customer:"+f.customer, + `{"customerId":"`+f.customer+`"}`, f.now) + + // Lease until this fixture's own job comes up. The queue is global, and + // other packages in the same database leave their own jobs behind; leasing + // blind would assert against whichever one happened to be oldest. + var job billingprojection.Job + for range 32 { + leased, ok, err := repository.LeaseJob(f.ctx, "worker", f.now, f.now.Add(time.Minute)) + if err != nil { + t.Fatalf("lease projection job: %v", err) + } + if !ok { + t.Fatal("this fixture's projection job was never leased") + } + if leased.ProjectID == f.project { + job = leased + break + } + } + if job.ID == "" { + t.Fatal("this fixture's projection job was never leased") + } + if scope := job.Scope(); scope.LineageID != "" { + t.Fatalf("a customer-scoped job carried lineage %q; the aggregate would be "+ + "recomputed from one source and the rest silently revoked", scope.LineageID) + } + + if _, err := service.Project(f.ctx, job.Scope(), job.ID); err != nil { + t.Fatalf("project: %v", err) + } + + var sources int + var state string + if err := f.pool.QueryRow(f.ctx, + `SELECT count(*) FROM entitlement_sources s + JOIN customer_entitlement_pointers p + ON p.current_snapshot_id = s.customer_entitlement_snapshot_id + WHERE p.billing_customer_id=$1 AND p.environment_id=$2`, + f.customer, f.production).Scan(&sources); err != nil { + t.Fatalf("read entitlement sources: %v", err) + } + if err := f.pool.QueryRow(f.ctx, + `SELECT e.state FROM customer_entitlement_snapshot_entries e + JOIN customer_entitlement_pointers p + ON p.current_snapshot_id = e.customer_entitlement_snapshot_id + WHERE p.billing_customer_id=$1 AND p.environment_id=$2`, + f.customer, f.production).Scan(&state); err != nil { + t.Fatalf("read entitlement entry: %v", err) + } + if sources != 2 { + t.Fatalf("entitlement sources = %d, want 2 (the subscription and the lifetime purchase)", sources) + } + if state != "active" { + t.Fatalf("entitlement state = %q, want active", state) + } +} diff --git a/apps/api/internal/platform/billingprojectionpostgres/projection_integration_test.go b/apps/api/internal/platform/billingprojectionpostgres/projection_integration_test.go new file mode 100644 index 00000000..abd4fe8c --- /dev/null +++ b/apps/api/internal/platform/billingprojectionpostgres/projection_integration_test.go @@ -0,0 +1,262 @@ +package billingprojectionpostgres + +import ( + "context" + "database/sql" + "os" + "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/billingprojection" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +func scopeFor(projectID, environmentID, customerID string) billingprojection.Scope { + return billingprojection.Scope{ + ProjectID: projectID, EnvironmentID: environmentID, CustomerID: customerID, + } +} + +// These tests cover the guarantees that live in the 9B schema rather than in +// Go: the alias uniqueness that stops one identity resolving to two customers, +// the append-only snapshot protection, the pointer's one-per-(customer, +// environment) shape, and the job coalescing that keeps a fact burst from +// becoming a job storm. A unit test with a fake repository would pass while +// every one of them was broken, because the thing under test is the database. + +func testPool(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + 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(), 60*time.Second) + t.Cleanup(cancel) + + db, err := sql.Open("pgx", databaseURL) + 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.Fatalf("apply migrations: %v", err) + } + _ = db.Close() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + return pool, ctx +} + +// seed builds the minimum tenant a projection needs. +func seed(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) (projectID, environmentID, customerID string) { + t.Helper() + now := time.Now().UTC() + organizationID := "org_proj_" + suffix + projectID = "proj_proj_" + suffix + environmentID = "env_proj_" + suffix + customerID = "bcu_proj_" + suffix + + cleanup(t, ctx, pool, projectID) + statements := []struct { + query string + args []any + }{ + {`INSERT INTO organizations(id,name,created_at,updated_at) VALUES ($1,$2,$3,$3) + ON CONFLICT (id) DO NOTHING`, []any{organizationID, "Projection Test", now}}, + {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES ($1,$2,$3,'Projection','active',$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{projectID, organizationID, "projection-" + suffix, 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{environmentID, projectID, now}}, + {`INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) + VALUES ($1,$2,'active','none',$3,$3) ON CONFLICT (id) DO NOTHING`, + []any{customerID, projectID, now}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatalf("seed: %v", err) + } + } + t.Cleanup(func() { cleanup(t, context.Background(), pool, projectID) }) + return projectID, environmentID, customerID +} + +func cleanup(t *testing.T, ctx context.Context, pool *pgxpool.Pool, projectID string) { + t.Helper() + for _, statement := range []string{ + `ALTER TABLE customer_entitlement_snapshots DISABLE TRIGGER customer_entitlement_snapshots_append_only`, + } { + _, _ = pool.Exec(ctx, statement) + } + for _, statement := range []string{ + `DELETE FROM projection_jobs 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_customer_aliases WHERE project_id=$1`, + `DELETE FROM billing_customers WHERE project_id=$1`, + } { + _, _ = pool.Exec(ctx, statement, projectID) + } + _, _ = pool.Exec(ctx, + `ALTER TABLE customer_entitlement_snapshots ENABLE TRIGGER customer_entitlement_snapshots_append_only`) +} + +// One alias value must never resolve to two active customers in one Project. +// Without the partial unique index, a concurrent login could attach the same +// person to two customers and each would hold half their purchases. +func TestAliasHasOneActiveResolution(t *testing.T) { + pool, ctx := testPool(t) + projectID, _, customerID := seed(t, ctx, pool, "alias") + now := time.Now().UTC() + + second := customerID + "_b" + if _, err := pool.Exec(ctx, + `INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) + VALUES ($1,$2,'active','none',$3,$3)`, second, projectID, now); err != nil { + t.Fatal(err) + } + + digest := make([]byte, 32) + insert := func(id, customer string) error { + _, err := 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,'application_user_id',$4,'trusted_server','verified',$5,$5)`, + id, projectID, customer, digest, now) + return err + } + if err := insert("bca_first", customerID); err != nil { + t.Fatalf("first alias rejected: %v", err) + } + if err := insert("bca_second", second); err == nil { + t.Fatal("the same alias resolved to two active customers") + } + + // End-dating the first must free the digest, so a legitimate reassignment + // through the accepted workflow still works. + if _, err := pool.Exec(ctx, + `UPDATE billing_customer_aliases SET effective_end=$1 WHERE id='bca_first'`, now); err != nil { + t.Fatal(err) + } + if err := insert("bca_third", second); err != nil { + t.Fatalf("reassignment after end-dating was rejected: %v", err) + } +} + +// Customer entitlement snapshots are immutable. If they were not, a bug or an +// operator could rewrite what a customer's access was without leaving a trace. +func TestCustomerSnapshotsAreAppendOnly(t *testing.T) { + pool, ctx := testPool(t) + projectID, environmentID, customerID := seed(t, ctx, pool, "immutable") + now := time.Now().UTC() + + 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_immutable',$1,$2,$3,1,1,$4,$4,$5,'entitlements_changed',$4)`, + projectID, environmentID, customerID, now, checksum); err != nil { + t.Fatal(err) + } + + if _, err := pool.Exec(ctx, + `UPDATE customer_entitlement_snapshots SET change_reason='rewritten' WHERE id='ces_immutable'`); err == nil { + t.Fatal("a committed customer entitlement snapshot was updated") + } + if _, err := pool.Exec(ctx, + `DELETE FROM customer_entitlement_snapshots WHERE id='ces_immutable'`); err == nil { + t.Fatal("a committed customer entitlement snapshot was deleted") + } +} + +// 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. +func TestOnePointerPerCustomerPerEnvironment(t *testing.T) { + pool, ctx := testPool(t) + projectID, environmentID, customerID := seed(t, ctx, pool, "pointer") + now := time.Now().UTC() + checksum := make([]byte, 32) + + for version := 1; version <= 2; version++ { + 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,'entitlements_changed',$6)`, + "ces_pointer_"+string(rune('0'+version)), projectID, environmentID, customerID, + version, now, checksum); err != nil { + t.Fatal(err) + } + } + + upsert := func(snapshotID string, version int64) error { + _, 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,$4,$5,$6) + ON CONFLICT (billing_customer_id, environment_id) DO UPDATE SET + current_snapshot_id=EXCLUDED.current_snapshot_id, + snapshot_version=EXCLUDED.snapshot_version, updated_at=EXCLUDED.updated_at`, + projectID, environmentID, customerID, snapshotID, version, now) + return err + } + if err := upsert("ces_pointer_1", 1); err != nil { + t.Fatal(err) + } + if err := upsert("ces_pointer_2", 2); err != nil { + t.Fatal(err) + } + + var count int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM customer_entitlement_pointers + WHERE billing_customer_id=$1 AND environment_id=$2`, customerID, environmentID). + Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("got %d pointers for one (customer, environment), want exactly one", count) + } +} + +// 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. +func TestProjectionJobsCoalesceOnScopeKey(t *testing.T) { + pool, ctx := testPool(t) + projectID, environmentID, customerID := seed(t, ctx, pool, "coalesce") + repository := New(pool) + scope := scopeFor(projectID, environmentID, customerID) + + for range 5 { + if err := repository.Enqueue(ctx, scope, "fact_committed", time.Now().UTC()); err != nil { + t.Fatalf("enqueue: %v", err) + } + } + + var queued int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM projection_jobs + WHERE scope_key=$1 AND status IN ('queued','leased')`, scope.Key()).Scan(&queued); err != nil { + t.Fatal(err) + } + if queued != 1 { + t.Fatalf("five triggers produced %d queued jobs, want one coalesced job", queued) + } +} diff --git a/apps/api/internal/platform/billingprojectionpostgres/repository.go b/apps/api/internal/platform/billingprojectionpostgres/repository.go new file mode 100644 index 00000000..ad72faa1 --- /dev/null +++ b/apps/api/internal/platform/billingprojectionpostgres/repository.go @@ -0,0 +1,1023 @@ +// Package billingprojectionpostgres is the PostgreSQL implementation of the +// billing projection persistence port. +// +// The whole point of this package is one guarantee: a projection command +// writes everything or nothing. A consumer must never observe a new +// subscription state without its matching entitlement state, an entitlement +// state without its source links, a current pointer aimed at an incomplete +// snapshot, or a webhook event announcing state that was not committed. That +// is why Commit takes the entire planned output and executes it inside one +// transaction, under one advisory lock, guarded by a compare-and-swap. +package billingprojectionpostgres + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +var _ billingprojection.Repository = (*Repository)(nil) + +func (r *Repository) BillingEnabled(ctx context.Context, projectID string) (bool, error) { + var enabled bool + err := r.pool.QueryRow(ctx, + `SELECT billing_enabled FROM billing_project_settings WHERE project_id = $1`, projectID). + Scan(&enabled) + if errors.Is(err, pgx.ErrNoRows) { + // Off by default: a Project that never opted in holds no billing state. + return false, nil + } + if err != nil { + return false, fmt.Errorf("read billing enablement: %w", err) + } + return enabled, nil +} + +// LoadInput reads everything one projection command needs. +// +// It runs in its own transaction and takes the scope's advisory lock first, so +// two workers cannot both read the same pre-state and then both commit. The +// lock is released when this transaction ends; Commit takes it again. That is +// deliberate — holding one lock across both would mean holding it while the +// pure engines run, and the engines are the only part that could ever become +// slow. +// +// The compare-and-swap on current_projection_version is what makes the gap +// safe: if another worker commits in between, Commit's CAS loses and the job +// retries against fresh input rather than overwriting. +func (r *Repository) LoadInput(ctx context.Context, scope billingprojection.Scope) (billingprojection.Input, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingprojection.Input{}, fmt.Errorf("begin projection read: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, scope.LockScope()); err != nil { + return billingprojection.Input{}, fmt.Errorf("acquire projection lock: %w", err) + } + + input := billingprojection.Input{Scope: scope} + if scope.CustomerID != "" { + err := tx.QueryRow(ctx, + `SELECT current_projection_version FROM billing_customers WHERE id=$1 AND project_id=$2`, + scope.CustomerID, scope.ProjectID).Scan(&input.CurrentProjectionVersion) + if errors.Is(err, pgx.ErrNoRows) { + return billingprojection.Input{}, billingprojection.ErrNotFound + } + if err != nil { + return billingprojection.Input{}, fmt.Errorf("read customer projection version: %w", err) + } + if err := loadCustomerSnapshot(ctx, tx, scope, &input); err != nil { + return billingprojection.Input{}, err + } + } + + if err := loadLineages(ctx, tx, scope, &input); err != nil { + return billingprojection.Input{}, err + } + if err := loadGrantVersions(ctx, tx, scope, &input); err != nil { + return billingprojection.Input{}, err + } + return input, nil +} + +// loadCustomerSnapshot reads the committed snapshot the candidate is compared +// against. Its entries and sources are both needed: the checksum covers both, +// because "pro is active for a different reason than yesterday" is a real +// change even though the entry alone looks identical. +func loadCustomerSnapshot(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, input *billingprojection.Input) error { + var snapshotID string + err := tx.QueryRow(ctx, + `SELECT current_snapshot_id, snapshot_version FROM customer_entitlement_pointers + 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 + } + if err != nil { + return fmt.Errorf("read customer entitlement pointer: %w", err) + } + + snapshot := billingprojection.CustomerSnapshot{} + rows, 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_id`, snapshotID) + if err != nil { + return fmt.Errorf("read snapshot entries: %w", err) + } + defer rows.Close() + for rows.Next() { + var entry billingprojection.EntitlementEntry + if err := rows.Scan(&entry.EntitlementID, &entry.EntitlementKey, &entry.State, + &entry.EffectiveStart, &entry.EffectiveEnd, &entry.EndKnown, &entry.SourceCount, + &entry.UncertaintyReason, &entry.IsTestSource, &entry.ExplanationCode); err != nil { + return fmt.Errorf("scan snapshot entry: %w", err) + } + snapshot.Entries = append(snapshot.Entries, entry) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("read snapshot entries: %w", err) + } + + // The entitlement key is deliberately not read here: it does not + // participate in the source's checksum contribution, so reading it would + // add a join that cannot change the comparison. + sourceRows, err := tx.Query(ctx, + `SELECT entitlement_id, purchase_lineage_id, product_id, grant_version_id, + source_type, source_state, source_start, source_end, end_known + FROM entitlement_sources WHERE customer_entitlement_snapshot_id=$1`, snapshotID) + if err != nil { + return fmt.Errorf("read entitlement sources: %w", err) + } + defer sourceRows.Close() + for sourceRows.Next() { + var source billingprojection.EntitlementSource + if err := sourceRows.Scan(&source.EntitlementID, &source.PurchaseLineageID, &source.ProductID, + &source.GrantVersionID, &source.SourceType, &source.SourceState, + &source.SourceStart, &source.SourceEnd, &source.EndKnown); err != nil { + return fmt.Errorf("scan entitlement source: %w", err) + } + snapshot.Sources = append(snapshot.Sources, source) + } + if err := sourceRows.Err(); err != nil { + return fmt.Errorf("read entitlement sources: %w", err) + } + + // The committed checksum is authoritative rather than recomputed: a + // recomputation here would compare the current code's opinion against + // itself and never detect a rule-version difference. + if err := tx.QueryRow(ctx, + `SELECT checksum FROM customer_entitlement_snapshots WHERE id=$1`, snapshotID). + Scan(&snapshot.Checksum); err != nil { + return fmt.Errorf("read snapshot checksum: %w", err) + } + input.PriorCustomerSnapshot = &snapshot + return nil +} + +// loadLineages reads the lineages in scope, their instances, their checkpoints, +// and every validated fact that belongs to them. +// +// Two scoping rules are load-bearing here: +// +// Environment. Everything that holds Phase 9B state is Environment-scoped +// (OD-3(b)), and Apple's chain digest is only unique per (store environment, +// original transaction id) — so two sandbox Environments in one Project share +// digests. Selecting input by Project alone would let a staging lineage +// contribute a source to a production snapshot. +// +// Chain root. A lineage is keyed on the *root* of the provider chain (plan +// §5): Apple's original transaction id, Google's purchase token walked +// backwards through linkedPurchaseToken. Facts carry their own token digest, +// so the fact set for a lineage is the transitive closure of supersession +// edges forward from the root, not the rows that happen to name the root. +// +// Nothing here joins by Product, customer, or time window. +func loadLineages(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, input *billingprojection.Input) error { + rows, err := tx.Query(ctx, + `SELECT l.id, l.lineage_type, l.lineage_key_digest, l.projection_frozen, + l.billing_customer_id IS NOT NULL, COALESCE(l.billing_customer_id, ''), + l.superseded_by_lineage_id IS NOT NULL, + COALESCE(si.id, oi.id, ''), COALESCE(si.current_snapshot_id, ''), + COALESCE(c.high_watermark, ''), c.checksum, COALESCE(c.facts_projected, 0) + FROM purchase_lineages l + LEFT JOIN subscription_instances si ON si.purchase_lineage_id = l.id + LEFT JOIN one_time_purchase_instances oi ON oi.purchase_lineage_id = l.id + LEFT JOIN projection_checkpoints c + ON c.subscription_instance_id = si.id OR c.one_time_purchase_instance_id = oi.id + WHERE l.project_id = $1 + AND l.environment_id = $2 + AND ($3::text = '' OR l.billing_customer_id = $3) + AND ($4::text = '' OR l.id = $4)`, + scope.ProjectID, scope.EnvironmentID, scope.CustomerID, scope.LineageID) + if err != nil { + return fmt.Errorf("read purchase lineages: %w", err) + } + defer rows.Close() + + type pending struct { + lineage billingprojection.LineageInput + digest []byte + } + pendings := make([]pending, 0, 4) + for rows.Next() { + var item pending + var checkpointChecksum []byte + var customerID string + if err := rows.Scan(&item.lineage.LineageID, &item.lineage.Type, &item.digest, + &item.lineage.Frozen, &item.lineage.CustomerResolved, &customerID, + &item.lineage.SupersededByLineage, + &item.lineage.InstanceID, &item.lineage.SnapshotID, &item.lineage.Checkpoint, + &checkpointChecksum, &item.lineage.CheckpointFacts); err != nil { + return fmt.Errorf("scan purchase lineage: %w", err) + } + item.lineage.CheckpointChecksum = checkpointChecksum + // A lineage-scoped command whose lineage has acquired a customer since + // the job was queued escalates to customer scope rather than minting + // anything at customer level itself (defect D-4). + if scope.CustomerID == "" && customerID != "" && input.EscalateToCustomerID == "" { + input.EscalateToCustomerID = customerID + } + pendings = append(pendings, item) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("read purchase lineages: %w", err) + } + + // A customer projection reads every lineage the customer owns, so it must + // hold those lineages' locks too. Without them a lineage-scoped projection + // — the path a fact takes before its customer association exists — could + // commit a subscription snapshot in the middle of the customer aggregate + // that reads it. The locks are taken in sorted order after the scope lock + // is already held, so no cycle is possible. + lineageIDs := make([]string, 0, len(pendings)) + for _, item := range pendings { + lineageIDs = append(lineageIDs, item.lineage.LineageID) + } + if err := lockLineages(ctx, tx, scope, lineageIDs); err != nil { + return err + } + + for _, item := range pendings { + facts, err := loadFacts(ctx, tx, scope.ProjectID, scope.EnvironmentID, item.digest) + if err != nil { + return err + } + item.lineage.Facts = facts + // Frozen and unresolved lineages are counted by Compute as it walks + // them. Counting here as well double-counted every one of them. + input.Lineages = append(input.Lineages, item.lineage) + } + return nil +} + +// loadFacts reads every validated fact belonging to one lineage in one +// Environment. +// +// The recursive term walks supersession edges *forward* from the lineage root: +// a fact whose `supersedes_chain_digest` is already in the chain contributes +// its own `purchase_chain_digest` to it. That direction is the correction to +// the defect that read the edge with the opposite sign — `supersedes_chain_digest +// IS NOT NULL` says "this fact's chain replaced something", which every +// successor fact says for its whole life, so every live Google successor was +// projected superseded and inactive. +// +// UNION rather than UNION ALL terminates on a cycle: provider data cannot +// contain one, so reaching a repeat means the data is already wrong and +// stopping is safer than looping. +// lockLineages takes the per-lineage advisory locks a scope reads, skipping the +// one the scope already holds. Ordering is deterministic so two customer +// projections that overlap on a lineage queue rather than deadlock. +func lockLineages(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, lineageIDs []string) error { + if len(lineageIDs) == 0 { + return nil + } + names := make([]string, 0, len(lineageIDs)) + for _, lineageID := range lineageIDs { + name := "billing-projection:lineage:" + lineageID + if name == scope.LockScope() { + continue + } + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, name); err != nil { + return fmt.Errorf("acquire lineage projection lock: %w", err) + } + } + return nil +} + +func loadFacts(ctx context.Context, tx pgx.Tx, projectID, environmentID string, chainDigest []byte) ([]billingprojection.Fact, error) { + if len(chainDigest) == 0 { + return nil, nil + } + rows, err := tx.Query(ctx, + `WITH RECURSIVE chain(digest) AS ( + SELECT $3::bytea + UNION + SELECT f.purchase_chain_digest + FROM billing_transaction_facts f + JOIN chain c ON f.supersedes_chain_digest = c.digest + WHERE f.project_id = $1 AND f.environment_id = $2 + AND f.purchase_chain_digest IS NOT NULL + ) + SELECT id, provider, provider_transaction_id, fact_kind, transaction_type, + occurred_at, provider_event_occurred_at, recorded_at, + period_start_at, period_end_at, grace_period_expires_at, revoked_at, refunded_at, + renewal_expected, billing_retry_active, is_upgraded, revocation_reason, + COALESCE(refund_type,''), COALESCE(auto_renew_product_identifier,''), + COALESCE(in_app_ownership_type,''), COALESCE(subscription_group_identifier,''), + COALESCE(mosaic_product_id,''), resolution_state, is_test_transaction + FROM billing_transaction_facts + WHERE project_id=$1 AND environment_id=$2 + AND purchase_chain_digest IN (SELECT digest FROM chain)`, + projectID, environmentID, chainDigest) + if err != nil { + return nil, fmt.Errorf("read transaction facts: %w", err) + } + defer rows.Close() + + facts := make([]billingprojection.Fact, 0, 8) + for rows.Next() { + var fact billingprojection.Fact + if err := rows.Scan(&fact.ID, &fact.Provider, &fact.ProviderTransactionID, &fact.FactKind, + &fact.TransactionType, &fact.OccurredAt, &fact.ProviderEventOccurredAt, &fact.RecordedAt, + &fact.PeriodStartAt, &fact.PeriodEndAt, &fact.GracePeriodExpiresAt, &fact.RevokedAt, + &fact.RefundedAt, &fact.RenewalExpected, &fact.BillingRetryActive, &fact.IsUpgraded, + &fact.RevocationReason, &fact.RefundType, &fact.AutoRenewProductIdentifier, + &fact.InAppOwnershipType, &fact.SubscriptionGroupIdentifier, &fact.MosaicProductID, + &fact.ResolutionState, &fact.IsTestSource); err != nil { + return nil, fmt.Errorf("scan transaction fact: %w", err) + } + facts = append(facts, fact) + } + return facts, rows.Err() +} + +// loadGrantVersions reads every version for the Products the scope touches. +// Selecting the applicable one is the engine's job — it depends on the +// purchase's own effective time, which the query does not know. +func loadGrantVersions(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, input *billingprojection.Input) error { + rows, err := tx.Query(ctx, + `SELECT v.id, v.product_id, v.entitlement_id, e.key, v.version, + v.effective_start, v.effective_end, v.supported_purchase_types, + v.grants_in_active, v.grants_in_trial, v.grants_in_grace, + v.grants_in_billing_retry, v.grants_in_one_time_ownership + FROM product_entitlement_grant_versions v + JOIN entitlements e ON e.id = v.entitlement_id AND e.project_id = v.project_id + WHERE v.project_id = $1`, scope.ProjectID) + if err != nil { + return fmt.Errorf("read grant versions: %w", err) + } + defer rows.Close() + for rows.Next() { + var version billingprojection.GrantVersion + if err := rows.Scan(&version.ID, &version.ProductID, &version.EntitlementID, + &version.EntitlementKey, &version.Version, &version.EffectiveStart, &version.EffectiveEnd, + &version.SupportedPurchaseTypes, &version.Policy.GrantsInActive, + &version.Policy.GrantsInTrial, &version.Policy.GrantsInGrace, + &version.Policy.GrantsInBillingRetry, &version.Policy.GrantsInOneTime); err != nil { + return fmt.Errorf("scan grant version: %w", err) + } + input.GrantVersions = append(input.GrantVersions, version) + } + return rows.Err() +} + +// Commit writes the whole planned output in one transaction. +// +// Order matters only where foreign keys require it: subscription snapshots +// before the instance pointer that names them, the customer snapshot before +// its entries, sources, pointer, and webhook event. Everything else is grouped +// for readability. +func (r *Repository) Commit(ctx context.Context, input billingprojection.Input, output billingprojection.Output, now time.Time) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin projection commit: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + scope := output.Scope + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, scope.LockScope()); err != nil { + return fmt.Errorf("acquire projection commit lock: %w", err) + } + // The commit writes the same lineages the input read, so it takes the same + // lineage locks. LoadInput's locks were released when its read transaction + // ended; the compare-and-swap below covers the customer aggregate across + // that gap, and these locks cover the per-lineage writes it does not. + commitLineages := make([]string, 0, len(input.Lineages)) + for _, lineage := range input.Lineages { + commitLineages = append(commitLineages, lineage.LineageID) + } + if err := lockLineages(ctx, tx, scope, commitLineages); err != nil { + return err + } + + if scope.CustomerID != "" { + // Compare-and-swap. Losing it means another worker committed newer + // state for this customer while this command was computing, so this + // output describes a stale world and must not be written. + tag, err := tx.Exec(ctx, + `UPDATE billing_customers + SET current_projection_version = current_projection_version + 1, + last_projected_at = $3, updated_at = $3 + WHERE id = $1 AND current_projection_version = $2`, + scope.CustomerID, input.CurrentProjectionVersion, now) + if err != nil { + return fmt.Errorf("advance customer projection version: %w", err) + } + if tag.RowsAffected() != 1 { + return billingprojection.ErrVersionConflict + } + } + + for _, commit := range output.Subscriptions { + if err := writeSubscription(ctx, tx, scope, commit, now); err != nil { + return err + } + } + for _, commit := range output.OneTimes { + if err := writeOneTime(ctx, tx, scope, commit, now); err != nil { + return err + } + } + for _, checkpoint := range output.Checkpoints { + if err := writeCheckpoint(ctx, tx, scope, checkpoint, now); err != nil { + return err + } + } + + if output.CustomerSnapshot != nil { + if err := writeCustomerSnapshot(ctx, tx, scope, input, output, now); err != nil { + return err + } + } + + if err := writeAudit(ctx, tx, scope, output, now); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit projection: %w", err) + } + return nil +} + +func writeSubscription(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, commit billingprojection.SubscriptionCommit, now time.Time) error { + var version int64 + if err := tx.QueryRow(ctx, + `SELECT current_projection_version + 1 FROM subscription_instances WHERE id=$1 FOR UPDATE`, + commit.InstanceID).Scan(&version); err != nil { + return fmt.Errorf("read subscription projection version: %w", err) + } + snapshot := commit.Snapshot + snapshotID := "bss_" + hashID(commit.InstanceID, version) + + if _, err := tx.Exec(ctx, + `INSERT INTO subscription_snapshots( + id, project_id, environment_id, subscription_instance_id, projection_version, rule_version, + computed_at, as_of, access_state, lifecycle_state, renewal_intent, billing_state, + uncertainty_reason, period_start_at, period_end_at, grace_period_end_at, + billing_retry_start_at, pause_start_at, pause_resume_at, cancellation_effective_at, + expiration_effective_at, revocation_effective_at, refund_effective_at, + current_product_id, prior_product_id, scheduled_product_identifier, + is_test_source, terminal, checksum, projection_reason, 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, + NULLIF($24,''),NULLIF($25,''),NULLIF($26,''),$27,$28,$29,$30,$7)`, + snapshotID, scope.ProjectID, scope.EnvironmentID, commit.InstanceID, version, + billingprojection.RuleVersion, now, snapshot.AsOf, + snapshot.AccessState, snapshot.LifecycleState, snapshot.RenewalIntent, snapshot.BillingState, + snapshot.UncertaintyReason, snapshot.PeriodStartAt, snapshot.PeriodEndAt, + snapshot.GracePeriodEndAt, snapshot.BillingRetryStartAt, snapshot.PauseStartAt, + snapshot.PauseResumeAt, snapshot.CancellationEffectiveAt, snapshot.ExpirationEffectiveAt, + snapshot.RevocationEffectiveAt, snapshot.RefundEffectiveAt, + snapshot.CurrentProductID, snapshot.PriorProductID, snapshot.ScheduledProductIdentifier, + snapshot.IsTestSource, snapshot.Terminal, snapshot.Checksum, "fact_projection"); err != nil { + return fmt.Errorf("insert subscription snapshot: %w", err) + } + + for position, factID := range snapshot.SourceFactIDs { + if _, err := tx.Exec(ctx, + `INSERT INTO subscription_snapshot_facts(snapshot_id, transaction_fact_id, position) + VALUES ($1,$2,$3) ON CONFLICT DO NOTHING`, snapshotID, factID, position); err != nil { + return fmt.Errorf("link snapshot fact: %w", err) + } + } + + for _, entry := range commit.Timeline { + if err := writeTimeline(ctx, tx, scope, entry, snapshotID, commit.InstanceID, "", now); err != nil { + return err + } + } + + if _, err := tx.Exec(ctx, + `UPDATE subscription_instances + SET current_snapshot_id=$2, current_projection_version=$3, + current_mosaic_product_id=NULLIF($4,''), updated_at=$5, + terminal_at = CASE WHEN $6 THEN COALESCE(terminal_at,$5) ELSE NULL END + WHERE id=$1`, + commit.InstanceID, snapshotID, version, snapshot.CurrentProductID, now, snapshot.Terminal); err != nil { + return fmt.Errorf("update subscription instance pointer: %w", err) + } + return nil +} + +func writeOneTime(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, commit billingprojection.OneTimeCommit, now time.Time) error { + snapshot := commit.Snapshot + if _, err := tx.Exec(ctx, + `UPDATE one_time_purchase_instances + SET validity_state=$2, refund_effective_at=$3, revocation_effective_at=$4, + mosaic_product_id=NULLIF($5,''), + current_projection_version=current_projection_version+1, updated_at=$6 + WHERE id=$1`, + commit.InstanceID, snapshot.ValidityState, snapshot.RefundEffectiveAt, + snapshot.RevocationEffectiveAt, snapshot.MosaicProductID, now); err != nil { + return fmt.Errorf("update one-time purchase instance: %w", err) + } + for _, entry := range commit.Timeline { + if err := writeTimeline(ctx, tx, scope, entry, "", "", commit.InstanceID, now); err != nil { + return err + } + } + return nil +} + +func writeTimeline(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, entry billingprojection.TimelineEntry, snapshotID, subscriptionInstanceID, oneTimeInstanceID string, now time.Time) error { + detail := []byte("{}") + if len(entry.Detail) > 0 { + if encoded, err := json.Marshal(entry.Detail); err == nil { + detail = encoded + } + } + // The entry id is derived from its content so a reprojection of the same + // timeline is absorbed rather than duplicating history. + // The id is derived from content alone. Including the entry's index made it + // positional: one late-arriving fact shifted every following index and the + // whole tail of the timeline was re-inserted as new history. + id := "bte_" + hashID(subscriptionInstanceID, oneTimeInstanceID, entry.EntryType, + entry.EffectiveAt.UnixMilli(), strings.Join(entry.SourceFactIDs, ",")) + _, err := tx.Exec(ctx, + `INSERT INTO subscription_timeline_entries( + id, project_id, environment_id, subscription_instance_id, one_time_purchase_instance_id, + entry_type, effective_at, observed_at, new_snapshot_id, product_id, + source_fact_ids, explanation_code, detail, rule_version, created_at) + VALUES ($1,$2,$3,NULLIF($4,''),NULLIF($5,''),$6,$7,$8,NULLIF($9,''),NULLIF($10,''), + $11,$12,$13,$14,$15) + ON CONFLICT (id) DO NOTHING`, + id, scope.ProjectID, scope.EnvironmentID, subscriptionInstanceID, oneTimeInstanceID, + entry.EntryType, entry.EffectiveAt, entry.ObservedAt, snapshotID, entry.ProductID, + entry.SourceFactIDs, entry.ExplanationCode, detail, billingprojection.RuleVersion, now) + if err != nil { + return fmt.Errorf("insert timeline entry: %w", err) + } + return nil +} + +func writeCheckpoint(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, checkpoint billingprojection.CheckpointCommit, now time.Time) error { + subscriptionID, oneTimeID := checkpoint.InstanceID, "" + if checkpoint.Type == "one_time" { + subscriptionID, oneTimeID = "", checkpoint.InstanceID + } + id := "bpc_" + hashID(checkpoint.InstanceID) + _, err := tx.Exec(ctx, + `INSERT INTO projection_checkpoints( + id, project_id, environment_id, subscription_instance_id, one_time_purchase_instance_id, + high_watermark, facts_projected, rule_version, checksum, invalidated, updated_at) + VALUES ($1,$2,$3,NULLIF($4,''),NULLIF($5,''),$6,$7,$8,$9,$10,$11) + ON CONFLICT (id) DO UPDATE SET + high_watermark=EXCLUDED.high_watermark, facts_projected=EXCLUDED.facts_projected, + rule_version=EXCLUDED.rule_version, checksum=EXCLUDED.checksum, + invalidated=EXCLUDED.invalidated, updated_at=EXCLUDED.updated_at`, + id, scope.ProjectID, scope.EnvironmentID, subscriptionID, oneTimeID, + checkpoint.HighWatermark, checkpoint.FactsProjected, billingprojection.RuleVersion, + checkpoint.Checksum, checkpoint.Invalidated, now) + if err != nil { + return fmt.Errorf("write projection checkpoint: %w", err) + } + return nil +} + +func writeCustomerSnapshot(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, input billingprojection.Input, output billingprojection.Output, now time.Time) error { + snapshot := output.CustomerSnapshot + snapshotID := "ces_" + hashID(scope.CustomerID, scope.EnvironmentID, output.SnapshotVersion) + + var previousID *string + if err := tx.QueryRow(ctx, + `SELECT current_snapshot_id FROM customer_entitlement_pointers + WHERE billing_customer_id=$1 AND environment_id=$2`, + scope.CustomerID, scope.EnvironmentID).Scan(&previousID); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("read previous snapshot pointer: %w", err) + } + + if _, 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,$9,$10,$11,$7)`, + snapshotID, scope.ProjectID, scope.EnvironmentID, scope.CustomerID, output.SnapshotVersion, + billingprojection.RuleVersion, now, snapshot.AsOf, previousID, snapshot.Checksum, + changeReason(output)); err != nil { + return fmt.Errorf("insert customer entitlement snapshot: %w", err) + } + + for index, entry := range snapshot.Entries { + if _, 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)`, + "cee_"+hashID(snapshotID, index), scope.ProjectID, snapshotID, entry.EntitlementID, + entry.EntitlementKey, entry.State, entry.EffectiveStart, entry.EffectiveEnd, + entry.EndKnown, entry.SourceCount, entry.UncertaintyReason, entry.IsTestSource, + entry.ExplanationCode); err != nil { + return fmt.Errorf("insert snapshot entry: %w", err) + } + } + + for index, source := range snapshot.Sources { + if _, 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,''),NULLIF($12,''), + $13,$14,$15,$16,$17,$18,$19,$20,$21) + ON CONFLICT (customer_entitlement_snapshot_id, purchase_lineage_id, entitlement_id, grant_version_id) + DO NOTHING`, + "esr_"+hashID(snapshotID, index), scope.ProjectID, scope.EnvironmentID, snapshotID, + scope.CustomerID, source.EntitlementID, source.PurchaseLineageID, source.ProductID, + source.GrantVersionID, source.SubscriptionInstanceID, source.OneTimePurchaseInstanceID, + source.SourceSubscriptionSnapshot, source.SourceType, source.SourceState, + source.SourceStart, source.SourceEnd, source.EndKnown, source.UncertaintyReason, + source.IsTestSource, source.ExplanationCode, now); err != nil { + return fmt.Errorf("insert entitlement source: %w", err) + } + } + + if _, err := tx.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,$4,$5,$6) + ON CONFLICT (billing_customer_id, environment_id) DO UPDATE SET + current_snapshot_id=EXCLUDED.current_snapshot_id, + snapshot_version=EXCLUDED.snapshot_version, updated_at=EXCLUDED.updated_at`, + scope.ProjectID, scope.EnvironmentID, scope.CustomerID, snapshotID, + output.SnapshotVersion, now); err != nil { + return fmt.Errorf("update customer entitlement pointer: %w", 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. + // + // output.Event is nil for a no-change projection, so a replay that + // recomputes identical state emits nothing — the property the whole + // no-change path exists to guarantee. + if output.Event != nil { + eventID := "whe_" + hashID(snapshotID) + payload, err := json.Marshal(billingStateEventEnvelope(eventID, scope, input, output, now)) + if err != nil { + return fmt.Errorf("encode webhook payload: %w", err) + } + if _, err := tx.Exec(ctx, + `INSERT INTO webhook_events( + id, project_id, environment_id, event_type, billing_customer_id, + customer_entitlement_snapshot_id, snapshot_version, payload, occurred_at, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + ON CONFLICT (customer_entitlement_snapshot_id, event_type) DO NOTHING`, + eventID, scope.ProjectID, scope.EnvironmentID, + billingprojection.EventTypeEntitlementsChanged, scope.CustomerID, + snapshotID, output.SnapshotVersion, payload, output.Event.OccurredAt, now); err != nil { + return fmt.Errorf("create webhook event: %w", err) + } + } + return nil +} + +// billingStateEventEnvelope renders the complete Billing State Webhook Contract +// v1 event record. +// +// The whole envelope is stored rather than a partial payload the delivery +// worker would have to finish assembling. Two reasons: the delivered body is +// then byte-identical across every attempt and every manual replay, which is +// what makes a signature reproducible; and a contract change becomes one +// migration of stored rows rather than a silent difference between what was +// committed and what was sent. +func billingStateEventEnvelope(eventID string, scope billingprojection.Scope, + input billingprojection.Input, output billingprojection.Output, now time.Time) map[string]any { + + event := output.Event + changed := make([]map[string]any, 0, len(output.Changes.Entries)) + for _, change := range output.Changes.Entries { + changed = append(changed, map[string]any{ + "entitlementKey": change.EntitlementKey, + "previousState": change.PreviousState, + "currentState": change.CurrentState, + }) + } + + uncertainty := map[string]any{"reason": event.UncertaintyReason} + if event.UncertaintyReason != billingprojection.UncertaintyNone { + // The schema requires `since` on every non-`none` uncertainty. The + // instant the change became effective is the instant from which this + // uncertainty holds, so there is no second timestamp to invent. + uncertainty["since"] = contractTimestamp(event.OccurredAt) + } + + payload := map[string]any{ + "eventId": eventID, + "eventType": billingprojection.EventTypeEntitlementsChanged, + "projectId": scope.ProjectID, + "environmentId": scope.EnvironmentID, + "billingCustomerId": scope.CustomerID, + "snapshotVersion": output.SnapshotVersion, + "projectionRuleVersion": billingprojection.RuleVersion, + "occurredAt": contractTimestamp(event.OccurredAt), + "createdAt": contractTimestamp(now), + "changedEntitlements": changed, + "stateSummary": map[string]any{ + "accessState": event.AccessState, + "lifecycleState": event.LifecycleState, + "renewalIntent": event.RenewalIntent, + "billingState": event.BillingState, + "uncertainty": uncertainty, + }, + "sourceReason": event.SourceReason, + "isTestSource": event.IsTestSource, + // The idempotency key is a digest of scope, watermark, rule version, and + // grant version set: stable across attempts, derived from identifiers + // only, and safe to hand to a consumer for correlation. + "correlationId": billingprojection.HexKey(output.IdempotencyKey), + } + if event.SubscriptionInstanceID != "" { + payload["subscriptionInstanceId"] = event.SubscriptionInstanceID + } + if input.CurrentSnapshotVersion > 0 { + payload["previousSnapshotVersion"] = input.CurrentSnapshotVersion + } + return map[string]any{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": payload, + } +} + +// contractTimestamp renders the millisecond-precision UTC form every Mosaic +// contract timestamp uses. The schemas pin the length exactly, so a +// nanosecond-precision or offset-bearing rendering is a rejection. +func contractTimestamp(value time.Time) string { + return value.UTC().Format("2006-01-02T15:04:05.000Z") +} + +func changeReason(output billingprojection.Output) string { + if len(output.Changes.Changed) > 0 { + return "entitlements_changed" + } + return "subscription_state_changed" +} + +func writeAudit(ctx context.Context, tx pgx.Tx, scope billingprojection.Scope, output billingprojection.Output, now time.Time) error { + if output.Outcome == billingprojection.OutcomeNoChange { + // A no-change projection records an attempt, not an audit event: an + // audit trail full of "nothing happened" hides the entries that matter. + return nil + } + var organizationID string + if err := tx.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id=$1`, scope.ProjectID). + Scan(&organizationID); err != nil { + return fmt.Errorf("read organization for audit: %w", err) + } + metadata, _ := json.Marshal(map[string]any{ + "scope": scope.Key(), + "outcome": output.Outcome, + "idempotencyKey": billingprojection.HexKey(output.IdempotencyKey), + }) + _, 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,'system',$2,$3,NULLIF($4,''),'billing.projection.committed','billing_projection',$5,$6,$7)`, + "aud_"+hashID(scope.Key(), now.UnixNano()), organizationID, scope.ProjectID, + scope.EnvironmentID, scope.Key(), metadata, now) + if err != nil { + return fmt.Errorf("insert projection audit event: %w", err) + } + return nil +} + +// RecordAttempt writes outside the projection transaction so a rolled-back +// projection still leaves a trace that it ran. +func (r *Repository) RecordAttempt(ctx context.Context, scope billingprojection.Scope, jobID string, output billingprojection.Output, errorCode string, started, completed time.Time) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO projection_attempts( + id, project_id, projection_job_id, scope_key, rule_version, idempotency_key, + outcome, error_code, started_at, completed_at) + VALUES ($1,$2,NULLIF($3,''),$4,$5,$6,$7,NULLIF($8,''),$9,$10)`, + "pat_"+hashID(scope.Key(), started.UnixNano()), scope.ProjectID, jobID, scope.Key(), + billingprojection.RuleVersion, nullBytes(output.IdempotencyKey), + outcomeOrFailed(output.Outcome), errorCode, started, completed) + if err != nil { + return fmt.Errorf("record projection attempt: %w", err) + } + return nil +} + +func outcomeOrFailed(outcome string) string { + if outcome == "" { + return billingprojection.OutcomeFailed + } + return outcome +} + +// Enqueue coalesces onto the scope key. The partial unique index means a scope +// with queued or leased work absorbs the trigger, so a burst of facts for one +// customer produces one projection rather than a job storm. +func (r *Repository) Enqueue(ctx context.Context, scope billingprojection.Scope, kind string, now time.Time) error { + detail, err := json.Marshal(map[string]string{ + "customerId": scope.CustomerID, "lineageId": scope.LineageID, + }) + if err != nil { + return fmt.Errorf("encode projection job detail: %w", err) + } + _, err = r.pool.Exec(ctx, + `INSERT INTO projection_jobs( + id, project_id, environment_id, scope_key, kind, detail, status, + attempt_count, max_attempts, available_at, created_at, updated_at) + VALUES ($1,$2,NULLIF($3,''),$4,$5,$6,'queued',0,8,$7,$7,$7) + ON CONFLICT DO NOTHING`, + "pjb_"+hashID(scope.Key(), kind, now.UnixNano()), scope.ProjectID, scope.EnvironmentID, + scope.Key(), kind, detail, now) + if err != nil && !isUniqueViolation(err) { + return fmt.Errorf("enqueue projection job: %w", err) + } + return nil +} + +// LeaseJob claims one job with SELECT ... FOR UPDATE SKIP LOCKED, matching the +// pattern every other Mosaic queue uses so all of them behave identically +// under concurrency. +func (r *Repository) LeaseJob(ctx context.Context, workerID string, now, leaseUntil time.Time) (billingprojection.Job, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingprojection.Job{}, false, fmt.Errorf("begin projection lease: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var job billingprojection.Job + var environmentID *string + var detail []byte + err = tx.QueryRow(ctx, + `SELECT id, project_id, environment_id, scope_key, kind, detail, attempt_count, max_attempts + FROM projection_jobs + WHERE (status = 'queued' OR (status = 'leased' AND lease_expires_at <= $1)) + AND available_at <= $1 AND attempt_count < max_attempts + ORDER BY available_at, created_at, id + FOR UPDATE SKIP LOCKED LIMIT 1`, now). + Scan(&job.ID, &job.ProjectID, &environmentID, &job.ScopeKey, &job.Kind, &detail, + &job.AttemptCount, &job.MaxAttempts) + if errors.Is(err, pgx.ErrNoRows) { + return billingprojection.Job{}, false, nil + } + if err != nil { + return billingprojection.Job{}, false, fmt.Errorf("select projection job: %w", err) + } + if environmentID != nil { + job.EnvironmentID = *environmentID + } + var scopeDetail struct { + CustomerID string `json:"customerId"` + LineageID string `json:"lineageId"` + } + _ = json.Unmarshal(detail, &scopeDetail) + job.CustomerID, job.LineageID = scopeDetail.CustomerID, scopeDetail.LineageID + + if _, err := tx.Exec(ctx, + `UPDATE projection_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 billingprojection.Job{}, false, fmt.Errorf("lease projection job: %w", err) + } + job.AttemptCount++ + if err := tx.Commit(ctx); err != nil { + return billingprojection.Job{}, false, fmt.Errorf("commit projection lease: %w", err) + } + return job, true, nil +} + +func (r *Repository) CompleteJob(ctx context.Context, job billingprojection.Job, status, errorCode string, availableAt, now time.Time) error { + _, err := r.pool.Exec(ctx, + `UPDATE projection_jobs + SET status=$2, available_at=$3, lease_owner=NULL, lease_expires_at=NULL, + last_error_code=NULLIF($4,''), updated_at=$5 + WHERE id=$1`, job.ID, status, availableAt, errorCode, now) + if err != nil { + return fmt.Errorf("complete projection job: %w", err) + } + return nil +} + +// ScopesForReplay enumerates the scopes a bounded replay will recompute. +// +// The Project window bounds on *facts in the window*, not on when the lineage +// row happened to be created (review finding I-12). A lineage's created_at is +// the instant Mosaic first saw the purchase chain; bounding on it meant +// "replay yesterday" enumerated only chains discovered yesterday and silently +// skipped every long-lived subscription that received a renewal, refund, or +// grace fact in that window — the exact population an operator replays a window +// to inspect. A fact counts as in-window when either its provider-effective +// time or the instant Mosaic recorded it falls inside, because an operator +// investigating a window may be reasoning about either clock. +// +// The chain CTE walks supersession edges forward from each lineage root, the +// same direction loadFacts walks, so a fact that arrived on a successor Google +// purchase token brings its root lineage into scope. UNION rather than UNION ALL +// terminates on the cycle that provider data cannot contain but corrupt data +// could. +func (r *Repository) ScopesForReplay(ctx context.Context, replay billingprojection.ReplayScope, limit int) ([]billingprojection.Scope, error) { + rows, err := r.pool.Query(ctx, + `WITH RECURSIVE roots AS ( + SELECT l.id AS lineage_id, l.project_id, l.environment_id, + COALESCE(l.billing_customer_id,'') AS billing_customer_id, + l.lineage_key_digest AS digest + FROM purchase_lineages l + LEFT JOIN subscription_instances si ON si.purchase_lineage_id = l.id + WHERE ($1::text = '' OR l.project_id = $1) + AND ($2::text = '' OR l.billing_customer_id = $2) + AND ($3::text = '' OR si.id = $3) + ), chain(lineage_id, environment_id, digest) AS ( + SELECT lineage_id, environment_id, digest FROM roots + UNION + SELECT c.lineage_id, c.environment_id, f.purchase_chain_digest + FROM billing_transaction_facts f + JOIN chain c ON f.supersedes_chain_digest = c.digest + WHERE f.environment_id = c.environment_id + AND f.purchase_chain_digest IS NOT NULL + ) + SELECT DISTINCT r.project_id, r.environment_id, r.billing_customer_id, r.lineage_id + FROM roots r + WHERE ($4::timestamptz IS NULL AND $5::timestamptz IS NULL) + OR EXISTS ( + SELECT 1 + FROM chain c + JOIN billing_transaction_facts f + ON f.environment_id = c.environment_id + AND f.purchase_chain_digest = c.digest + WHERE c.lineage_id = r.lineage_id + AND ( + (($4::timestamptz IS NULL OR f.occurred_at >= $4) + AND ($5::timestamptz IS NULL OR f.occurred_at <= $5)) + OR (($4::timestamptz IS NULL OR f.recorded_at >= $4) + AND ($5::timestamptz IS NULL OR f.recorded_at <= $5)) + ) + ) + ORDER BY 1, 2, 3, 4 + LIMIT $6`, + replay.ProjectID, replay.CustomerID, replay.SubscriptionInstanceID, + replay.WindowStart, replay.WindowEnd, limit) + if err != nil { + return nil, fmt.Errorf("read replay scopes: %w", err) + } + defer rows.Close() + + seen := map[string]struct{}{} + scopes := make([]billingprojection.Scope, 0, limit) + for rows.Next() { + var scope billingprojection.Scope + if err := rows.Scan(&scope.ProjectID, &scope.EnvironmentID, &scope.CustomerID, &scope.LineageID); err != nil { + return nil, fmt.Errorf("scan replay scope: %w", err) + } + if scope.CustomerID != "" { + // Collapse to the customer scope: replaying two lineages of one + // customer separately would recompute the same aggregate twice and + // mint two snapshot versions for one logical change. + scope.LineageID = "" + } + if _, duplicate := seen[scope.Key()]; duplicate { + continue + } + seen[scope.Key()] = struct{}{} + scopes = append(scopes, scope) + } + return scopes, rows.Err() +} + +func nullBytes(value []byte) any { + if len(value) == 0 { + return nil + } + return value +} + +func hashID(parts ...any) string { + hasher := sha256.New() + for _, part := range parts { + fmt.Fprintf(hasher, "%v\x00", part) + } + return fmt.Sprintf("%x", hasher.Sum(nil))[:24] +} + +func isUniqueViolation(err error) bool { + return err != nil && strings.Contains(err.Error(), "SQLSTATE 23505") +} diff --git a/apps/api/internal/platform/billingrestorepostgres/chain_integration_test.go b/apps/api/internal/platform/billingrestorepostgres/chain_integration_test.go new file mode 100644 index 00000000..cf2c2517 --- /dev/null +++ b/apps/api/internal/platform/billingrestorepostgres/chain_integration_test.go @@ -0,0 +1,221 @@ +package billingrestorepostgres + +import ( + "context" + "crypto/sha256" + "database/sql" + "os" + "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/billingrestore" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +// This file covers exactly one thing: that LoadChain's stage-3 read resolves a +// validated fact to the Purchase Lineage that owns it, against the real schema. +// +// It exists because that read referenced `billing_transaction_facts. +// purchase_lineage_id`, a column no migration creates. Every restore that got +// as far as stage 3 failed with SQLSTATE 42703, was rescheduled, burned its +// twelve attempts, and reported `validation_pending` forever (defect D-2). No +// unit test could have caught it: the query is a string until PostgreSQL parses +// it, and the fake repository in the domain's own tests never runs any SQL. +// +// The seeding below is deliberately the minimum the foreign keys demand, and +// nothing else in this package gets a database round trip. + +func testPool(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + 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(), 60*time.Second) + t.Cleanup(cancel) + + db, err := sql.Open("pgx", databaseURL) + 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.Fatalf("apply migrations: %v", err) + } + _ = db.Close() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + return pool, ctx +} + +// TestLoadChainResolvesTheCustomerThroughTheLineageDigest is the regression for +// defect D-2. +// +// A restore whose linked input produced a validated fact must report the +// Billing Customer that owns the fact's lineage. The join is by provider chain +// digest — the only fact-to-lineage relationship the schema has — scoped by +// Environment and provider, because Apple's chain digest is unique only per +// (store environment, original transaction id). +func TestLoadChainResolvesTheCustomerThroughTheLineageDigest(t *testing.T) { + pool, ctx := testPool(t) + scope := seedRestoreTenant(t, ctx, pool, "d2") + repository := New(pool) + + chain, err := repository.LoadChain(ctx, billingrestore.Job{ + ID: scope.restoreID, ProjectID: scope.projectID, EnvironmentID: scope.environmentID, + }) + if err != nil { + t.Fatalf("load restore chain: %v", err) + } + if chain.LinkedInputCount != 1 { + t.Fatalf("linked inputs = %d, want 1", chain.LinkedInputCount) + } + if chain.FactCount != 1 { + t.Fatalf("facts = %d, want 1", chain.FactCount) + } + if chain.CustomerID != scope.customerID { + t.Fatalf("customer = %q, want %q — the fact never resolved to its lineage", + chain.CustomerID, scope.customerID) + } + if chain.IdentityConflict { + t.Fatal("a single unfrozen lineage was reported as an identity conflict") + } +} + +type restoreTenant struct { + projectID string + environmentID string + applicationID string + customerID string + restoreID string +} + +func seedRestoreTenant(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) restoreTenant { + t.Helper() + now := time.Now().UTC() + scope := restoreTenant{ + projectID: "proj_rst_" + suffix, + environmentID: "env_rst_" + suffix, + applicationID: "app_rst_" + suffix, + customerID: "bcu_rst_" + suffix, + restoreID: "rst_rst_" + suffix, + } + organizationID := "org_rst_" + suffix + rawInputID := "bri_rst_" + suffix + attemptID := "bva_rst_" + suffix + lineageID := "bpl_rst_" + suffix + factID := "btf_rst_" + suffix + + digest := sha256.Sum256([]byte("chain-" + suffix)) + reference := sha256.Sum256([]byte("reference-" + suffix)) + idempotency := sha256.Sum256([]byte("idempotency-" + suffix)) + content := sha256.Sum256([]byte("content-" + suffix)) + factDigest := sha256.Sum256([]byte("fact-" + suffix)) + + cleanupRestoreTenant(ctx, pool, scope.projectID) + statements := []struct { + query string + args []any + }{ + {`INSERT INTO organizations(id,name,created_at,updated_at) VALUES ($1,'Restore Test',$2,$2) + ON CONFLICT (id) DO NOTHING`, []any{organizationID, now}}, + {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES ($1,$2,$3,'Restore','active',$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{scope.projectID, organizationID, "restore-" + suffix, 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{scope.environmentID, scope.projectID, now}}, + {`INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES ($1,$2,'Restore App','ios',$3,$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{scope.applicationID, scope.projectID, "com.mosaic.restore." + suffix, now}}, + {`INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) + VALUES ($1,$2,'active','none',$3,$3) ON CONFLICT (id) DO NOTHING`, + []any{scope.customerID, scope.projectID, now}}, + {`INSERT INTO purchase_lineages(id,project_id,environment_id,environment_mode,application_id, + provider,store_environment,lineage_key_digest,lineage_type,billing_customer_id, + projection_frozen,diagnostic_status,created_at,updated_at) + VALUES ($1,$2,$3,'production',$4,'app_store','production',$5,'subscription',$6,false,'none',$7,$7)`, + []any{lineageID, scope.projectID, scope.environmentID, scope.applicationID, + digest[:], scope.customerID, now}}, + {`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 ($1,$2,$3,$4,'production',$5,'app_store','client_observation','client_observation', + $6,$7,$8,'not_retained','unauthenticated_client','production','accepted','corr-` + suffix + `', + $9,$9::timestamptz + interval '30 days')`, + []any{rawInputID, scope.projectID, organizationID, scope.environmentID, scope.applicationID, + idempotency[:], content[:], reference[:], now}}, + {`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 ($1,$2,$3,$4,1,2,$5,$5,'validated',false,'production',1,'corr-` + suffix + `')`, + []any{attemptID, scope.projectID, scope.environmentID, rawInputID, now}}, + {`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,provider_product_identifier,resolution_state,validator_version, + fact_version,source_raw_input_id,validation_attempt_id,fact_digest,recorded_at) + VALUES ($1,$2,$3,'production',$4,'app_store','production','3000000000000001',$5, + 'auto_renewable_subscription','initial_purchase',$6,'com.mosaic.pro.monthly','unresolved', + 2,1,$7,$8,$9,$6)`, + []any{factID, scope.projectID, scope.environmentID, scope.applicationID, digest[:], now, + rawInputID, attemptID, factDigest[:]}}, + {`INSERT INTO restore_sync_jobs(id,project_id,environment_id,store_platform,status,provider_outcome, + uncertainty_reason,observed_transaction_count,pending_validation_count,correlation_id, + attempt_count,max_attempts,available_at,requested_at,updated_at) + VALUES ($1,$2,$3,'apple_app_store','queued','completed','none',1,0,'corr-` + suffix + `',0,12,$4,$4,$4)`, + []any{scope.restoreID, scope.projectID, scope.environmentID, now}}, + {`INSERT INTO restore_sync_job_inputs(restore_sync_job_id,project_id,raw_input_id, + transaction_reference_digest,created_at) + VALUES ($1,$2,$3,$4,$5)`, + []any{scope.restoreID, scope.projectID, rawInputID, reference[:], now}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatalf("seed restore tenant: %v", err) + } + } + t.Cleanup(func() { cleanupRestoreTenant(context.Background(), pool, scope.projectID) }) + return scope +} + +// appendOnlyTables carry triggers that refuse UPDATE and DELETE. They are +// disabled for the teardown of this test's own rows only; nothing in the +// package's code path touches them. +var appendOnlyTables = []string{ + "restore_sync_job_inputs", + "billing_transaction_facts", + "billing_validation_attempts", + "billing_raw_inputs", +} + +func cleanupRestoreTenant(ctx context.Context, pool *pgxpool.Pool, projectID string) { + for _, table := range appendOnlyTables { + _, _ = pool.Exec(ctx, `ALTER TABLE `+table+` DISABLE TRIGGER USER`) + } + for _, statement := range []string{ + `DELETE FROM restore_sync_job_inputs WHERE project_id=$1`, + `DELETE FROM restore_sync_jobs WHERE project_id=$1`, + `DELETE FROM billing_transaction_facts WHERE project_id=$1`, + `DELETE FROM billing_validation_attempts WHERE project_id=$1`, + `DELETE FROM billing_raw_inputs WHERE project_id=$1`, + `DELETE FROM purchase_lineages WHERE project_id=$1`, + `DELETE FROM billing_customers WHERE project_id=$1`, + } { + _, _ = pool.Exec(ctx, statement, projectID) + } + for _, table := range appendOnlyTables { + _, _ = pool.Exec(ctx, `ALTER TABLE `+table+` ENABLE TRIGGER USER`) + } +} diff --git a/apps/api/internal/platform/billingrestorepostgres/queue_metrics.go b/apps/api/internal/platform/billingrestorepostgres/queue_metrics.go new file mode 100644 index 00000000..46abe540 --- /dev/null +++ b/apps/api/internal/platform/billingrestorepostgres/queue_metrics.go @@ -0,0 +1,106 @@ +package billingrestorepostgres + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const queueMetricTimeout = 5 * time.Second + +// RegisterQueueMetrics publishes backlog depth, oldest-job age, and dead-letter +// count for the restore queue, under the same instrument names and the same +// `family`/`queue` attributes every other Mosaic queue uses. One dashboard, one +// alert rule, one place to look. +// +// Oldest age is the signal that matters here more than anywhere else in +// billing: a restore is the one job a person is actually waiting on, and depth +// alone cannot tell a busy queue from one that has stopped moving. +// +// The age is measured from requested_at rather than from a created_at column, +// because the restore table dates a job from the moment the caller asked — the +// number a user would recognize as how long they have been waiting. +func (r *Repository) RegisterQueueMetrics() error { + meter := otel.Meter("mosaic/billingrestore") + depth, err := meter.Int64ObservableGauge("mosaic.worker.queue.depth", + metric.WithDescription("Jobs waiting or leased in a Mosaic worker queue.")) + if err != nil { + return fmt.Errorf("register restore queue depth gauge: %w", err) + } + oldest, err := meter.Float64ObservableGauge("mosaic.worker.queue.oldest_age_seconds", + metric.WithDescription("Age of the oldest unfinished job in a Mosaic worker queue."), + metric.WithUnit("s")) + if err != nil { + return fmt.Errorf("register restore queue age gauge: %w", err) + } + deadLettered, err := meter.Int64ObservableGauge("mosaic.worker.queue.dead_lettered", + metric.WithDescription("Jobs that exhausted their attempts in a Mosaic worker queue.")) + if err != nil { + return fmt.Errorf("register restore dead-letter gauge: %w", err) + } + // An uncertain outcome is not a failure, so it never shows up in the + // dead-letter gauge — but a Project whose restores all end + // `identity_unresolved` has a real problem an operator must be able to see. + uncertain, err := meter.Int64ObservableGauge("mosaic.billing.restore.uncertain", + metric.WithDescription("Completed restores that ended on a non-definite outcome, by outcome.")) + if err != nil { + return fmt.Errorf("register restore uncertainty gauge: %w", err) + } + + _, err = meter.RegisterCallback(func(ctx context.Context, observer metric.Observer) error { + ctx, cancel := context.WithTimeout(ctx, queueMetricTimeout) + defer cancel() + + var count, failed int64 + var age *float64 + row := r.pool.QueryRow(ctx, + `SELECT + count(*) FILTER (WHERE status IN ('queued','leased')), + max(extract(epoch from (now()-requested_at))) FILTER (WHERE status IN ('queued','leased')), + count(*) FILTER (WHERE status = 'failed') + FROM restore_sync_jobs`) + if err := row.Scan(&count, &age, &failed); err == nil { + attributes := metric.WithAttributes( + attribute.String("family", "billing"), + attribute.String("queue", "restore_sync"), + ) + observer.ObserveInt64(depth, count, attributes) + observer.ObserveInt64(deadLettered, failed, attributes) + seconds := 0.0 + if age != nil { + seconds = *age + } + observer.ObserveFloat64(oldest, seconds, attributes) + } + + rows, err := r.pool.Query(ctx, + `SELECT outcome, count(*) FROM restore_sync_jobs + WHERE status IN ('completed','failed') + AND outcome IS NOT NULL + AND outcome NOT IN ('restored','no_additional_purchases') + AND completed_at > now() - interval '24 hours' + GROUP BY outcome`) + if err != nil { + return nil + } + defer rows.Close() + for rows.Next() { + var outcome string + var total int64 + if err := rows.Scan(&outcome, &total); err != nil { + continue + } + observer.ObserveInt64(uncertain, total, metric.WithAttributes( + attribute.String("outcome", outcome))) + } + return nil + }, depth, oldest, deadLettered, uncertain) + if err != nil { + return fmt.Errorf("register restore queue metric callback: %w", err) + } + return nil +} diff --git a/apps/api/internal/platform/billingrestorepostgres/repository.go b/apps/api/internal/platform/billingrestorepostgres/repository.go new file mode 100644 index 00000000..23a1e55c --- /dev/null +++ b/apps/api/internal/platform/billingrestorepostgres/repository.go @@ -0,0 +1,506 @@ +// Package billingrestorepostgres is the PostgreSQL implementation of the +// restore/sync persistence port. +// +// Its job is to answer one question honestly: where did this restore's chain +// actually get to? Everything here is a read of committed state written by +// somebody else — ingestion wrote the Raw Billing Inputs, validation wrote the +// facts, identity resolution attached the lineages, projection moved the +// pointer — and nothing in this package advances any of those stages. A restore +// that could advance a stage itself would eventually disagree with the stage's +// owner about what happened, and both answers would look authoritative. +package billingrestorepostgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingrestore" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +var _ billingrestore.Repository = (*Repository)(nil) + +// providerUnavailableCodes are the validation error codes that mean the wait is +// on the store rather than on Mosaic. They are the retry classifier's own +// diagnostics, so the restore surface reports provider unavailability from the +// same evidence the validation queue retries on rather than from a guess. +var providerUnavailableCodes = []string{ + "provider_timeout", "provider_cancelled", "provider_network_error", "provider_unreachable", + "apple_server_error", "apple_rate_limited", "apple_retryable_error", + "google_server_error", "google_quota_exhausted", +} + +// productQuarantineReasons are the quarantine reasons that mean the purchase is +// real but the Entitlement is unknown. They are kept apart from every other +// quarantine reason because "we cannot map your Product" is an operator task +// with a definite fix, while the rest are failures. +var productQuarantineReasons = []string{"product_unknown", "product_ambiguous"} + +func (r *Repository) BillingEnabled(ctx context.Context, projectID string) (bool, error) { + var enabled bool + err := r.pool.QueryRow(ctx, + `SELECT billing_enabled FROM billing_project_settings WHERE project_id = $1`, projectID). + Scan(&enabled) + if errors.Is(err, pgx.ErrNoRows) { + // Off by default: a Project that never opted in holds no billing state. + return false, nil + } + if err != nil { + return false, fmt.Errorf("read billing enablement: %w", err) + } + return enabled, nil +} + +// CreateJob records a restore and links the Raw Billing Inputs the named +// observation submissions produced, in one transaction. +// +// The inputs are resolved by the observation's idempotency key rather than by a +// caller-supplied digest. That is the security-relevant choice: a Google +// purchase-token digest is computable by anyone holding the token, so accepting +// digests would let a caller attach another submission's input to its own +// restore. A submission id is the caller's own, and the key is +// Environment-scoped, so a restore can only ever link inputs the same caller +// created. +func (r *Repository) CreateJob(ctx context.Context, job billingrestore.Job, + submissionIDs []string, now time.Time) (billingrestore.Job, error) { + + provider := providerFor(job.StorePlatform) + + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingrestore.Job{}, fmt.Errorf("begin restore submission: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + keys := make([][]byte, 0, len(submissionIDs)) + for _, submissionID := range submissionIDs { + keys = append(keys, billing.ObservationKey(job.EnvironmentID, submissionID)) + } + + type linkedInput struct { + id string + digest []byte + } + linked := make([]linkedInput, 0, len(keys)) + if len(keys) > 0 { + rows, err := tx.Query(ctx, + `SELECT id, transaction_reference_digest + FROM billing_raw_inputs + WHERE project_id = $1 AND provider = $2 AND environment_id = $3 + AND idempotency_key = ANY($4) + AND transaction_reference_digest IS NOT NULL + ORDER BY id`, + job.ProjectID, provider, job.EnvironmentID, keys) + if err != nil { + return billingrestore.Job{}, fmt.Errorf("resolve restore observations: %w", err) + } + for rows.Next() { + var input linkedInput + if err := rows.Scan(&input.id, &input.digest); err != nil { + rows.Close() + return billingrestore.Job{}, fmt.Errorf("scan restore observation: %w", err) + } + linked = append(linked, input) + } + rows.Close() + if err := rows.Err(); err != nil { + return billingrestore.Job{}, fmt.Errorf("read restore observations: %w", err) + } + } + + // The count reported is the number of inputs actually linked, never the + // number the caller claimed. observedTransactionCount is never evidence of + // access, but it is evidence of what the chain can speak about, and a + // submission id that resolved to nothing is not part of the chain. + job.ObservedTransactionCount = len(linked) + + var baseline *int64 + if job.CustomerID != "" { + var version int64 + err := tx.QueryRow(ctx, + `SELECT COALESCE(( + SELECT snapshot_version FROM customer_entitlement_pointers + WHERE billing_customer_id = $1 AND environment_id = $2), 0)`, + job.CustomerID, job.EnvironmentID).Scan(&version) + if err != nil { + return billingrestore.Job{}, fmt.Errorf("read restore baseline: %w", err) + } + baseline = &version + } + job.BaselineSnapshotVersion = baseline + + if _, err := tx.Exec(ctx, + `INSERT INTO restore_sync_jobs( + id, project_id, environment_id, billing_customer_id, store_platform, + status, provider_outcome, uncertainty_reason, + observed_transaction_count, pending_validation_count, + baseline_snapshot_version, correlation_id, + attempt_count, max_attempts, available_at, requested_at, updated_at) + VALUES ($1,$2,$3,NULLIF($4,''),$5,'queued',$6,'none',$7,$8,$9,$10,0,$11,$12,$12,$12)`, + job.ID, job.ProjectID, job.EnvironmentID, job.CustomerID, job.StorePlatform, + job.ProviderOutcome, job.ObservedTransactionCount, len(linked), + baseline, job.CorrelationID, job.MaxAttempts, now); err != nil { + if isForeignKeyViolation(err) { + // The only caller-supplied reference here is the Billing Customer, + // and it comes from a trusted backend naming a customer that does + // not exist in its Project. + return billingrestore.Job{}, billingrestore.ErrInvalid + } + return billingrestore.Job{}, fmt.Errorf("insert restore job: %w", err) + } + + for _, input := range linked { + if _, err := tx.Exec(ctx, + `INSERT INTO restore_sync_job_inputs( + restore_sync_job_id, project_id, raw_input_id, + transaction_reference_digest, created_at) + VALUES ($1,$2,$3,$4,$5) + ON CONFLICT DO NOTHING`, + job.ID, job.ProjectID, input.id, input.digest, now); err != nil { + return billingrestore.Job{}, fmt.Errorf("link restore observation: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return billingrestore.Job{}, fmt.Errorf("commit restore submission: %w", err) + } + job.PendingValidationCount = len(linked) + job.Status = billingrestore.StatusQueued + job.RequestedAt, job.UpdatedAt = now, now + return job, nil +} + +// LeaseJob claims one due job with SELECT ... FOR UPDATE SKIP LOCKED, matching +// the pattern every other Mosaic queue uses so all of them behave identically +// under concurrency. +// +// The attempt counter is incremented on lease, so the attempt that takes the +// count to max_attempts is the attempt that has to finalize. That is what keeps +// an exhausted restore from becoming a queued row nothing will ever lease +// again — a zombie with no outcome, which is the one state the status endpoint +// could not report honestly. +func (r *Repository) LeaseJob(ctx context.Context, workerID string, now, leaseUntil time.Time) (billingrestore.Job, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingrestore.Job{}, false, fmt.Errorf("begin restore lease: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var job billingrestore.Job + var customerID *string + var baseline *int64 + err = tx.QueryRow(ctx, + `SELECT id, project_id, environment_id, billing_customer_id, store_platform, + provider_outcome, uncertainty_reason, observed_transaction_count, + pending_validation_count, baseline_snapshot_version, correlation_id, + attempt_count, max_attempts, requested_at + FROM restore_sync_jobs + WHERE (status = 'queued' OR (status = 'leased' AND leased_until <= $1)) + AND available_at <= $1 AND attempt_count < max_attempts + ORDER BY available_at, id + FOR UPDATE SKIP LOCKED LIMIT 1`, now). + Scan(&job.ID, &job.ProjectID, &job.EnvironmentID, &customerID, &job.StorePlatform, + &job.ProviderOutcome, &job.UncertaintyReason, &job.ObservedTransactionCount, + &job.PendingValidationCount, &baseline, &job.CorrelationID, + &job.AttemptCount, &job.MaxAttempts, &job.RequestedAt) + if errors.Is(err, pgx.ErrNoRows) { + return billingrestore.Job{}, false, nil + } + if err != nil { + return billingrestore.Job{}, false, fmt.Errorf("select restore job: %w", err) + } + if customerID != nil { + job.CustomerID = *customerID + } + job.BaselineSnapshotVersion = baseline + + if _, err := tx.Exec(ctx, + `UPDATE restore_sync_jobs + SET status='leased', leased_by=$2, leased_until=$3, + attempt_count=attempt_count+1, updated_at=$4 + WHERE id=$1`, job.ID, workerID, leaseUntil, now); err != nil { + return billingrestore.Job{}, false, fmt.Errorf("lease restore job: %w", err) + } + job.AttemptCount++ + job.Status = billingrestore.StatusLeased + if err := tx.Commit(ctx); err != nil { + return billingrestore.Job{}, false, fmt.Errorf("commit restore lease: %w", err) + } + return job, true, nil +} + +// LoadChain reads where the chain got to, in four reads that each answer one +// stage's question. They are not merged into one statement: the joins have +// genuinely different shapes, and a single query that produced all of it would +// be the kind nobody can read six months later. +func (r *Repository) LoadChain(ctx context.Context, job billingrestore.Job) (billingrestore.ChainState, error) { + chain := billingrestore.ChainState{CustomerID: job.CustomerID} + + // Stage 1 and 2: the linked inputs and what validation did with each. + // + // A fact existing for an input is the only evidence that input validated; + // the validation job's own status is used to tell "still working" from + // "gave up", never to conclude success. + var providerPending, productUnresolved, permanent int + err := r.pool.QueryRow(ctx, + `SELECT + count(*), + count(*) FILTER (WHERE f.id IS NULL AND (v.status IS NULL OR v.status IN ('queued','leased'))), + count(*) FILTER (WHERE f.id IS NULL AND v.status IN ('queued','leased') + AND v.last_error_code = ANY($2)), + count(*) FILTER (WHERE q.id IS NOT NULL AND q.status IN ('open','retrying') + AND q.reason_code = ANY($3)), + count(*) FILTER (WHERE f.id IS NULL AND (v.status = 'failed' + OR (q.id IS NOT NULL AND q.status IN ('open','retrying') + AND NOT (q.reason_code = ANY($3))))), + count(f.id) + FROM restore_sync_job_inputs i + LEFT JOIN LATERAL ( + SELECT tf.id FROM billing_transaction_facts tf + WHERE tf.source_raw_input_id = i.raw_input_id LIMIT 1 + ) f ON true + LEFT JOIN billing_validation_jobs v ON v.raw_input_id = i.raw_input_id + LEFT JOIN billing_quarantine_records q ON q.raw_input_id = i.raw_input_id + WHERE i.restore_sync_job_id = $1`, + job.ID, providerUnavailableCodes, productQuarantineReasons). + Scan(&chain.LinkedInputCount, &chain.PendingValidationCount, &providerPending, + &productUnresolved, &permanent, &chain.FactCount) + if err != nil { + return billingrestore.ChainState{}, fmt.Errorf("read restore validation chain: %w", err) + } + // The query counts rows because that is what SQL aggregates do; the domain + // only cares whether there were any. + chain.ProviderUnavailable = providerPending > 0 + chain.ProductUnresolved = productUnresolved > 0 + chain.PermanentFailure = permanent > 0 + + // Stage 3: which customer the validated facts resolved to, and whether that + // identity is disputed. Two distinct customers behind one restore is itself + // a conflict: Mosaic cannot know which of them asked. + var resolved *string + var distinct int + var frozen, lineageProductUnresolved bool + lineageKeys := []string{} + err = r.pool.QueryRow(ctx, + `SELECT + (array_agg(DISTINCT l.billing_customer_id) + FILTER (WHERE l.billing_customer_id IS NOT NULL))[1], + count(DISTINCT l.billing_customer_id) FILTER (WHERE l.billing_customer_id IS NOT NULL), + COALESCE(bool_or(l.projection_frozen OR l.diagnostic_status = 'identity_conflict'), false), + COALESCE(bool_or(l.diagnostic_status = 'product_unresolved'), false), + COALESCE(array_agg(DISTINCT 'lineage:' || l.id), ARRAY[]::text[]) + FROM restore_sync_job_inputs i + JOIN billing_transaction_facts f ON f.source_raw_input_id = i.raw_input_id + -- A fact names its lineage by the provider chain digest, not by a + -- foreign key: billing_transaction_facts has no purchase_lineage_id + -- column and no migration adds one. This join used to read that + -- non-existent column, so every restore that reached this stage failed + -- with SQLSTATE 42703, burned its attempts, and reported + -- validation_pending forever (defect D-2). The predicate below is the + -- same fact-to-lineage relationship every other join in the codebase + -- uses, scoped by Environment and provider because Apple's chain digest + -- is only unique per (store environment, original transaction id). + JOIN purchase_lineages l + ON l.environment_id = f.environment_id + AND l.provider = f.provider + AND l.lineage_key_digest = f.purchase_chain_digest + WHERE i.restore_sync_job_id = $1`, job.ID). + Scan(&resolved, &distinct, &frozen, &lineageProductUnresolved, &lineageKeys) + if err != nil { + return billingrestore.ChainState{}, fmt.Errorf("read restore identity chain: %w", err) + } + if chain.CustomerID == "" && resolved != nil { + chain.CustomerID = *resolved + } + chain.IdentityConflict = frozen || distinct > 1 + chain.ProductUnresolved = chain.ProductUnresolved || lineageProductUnresolved + + if chain.CustomerID == "" { + // Without a customer there is no pointer to read and no customer-scoped + // projection to wait on. Reporting settled here would be a claim about + // state that does not exist. + return chain, nil + } + + // Stage 4: the authoritative snapshot and whether the projection that would + // move it has finished. Lineage-scoped projections count: a lineage that + // has not been projected has not reached the customer aggregate either. + scopes := append(lineageKeys, "customer:"+chain.CustomerID) + var pending bool + err = r.pool.QueryRow(ctx, + `SELECT + COALESCE((SELECT snapshot_version FROM customer_entitlement_pointers + WHERE billing_customer_id = $1 AND environment_id = $2), 0), + EXISTS(SELECT 1 FROM projection_jobs + WHERE scope_key = ANY($3) AND status IN ('queued','leased')), + EXISTS(SELECT 1 FROM projection_jobs + WHERE scope_key = ANY($3) AND status = 'failed')`, + chain.CustomerID, job.EnvironmentID, scopes). + Scan(&chain.SnapshotVersion, &pending, &chain.ProjectionFailed) + if err != nil { + return billingrestore.ChainState{}, fmt.Errorf("read restore projection chain: %w", err) + } + chain.ProjectionSettled = !pending + return chain, nil +} + +// AdoptBaseline records the customer and the version that existed when identity +// first resolved. The WHERE clause makes it a one-way door: a baseline that +// could move would let `restored` be proven against a version chosen after the +// snapshot it is compared with had already been written. +func (r *Repository) AdoptBaseline(ctx context.Context, job billingrestore.Job, + customerID string, baseline int64, now time.Time) error { + + _, err := r.pool.Exec(ctx, + `UPDATE restore_sync_jobs + SET billing_customer_id = $2, baseline_snapshot_version = $3, updated_at = $4 + WHERE id = $1 AND baseline_snapshot_version IS NULL`, + job.ID, customerID, baseline, now) + if err != nil { + return fmt.Errorf("adopt restore baseline: %w", err) + } + return nil +} + +// CompleteJob writes the terminal outcome. +// +// The decision is validated again here, at the last moment before the write. +// The service already validated it and the schema will check it once more, and +// all three are kept: this is the one row in Mosaic whose whole purpose is that +// a particular pairing of columns is never wrong. +func (r *Repository) CompleteJob(ctx context.Context, job billingrestore.Job, + decision billingrestore.Decision, chain billingrestore.ChainState, now time.Time) error { + + if err := decision.Validate(); err != nil { + return err + } + var snapshotVersion *int64 + if version := decision.SnapshotVersion(); version > 0 { + snapshotVersion = &version + } + // The schema refuses a customer beside identity_unresolved, and so does the + // contract: the whole meaning of that outcome is that Mosaic does not know + // whose purchase this is. The column is cleared outright rather than merely + // left unwritten, so the row cannot end up naming a customer the outcome + // denies knowing. + clearCustomer := decision.Outcome == billingrestore.OutcomeIdentityUnresolved + + _, err := r.pool.Exec(ctx, + `UPDATE restore_sync_jobs + SET status = $2, + outcome = $3, + uncertainty_reason = $4, + pending_validation_count = $5, + billing_customer_id = CASE + WHEN $9 THEN NULL + ELSE COALESCE(NULLIF($6,''), billing_customer_id) + END, + snapshot_version = $7, + leased_by = NULL, + leased_until = NULL, + completed_at = $8, + updated_at = $8 + WHERE id = $1`, + job.ID, billingrestore.TerminalStatus(decision), decision.Outcome, + decision.UncertaintyReason, chain.PendingValidationCount, chain.CustomerID, + snapshotVersion, now, clearCustomer) + if err != nil { + return fmt.Errorf("complete restore job: %w", err) + } + return nil +} + +// RescheduleJob records progress and sets the next availability. The outcome +// column stays null on purpose: the chain has no answer yet, and writing a +// provisional one would make an unfinished restore look decided to every reader +// of the table. The status endpoint renders a null outcome as +// validation_pending, which is what an unfinished chain honestly means. +func (r *Repository) RescheduleJob(ctx context.Context, job billingrestore.Job, + decision billingrestore.Decision, chain billingrestore.ChainState, + availableAt, now time.Time) error { + + reason := decision.UncertaintyReason + if reason == "" { + reason = billingrestore.ReasonNone + } + _, err := r.pool.Exec(ctx, + `UPDATE restore_sync_jobs + SET status = 'queued', + uncertainty_reason = $2, + pending_validation_count = $3, + available_at = $4, + leased_by = NULL, + leased_until = NULL, + updated_at = $5 + WHERE id = $1`, + job.ID, reason, chain.PendingValidationCount, availableAt, now) + if err != nil { + return fmt.Errorf("reschedule restore job: %w", err) + } + return nil +} + +// Job reads one restore, scoped to the tenant that asked. A restore in another +// Project or Environment reads as absent rather than forbidden, so the surface +// cannot be used to probe for the existence of another tenant's restores. +func (r *Repository) Job(ctx context.Context, projectID, environmentID, restoreID string) (billingrestore.Job, error) { + var job billingrestore.Job + var customerID *string + var outcome *string + err := r.pool.QueryRow(ctx, + `SELECT id, project_id, environment_id, billing_customer_id, store_platform, + status, outcome, provider_outcome, uncertainty_reason, + observed_transaction_count, pending_validation_count, + baseline_snapshot_version, snapshot_version, correlation_id, + attempt_count, max_attempts, requested_at, updated_at, completed_at + FROM restore_sync_jobs + WHERE id = $1 AND project_id = $2 AND environment_id = $3`, + restoreID, projectID, environmentID). + Scan(&job.ID, &job.ProjectID, &job.EnvironmentID, &customerID, &job.StorePlatform, + &job.Status, &outcome, &job.ProviderOutcome, &job.UncertaintyReason, + &job.ObservedTransactionCount, &job.PendingValidationCount, + &job.BaselineSnapshotVersion, &job.SnapshotVersion, &job.CorrelationID, + &job.AttemptCount, &job.MaxAttempts, &job.RequestedAt, &job.UpdatedAt, + &job.CompletedAt) + if errors.Is(err, pgx.ErrNoRows) { + return billingrestore.Job{}, billingrestore.ErrNotFound + } + if err != nil { + return billingrestore.Job{}, fmt.Errorf("read restore job: %w", err) + } + if customerID != nil { + job.CustomerID = *customerID + } + if outcome != nil { + job.Outcome = *outcome + } + return job, nil +} + +// providerFor maps the contract's store platform onto the ingestion provider +// vocabulary. The two enumerations are deliberately separate — one is the +// frozen wire contract, the other is a storage CHECK — and this is the only +// place they meet. +func providerFor(storePlatform string) string { + if storePlatform == billingrestore.StoreApple { + return "app_store" + } + return "google_play" +} + +func isForeignKeyViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23503" +} diff --git a/apps/api/internal/platform/billingseam/binder.go b/apps/api/internal/platform/billingseam/binder.go new file mode 100644 index 00000000..47906e58 --- /dev/null +++ b/apps/api/internal/platform/billingseam/binder.go @@ -0,0 +1,120 @@ +// Package billingseam adapts the Phase 9B billing identity application service +// to the two ports the Phase 9A ingestion module declares. +// +// It exists so neither module has to import the other in the wrong direction. +// The identity module already depends on the ingestion module for digest +// domains and provider vocabulary, so the ingestion module declares interfaces +// (`billing.LineageBinder`, `billing.SubmissionBinder`) and this package +// satisfies them. Nothing here makes a decision: every rule about which customer +// owns a lineage lives in `billingcustomer`, and every rule about what a +// Customer Access Token proves lives in `billingaccess`. +package billingseam + +import ( + "context" + "errors" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" +) + +// Identity is the slice of the billing identity service this adapter needs. +type Identity interface { + AttachLineageForFact(ctx context.Context, attachment billingcustomer.FactAttachment) (billingcustomer.Resolution, error) + RecordSubmissionEvidence(ctx context.Context, projectID, environmentID, rawInputID, customerID string, referenceDigest []byte, secretServerKey bool) error +} + +// Tokens is the slice of the entitlement access service this adapter needs: the +// authority on what a Customer Access Token proves. +type Tokens interface { + AuthenticateCustomerTokenForTenant(ctx context.Context, rawToken, projectID, environmentID string) (billingaccess.Token, error) +} + +// Binder implements both seam ports. +type Binder struct { + identity Identity + tokens Tokens +} + +func New(identity Identity, tokens Tokens) *Binder { + return &Binder{identity: identity, tokens: tokens} +} + +var ( + _ billing.LineageBinder = (*Binder)(nil) + _ billing.SubmissionBinder = (*Binder)(nil) +) + +// BindFact hands a committed fact's lineage to the identity service. +func (b *Binder) BindFact(ctx context.Context, binding billing.FactBinding) error { + if b == nil || b.identity == nil || len(binding.LineageKeyDigest) == 0 { + return nil + } + correlators := make([]billingcustomer.AttachmentCorrelator, 0, len(binding.Correlators)) + for _, correlator := range binding.Correlators { + correlators = append(correlators, billingcustomer.AttachmentCorrelator{ + EvidenceType: correlator.EvidenceType, + AliasType: correlator.AliasType, + Digest: correlator.Digest, + }) + } + observedAt := binding.AcquiredAt + if observedAt.IsZero() { + observedAt = time.Now().UTC() + } + _, err := b.identity.AttachLineageForFact(ctx, billingcustomer.FactAttachment{ + ProjectID: binding.ProjectID, + EnvironmentID: binding.EnvironmentID, + Provider: binding.Provider, + LineageKeyDigest: binding.LineageKeyDigest, + FactChainDigest: binding.FactChainDigest, + RawInputID: binding.RawInputID, + ReferenceDigests: binding.ReferenceDigests, + Correlators: correlators, + ObservedAt: observedAt, + }) + if errors.Is(err, billingcustomer.ErrBillingDisabled) { + // The Project turned billing off between the fact being recorded and the + // association being decided. There is no identity to hold and nothing to + // report: the fact stands, exactly as Phase 9A leaves it. + return nil + } + return err +} + +// BindSubmission records the association a token-bound observation carries. +// +// An absent token is the ordinary case and is not an error. A token that fails +// to authenticate is also not an error *for the submission*: the observation +// itself was authenticated by an API key and is perfectly valid, and refusing to +// record it because an expired token rode along would turn a stale cache on one +// device into a dropped purchase. The submission simply carries no association. +func (b *Binder) BindSubmission(ctx context.Context, token string, submission billing.SubmissionBinding) (string, error) { + if b == nil || b.identity == nil || b.tokens == nil || token == "" { + return "", nil + } + // The tenant is the one the API key already authenticated, so a token minted + // for another Project or Environment is refused rather than used: attaching + // a purchase across that boundary would be a route to writing evidence about + // someone else's customer. + authenticated, err := b.tokens.AuthenticateCustomerTokenForTenant(ctx, token, + submission.ProjectID, submission.EnvironmentID) + if err != nil { + return "", nil + } + customerID := authenticated.CustomerID + if customerID == "" { + return "", nil + } + if err := b.identity.RecordSubmissionEvidence(ctx, submission.ProjectID, submission.EnvironmentID, + submission.RawInputID, customerID, submission.TransactionReferenceDigest, + submission.SecretServerKey); err != nil { + if errors.Is(err, billingcustomer.ErrBillingDisabled) { + return "", nil + } + return "", err + } + return customerID, nil +} diff --git a/apps/api/internal/platform/billingseam/seam_integration_test.go b/apps/api/internal/platform/billingseam/seam_integration_test.go new file mode 100644 index 00000000..3e753505 --- /dev/null +++ b/apps/api/internal/platform/billingseam/seam_integration_test.go @@ -0,0 +1,441 @@ +package billingseam_test + +import ( + "context" + "crypto/sha256" + "database/sql" + "os" + "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/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingcustomerpostgres" + "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/billingseam" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +// These two tests are the regression for defect D-1, and they are integration +// tests for a reason that is not incidental: the defect was that four +// application services had no production caller, so every one of them passed its +// own unit tests while a purchase reached nothing. What has to be proven is that +// a validated Transaction Fact ends as a committed Customer Entitlement Snapshot +// through production wiring only — the fact-commit transaction, the seam, the +// identity service, the projection job, and the projection command — with no +// step performed by the test that a deployed system would not perform. +// +// The test drives `CompleteAttempt` directly rather than a provider call, +// because the provider half is Phase 9A's and is already demonstrated. From that +// call onward, every row below is written by production code. + +func testPool(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + 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(), 90*time.Second) + t.Cleanup(cancel) + + db, err := sql.Open("pgx", databaseURL) + 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.Fatalf("apply migrations: %v", err) + } + _ = db.Close() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + return pool, ctx +} + +// TestValidatedFactBecomesACommittedEntitlementSnapshot is the end-to-end +// regression the brief asks for. +// +// A backend has identified its user and its SDK reported the purchase carrying +// that customer's token, so submission-context evidence exists. A fact then +// commits. Everything after that is production wiring: the lineage, the +// subscription instance, the association, the projection job, and the +// authoritative snapshot. +func TestValidatedFactBecomesACommittedEntitlementSnapshot(t *testing.T) { + pool, ctx := testPool(t) + fixture := newFixture(t, ctx, pool, "d1flow") + + // The submission an SDK holding a Customer Access Token produces. It is + // written through the identity service, which is what the observation intake + // calls; the token authentication itself is billingaccess's and is not + // re-proven here. + if err := fixture.identity.RecordSubmissionEvidence(ctx, fixture.projectID, fixture.environmentID, + fixture.rawInputID, fixture.customerID, fixture.referenceDigest, false); err != nil { + t.Fatalf("record submission evidence: %v", err) + } + + fixture.commitFact(t, ctx) + + lineage := fixture.lineage(t, ctx) + if lineage.BillingCustomerID != fixture.customerID { + t.Fatalf("lineage owner = %q, want %q — the seam did not attach the purchase", + lineage.BillingCustomerID, fixture.customerID) + } + if instances := fixture.count(t, ctx, + `SELECT count(*) FROM subscription_instances WHERE purchase_lineage_id=$1`, lineage.ID); instances != 1 { + t.Fatalf("subscription instances = %d, want 1; the projection loader joins to it", instances) + } + + fixture.drainProjection(t, ctx) + + var version int64 + var state string + if err := pool.QueryRow(ctx, + `SELECT p.snapshot_version, e.state + FROM customer_entitlement_pointers p + JOIN customer_entitlement_snapshot_entries e + ON e.customer_entitlement_snapshot_id = p.current_snapshot_id + WHERE p.billing_customer_id=$1 AND p.environment_id=$2`, + fixture.customerID, fixture.environmentID).Scan(&version, &state); err != nil { + t.Fatalf("no committed customer entitlement snapshot: %v", err) + } + if version < 1 || state != "active" { + t.Fatalf("snapshot version %d state %q, want an active entitlement", version, state) + } +} + +// TestPurchaseWithNoEvidenceAnchorsAndLaterIdentifies covers plan §5a rules 1, +// 2, and 3. +// +// A purchase arrives that nothing identifies — the ordinary anonymous case, and +// the one a store notification always produces on its own. It must still reach a +// customer, that customer must be recorded as purchase-anchored rather than +// silently indistinguishable from an identified one, and identifying the person +// afterwards must attach the alias to *that same customer* rather than minting a +// second one. The last part is the duplicate-customer trap the whole model +// exists to avoid: two customers each holding half a person's purchases is +// unrecoverable once entitlements have been granted from them. +func TestPurchaseWithNoEvidenceAnchorsAndLaterIdentifies(t *testing.T) { + pool, ctx := testPool(t) + fixture := newFixture(t, ctx, pool, "d1anchor") + + fixture.commitFact(t, ctx) + + lineage := fixture.lineage(t, ctx) + if lineage.BillingCustomerID == "" { + t.Fatal("an anonymous purchase reached no Billing Customer; it can never be answered for") + } + anchored := lineage.BillingCustomerID + if anchored == fixture.customerID { + t.Fatal("the anonymous purchase attached to the pre-existing customer without evidence") + } + + var evidenceType, outcome string + if err := pool.QueryRow(ctx, + `SELECT evidence_type, outcome FROM billing_association_evidence + WHERE project_id=$1 AND billing_customer_id=$2 AND purchase_lineage_id=$3`, + fixture.projectID, anchored, lineage.ID).Scan(&evidenceType, &outcome); err != nil { + t.Fatalf("no evidence explains the anchored customer: %v", err) + } + if evidenceType != billingcustomer.EvidencePurchaseAnchor || outcome != billingcustomer.OutcomeResolved { + t.Fatalf("evidence %q/%q, want %q/resolved", evidenceType, outcome, + billingcustomer.EvidencePurchaseAnchor) + } + + // The person signs in. Login attaches; it never merges. + alias, err := fixture.identity.AttachApplicationUserAlias(ctx, billingcustomer.Actor{ID: "actor-test"}, + fixture.projectID, anchored, "person-"+fixture.suffix) + if err != nil { + t.Fatalf("attach application user alias: %v", err) + } + if alias.BillingCustomerID != anchored { + t.Fatalf("alias attached to %q, want the purchase-anchored customer %q", + alias.BillingCustomerID, anchored) + } + if customers := fixture.count(t, ctx, + `SELECT count(*) FROM billing_customers WHERE project_id=$1`, fixture.projectID); customers != 2 { + // The seeded customer plus the anchored one. A third would mean + // identifying the person minted a duplicate. + t.Fatalf("billing customers = %d, want 2; identifying a person must not create one", customers) + } +} + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +type fixture struct { + pool *pgxpool.Pool + suffix string + projectID string + environmentID string + applicationID string + productID string + customerID string + rawInputID string + attemptID string + factID string + + chainDigest []byte + referenceDigest []byte + + billing *billingpostgres.Repository + identity *billingcustomer.Service + projection *billingprojection.Service +} + +func newFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) *fixture { + t.Helper() + f := &fixture{ + pool: pool, suffix: suffix, + projectID: "proj_seam_" + suffix, + environmentID: "env_seam_" + suffix, + applicationID: "app_seam_" + suffix, + productID: "prd_seam_" + suffix, + customerID: "bcu_seam_" + suffix, + rawInputID: "bri_seam_" + suffix, + attemptID: "bva_seam_" + suffix, + factID: "btf_seam_" + suffix, + } + chain := sha256.Sum256([]byte("chain-" + suffix)) + reference := sha256.Sum256([]byte("reference-" + suffix)) + f.chainDigest, f.referenceDigest = chain[:], reference[:] + + f.billing = billingpostgres.New(pool) + projectionRepository := billingprojectionpostgres.New(pool) + f.projection = billingprojection.NewService(projectionRepository) + f.identity = billingcustomer.NewService(billingcustomerpostgres.New(pool), + stubKeys{}, f.projection) + + f.clean(ctx) + t.Cleanup(func() { f.clean(context.Background()) }) + f.seed(t, ctx) + return f +} + +// stubKeys stands in for the trusted-server key authenticator. No test here +// authenticates a key: every call goes through the application service directly, +// exactly as the seam's production caller does. +type stubKeys struct{} + +func (stubKeys) AuthenticateServerKey(context.Context, string) (billingcustomer.KeyScope, error) { + return billingcustomer.KeyScope{}, billingcustomer.ErrUnauthenticated +} + +func (f *fixture) seed(t *testing.T, ctx context.Context) { + t.Helper() + now := time.Now().UTC() + organizationID := "org_seam_" + f.suffix + entitlementID := "ent_seam_" + f.suffix + mappingID := "ppm_seam_" + f.suffix + grantID := "pegv_seam_" + f.suffix + + for _, statement := range []struct { + query string + args []any + }{ + {`INSERT INTO organizations(id,name,created_at,updated_at) VALUES ($1,'Seam',$2,$2) + ON CONFLICT (id) DO NOTHING`, []any{organizationID, now}}, + {`INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) + VALUES ($1,'actor-test','owner',$2,$2) + ON CONFLICT (organization_id,actor_id) DO UPDATE SET role='owner'`, + []any{organizationID, now}}, + {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES ($1,$2,$3,'Seam','active',$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{f.projectID, organizationID, "seam-" + f.suffix, 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}}, + {`INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES ($1,$2,'Seam','ios',$3,$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{f.applicationID, f.projectID, "com.mosaic.seam." + f.suffix, now}}, + {`INSERT INTO products(id,project_id,key,internal_name,description,type,status, + metadata_source,readiness_ready,created_at,updated_at) + VALUES ($1,$2,$3,'Pro','','subscription','connected','mock',true,$4,$4) + ON CONFLICT (id) DO NOTHING`, []any{f.productID, f.projectID, "pro-" + f.suffix, now}}, + {`INSERT INTO entitlements(id,project_id,key,name,description,created_at,updated_at) + VALUES ($1,$2,$3,'Pro','',$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{entitlementID, f.projectID, "pro-" + f.suffix, now}}, + {`INSERT INTO provider_product_mappings(id,project_id,product_id,application_id,provider, + provider_product_identifier,platform,status,created_at,updated_at) + VALUES ($1,$2,$3,$4,'app_store','com.mosaic.pro','ios','placeholder',$5,$5) + ON CONFLICT (id) DO NOTHING`, []any{mappingID, f.projectID, f.productID, f.applicationID, now}}, + {`INSERT INTO product_entitlement_grant_versions( + id,project_id,product_id,entitlement_id,version,effective_start,created_at) + VALUES ($1,$2,$3,$4,1,$5,$5) ON CONFLICT (id) DO NOTHING`, + []any{grantID, f.projectID, f.productID, entitlementID, now.Add(-365 * 24 * time.Hour)}}, + {`INSERT INTO billing_project_settings(project_id,billing_enabled,updated_by_actor_id,created_at,updated_at) + VALUES ($1,true,'seam',$2,$2) ON CONFLICT (project_id) DO UPDATE SET billing_enabled=true`, + []any{f.projectID, now}}, + {`INSERT INTO billing_customers(id,project_id,status,diagnostics_status,created_at,updated_at) + VALUES ($1,$2,'active','none',$3,$3) ON CONFLICT (id) DO NOTHING`, + []any{f.customerID, f.projectID, now}}, + {`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 ($1,$2,$3,$4,'production',$5,'app_store','client_observation','client_observation', + $6,$7,$8,'not_retained','unauthenticated_client','production','accepted','seam', + $9,$9::timestamptz + interval '30 days')`, + []any{f.rawInputID, f.projectID, organizationID, f.environmentID, f.applicationID, + digest("idem-" + f.suffix), digest("content-" + f.suffix), f.referenceDigest, now}}, + {`INSERT INTO billing_validation_jobs(id,project_id,environment_id,raw_input_id,provider,status, + attempt_count,max_attempts,available_at,lease_owner,lease_expires_at,created_at,updated_at) + VALUES ($1,$2,$3,$4,'app_store','leased',1,8,$5,'seam-test',$5::timestamptz + interval '2 minutes',$5,$5)`, + []any{"bvj_seam_" + f.suffix, f.projectID, f.environmentID, f.rawInputID, now}}, + } { + if _, err := f.pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatalf("seed seam fixture: %v", err) + } + } +} + +// commitFact runs the production fact-commit transaction and then the +// production seam, exactly as ProcessNextValidation does. +func (f *fixture) commitFact(t *testing.T, ctx context.Context) { + t.Helper() + now := time.Now().UTC() + start := now.Add(-24 * time.Hour) + end := now.Add(30 * 24 * time.Hour) + + fact := billing.TransactionFact{ + ID: f.factID, ProjectID: f.projectID, EnvironmentID: f.environmentID, + EnvironmentMode: "production", ApplicationID: f.applicationID, + Provider: billing.ProviderAppStore, StoreEnvironment: billing.StoreProduction, + ProviderTransactionID: "3000000000000001", PurchaseChainDigest: f.chainDigest, + TransactionType: billing.TypeAutoRenewableSubscription, FactKind: billing.KindInitialPurchase, + OccurredAt: start, PeriodStartAt: &start, PeriodEndAt: &end, + ProviderProductIdentifier: "com.mosaic.pro", ResolutionState: billing.StateActiveMapping, + MosaicProductID: f.productID, ProviderProductMappingID: "ppm_seam_" + f.suffix, + ValidatorVersion: billing.ValidatorVersion, FactVersion: 1, + SourceRawInputID: f.rawInputID, ValidationAttemptID: f.attemptID, + FactDigest: digest("fact-" + f.suffix), RecordedAt: now, + } + outcome := billing.AttemptOutcome{ + Attempt: billing.ValidationAttempt{ + ID: f.attemptID, ProjectID: f.projectID, EnvironmentID: f.environmentID, + RawInputID: f.rawInputID, AttemptNumber: 1, ValidatorVersion: billing.ValidatorVersion, + StartedAt: now, CompletedAt: now, Outcome: billing.OutcomeValidated, + StoreEnvironment: billing.StoreProduction, CorrelationID: "seam", + }, + Fact: &fact, + ReferenceDigests: [][]byte{f.referenceDigest}, + } + job := billing.ValidationJob{ + ID: "bvj_seam_" + f.suffix, ProjectID: f.projectID, EnvironmentID: f.environmentID, + RawInputID: f.rawInputID, Provider: billing.ProviderAppStore, MaxAttempts: 8, + } + if err := f.billing.CompleteAttempt(ctx, job, outcome, now); err != nil { + t.Fatalf("complete attempt: %v", err) + } + + binder := billingseam.New(f.identity, nil) + if err := binder.BindFact(ctx, billing.FactBinding{ + ProjectID: f.projectID, EnvironmentID: f.environmentID, + Provider: billing.ProviderAppStore, LineageKeyDigest: f.chainDigest, + FactChainDigest: f.chainDigest, RawInputID: f.rawInputID, + ReferenceDigests: outcome.ReferenceDigests, AcquiredAt: start, + }); err != nil { + t.Fatalf("bind fact: %v", err) + } +} + +func (f *fixture) lineage(t *testing.T, ctx context.Context) billingcustomer.Lineage { + t.Helper() + lineage, err := billingcustomerpostgres.New(f.pool). + LineageByKey(ctx, f.environmentID, billing.ProviderAppStore, f.chainDigest) + if err != nil { + t.Fatalf("the fact-commit transaction created no Purchase Lineage: %v", err) + } + return lineage +} + +// drainProjection runs the real worker job function until the queue is empty. +func (f *fixture) drainProjection(t *testing.T, ctx context.Context) { + t.Helper() + for range 12 { + processed, err := f.projection.ProcessNextProjection(ctx, "seam-test") + if err != nil { + t.Fatalf("process projection: %v", err) + } + if !processed { + return + } + } +} + +func (f *fixture) count(t *testing.T, ctx context.Context, query string, args ...any) int { + t.Helper() + var total int + if err := f.pool.QueryRow(ctx, query, args...).Scan(&total); err != nil { + t.Fatalf("count: %v", err) + } + return total +} + +func (f *fixture) clean(ctx context.Context) { + appendOnly := []string{ + "billing_association_evidence", "billing_transaction_facts", + "billing_validation_attempts", "billing_raw_inputs", + "customer_entitlement_snapshots", "customer_entitlement_snapshot_entries", + "entitlement_sources", "subscription_snapshots", "subscription_snapshot_facts", + "subscription_timeline_entries", "webhook_events", + } + for _, table := range appendOnly { + _, _ = f.pool.Exec(ctx, `ALTER TABLE `+table+` DISABLE TRIGGER USER`) + } + for _, statement := range []string{ + `DELETE FROM webhook_events WHERE project_id=$1`, + `DELETE FROM projection_attempts WHERE project_id=$1`, + `DELETE FROM projection_jobs WHERE project_id=$1`, + `DELETE FROM projection_checkpoints WHERE project_id=$1`, + `DELETE FROM entitlement_sources WHERE project_id=$1`, + `DELETE FROM customer_entitlement_snapshot_entries WHERE project_id=$1`, + `DELETE FROM customer_entitlement_pointers WHERE project_id=$1`, + `DELETE FROM customer_entitlement_snapshots WHERE project_id=$1`, + `DELETE FROM subscription_timeline_entries WHERE project_id=$1`, + `UPDATE subscription_instances SET current_snapshot_id=NULL WHERE project_id=$1`, + `DELETE FROM subscription_snapshot_facts WHERE snapshot_id IN (SELECT id FROM subscription_snapshots WHERE project_id=$1)`, + `DELETE FROM subscription_snapshots WHERE project_id=$1`, + `DELETE FROM subscription_instances WHERE project_id=$1`, + `DELETE FROM one_time_purchase_instances WHERE project_id=$1`, + `DELETE FROM billing_identity_conflicts WHERE project_id=$1`, + `DELETE FROM billing_association_evidence WHERE project_id=$1`, + `DELETE FROM billing_customer_aliases WHERE project_id=$1`, + `DELETE FROM purchase_lineages WHERE project_id=$1`, + `DELETE FROM billing_transaction_facts WHERE project_id=$1`, + `DELETE FROM billing_ledger_entries WHERE project_id=$1`, + `DELETE FROM billing_product_resolutions WHERE project_id=$1`, + `DELETE FROM billing_validation_attempts WHERE project_id=$1`, + `DELETE FROM billing_validation_jobs WHERE project_id=$1`, + `DELETE FROM billing_raw_inputs WHERE project_id=$1`, + `DELETE FROM billing_customers WHERE project_id=$1`, + `DELETE FROM product_entitlement_grant_versions WHERE project_id=$1`, + `DELETE FROM provider_product_mappings WHERE project_id=$1`, + `DELETE FROM billing_project_settings WHERE project_id=$1`, + `DELETE FROM audit_events WHERE project_id=$1`, + `DELETE FROM organization_members WHERE organization_id IN (SELECT organization_id FROM projects WHERE id=$1)`, + } { + _, _ = f.pool.Exec(ctx, statement, f.projectID) + } + for _, table := range appendOnly { + _, _ = f.pool.Exec(ctx, `ALTER TABLE `+table+` ENABLE TRIGGER USER`) + } +} + +func digest(value string) []byte { + sum := sha256.Sum256([]byte(value)) + return sum[:] +} diff --git a/apps/api/internal/platform/billingwebhookpostgres/delivery_integration_test.go b/apps/api/internal/platform/billingwebhookpostgres/delivery_integration_test.go new file mode 100644 index 00000000..786ff8a1 --- /dev/null +++ b/apps/api/internal/platform/billingwebhookpostgres/delivery_integration_test.go @@ -0,0 +1,399 @@ +package billingwebhookpostgres + +import ( + "bytes" + "context" + "database/sql" + "os" + "sync" + "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/billingwebhook" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +// Webhook delivery end-to-end persistence behaviour. +// +// Every guarantee exercised here lives in the schema and in the transaction +// boundaries around it — the (event, destination) uniqueness that makes a +// logical delivery singular, SELECT ... FOR UPDATE SKIP LOCKED under a real +// concurrent claim, the attempt-number uniqueness that makes history readable, +// and the stored event body that a replay re-sends unchanged. A unit test with +// a fake repository would pass with all four broken, because the database is +// the thing under test. + +func testPool(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + 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(), 60*time.Second) + t.Cleanup(cancel) + + db, err := sql.Open("pgx", databaseURL) + 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.Fatalf("apply migrations: %v", err) + } + _ = db.Close() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + return pool, ctx +} + +// fixture is one tenant with one active destination and one committed event +// ready to fan out. +type fixture struct { + projectID string + environmentID string + destinationID string + eventID string + payload string +} + +func seedWebhookFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) fixture { + t.Helper() + now := time.Now().UTC() + f := fixture{ + projectID: "proj_whd_" + suffix, + environmentID: "env_whd_" + suffix, + destinationID: "whd_" + suffix, + eventID: "whe_" + suffix, + payload: `{"eventId": "whe_` + suffix + `", "eventType": "customer.entitlements.changed"}`, + } + organizationID := "org_whd_" + suffix + customerID := "bcus_whd_" + suffix + snapshotID := "ces_whd_" + suffix + + cleanupWebhookFixture(ctx, pool, f, organizationID) + statements := []struct { + query string + args []any + }{ + {`INSERT INTO organizations(id,name,created_at,updated_at) VALUES ($1,'Webhook Test',$2,$2) + ON CONFLICT (id) DO NOTHING`, []any{organizationID, now}}, + {`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 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}}, + {`INSERT INTO billing_project_settings(project_id,billing_enabled,updated_by_actor_id,created_at,updated_at) + VALUES ($1,true,'seed',$2,$2) ON CONFLICT (project_id) DO UPDATE SET billing_enabled=true`, + []any{f.projectID, now}}, + {`INSERT INTO webhook_destinations(id,project_id,environment_id,url,status,created_at,updated_at) + VALUES ($1,$2,$3,'https://receiver.example.com/mosaic','active',$4,$4)`, + []any{f.destinationID, f.projectID, f.environmentID, now}}, + {`INSERT INTO billing_customers(id,project_id,created_at,updated_at) VALUES ($1,$2,$3,$3)`, + []any{customerID, f.projectID, now}}, + {`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,1,1,$5,$5,sha256('snapshot'::bytea),'initial',$5)`, + []any{snapshotID, f.projectID, f.environmentID, customerID, now}}, + // The event is committed exactly as the projection transaction writes + // it: immutable, with the payload the delivery will send verbatim. + {`INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id, + customer_entitlement_snapshot_id,snapshot_version,payload,occurred_at,created_at) + VALUES ($1,$2,$3,'customer.entitlements.changed',$4,$5,1,$6::jsonb,$7,$7)`, + []any{f.eventID, f.projectID, f.environmentID, customerID, snapshotID, f.payload, now}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatalf("seed webhook fixture: %v", err) + } + } + t.Cleanup(func() { + cleanupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cleanupWebhookFixture(cleanupContext, pool, f, organizationID) + }) + return f +} + +// cleanupWebhookFixture removes the fixture in dependency order. Events, +// attempts, and snapshots carry append-only triggers, so they are cleared with +// a session-level trigger disable rather than by weakening the schema. +func cleanupWebhookFixture(ctx context.Context, pool *pgxpool.Pool, f fixture, organizationID string) { + for _, statement := range []string{ + `ALTER TABLE webhook_events DISABLE TRIGGER webhook_events_append_only`, + `ALTER TABLE webhook_delivery_attempts DISABLE TRIGGER webhook_delivery_attempts_append_only`, + `ALTER TABLE customer_entitlement_snapshots DISABLE TRIGGER customer_entitlement_snapshots_append_only`, + } { + _, _ = pool.Exec(ctx, statement) + } + for _, statement := range []string{ + `DELETE FROM webhook_delivery_attempts WHERE project_id=$1`, + `DELETE FROM webhook_event_fanouts WHERE project_id=$1`, + `DELETE FROM webhook_deliveries WHERE project_id=$1`, + `DELETE FROM webhook_events WHERE project_id=$1`, + `DELETE FROM webhook_signing_secrets WHERE project_id=$1`, + `DELETE FROM webhook_destinations WHERE project_id=$1`, + `DELETE FROM customer_entitlement_snapshots WHERE project_id=$1`, + `DELETE FROM billing_customers WHERE project_id=$1`, + `DELETE FROM audit_events WHERE project_id=$1`, + `DELETE FROM billing_project_settings WHERE project_id=$1`, + `DELETE FROM environments WHERE project_id=$1`, + `DELETE FROM projects WHERE id=$1`, + } { + _, _ = pool.Exec(ctx, statement, f.projectID) + } + _, _ = pool.Exec(ctx, `DELETE FROM organizations WHERE id=$1`, organizationID) + for _, statement := range []string{ + `ALTER TABLE webhook_events ENABLE TRIGGER webhook_events_append_only`, + `ALTER TABLE webhook_delivery_attempts ENABLE TRIGGER webhook_delivery_attempts_append_only`, + `ALTER TABLE customer_entitlement_snapshots ENABLE TRIGGER customer_entitlement_snapshots_append_only`, + } { + _, _ = pool.Exec(ctx, statement) + } +} + +func countRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool, query string, args ...any) int { + t.Helper() + var count int + if err := pool.QueryRow(ctx, query, args...).Scan(&count); err != nil { + t.Fatal(err) + } + return count +} + +// A worker that dies between claiming a delivery and recording its outcome must +// not produce a second logical delivery. +// +// The window is real: the lease commits (which is what advances attempt_count) +// before the HTTP request begins, and the process can be killed at any point +// afterwards. Recovery runs the same fan-out and the same claim query. If either +// minted a new row, the destination would receive the same entitlement change +// under two delivery identities, and a receiver deduplicating on the delivery +// rather than the event would act on it twice. +func TestCrashedDeliveryRetryDoesNotCreateASecondDelivery(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + f := seedWebhookFixture(t, ctx, pool, "crash") + now := time.Now().UTC() + + if _, err := repository.FanOut(ctx, now, 10); err != nil { + t.Fatal(err) + } + leased, ok, err := repository.LeaseDelivery(ctx, "worker-a", now, now.Add(billingwebhook.DeliveryLease)) + if err != nil || !ok { + t.Fatalf("first lease: ok=%t err=%v", ok, err) + } + if leased.Delivery.AttemptCount != 1 { + t.Fatalf("attempt count after the first claim is %d, want 1", leased.Delivery.AttemptCount) + } + + // worker-a dies here: no CompleteAttempt, the lease is left dangling. + + // Recovery pass one: the fan-out runs again over the same committed event. + if _, err := repository.FanOut(ctx, now, 10); err != nil { + t.Fatal(err) + } + // Recovery pass two: another worker claims the delivery once the lease has + // lapsed. The clock is moved past the lease rather than waiting on it. + afterLease := now.Add(billingwebhook.DeliveryLease + time.Second) + recovered, ok, err := repository.LeaseDelivery(ctx, "worker-b", afterLease, + afterLease.Add(billingwebhook.DeliveryLease)) + if err != nil || !ok { + t.Fatalf("recovery lease: ok=%t err=%v", ok, err) + } + if recovered.Delivery.ID != leased.Delivery.ID { + t.Fatalf("recovery claimed delivery %s, want the abandoned %s — the retry created a "+ + "second logical delivery for one event and destination", + recovered.Delivery.ID, leased.Delivery.ID) + } + if recovered.Delivery.AttemptCount != 2 { + t.Fatalf("attempt count after recovery is %d, want 2; a crash must still consume an attempt", + recovered.Delivery.AttemptCount) + } + + completedAt := afterLease.Add(time.Second) + if _, err := repository.CompleteAttempt(ctx, billingwebhook.AttemptResult{ + Delivery: recovered.Delivery, AttemptNumber: recovered.Delivery.AttemptCount, + Outcome: billingwebhook.OutcomeDelivered, Status: billingwebhook.DeliverySucceeded, + AttemptedAt: afterLease, RespondedAt: &completedAt, CompletedAt: &completedAt, + ResetDestinationFailures: true, + }); err != nil { + t.Fatal(err) + } + + if deliveries := countRows(t, ctx, pool, + `SELECT count(*) FROM webhook_deliveries WHERE webhook_event_id=$1 AND webhook_destination_id=$2`, + f.eventID, f.destinationID); deliveries != 1 { + t.Fatalf("%d delivery rows for one (event, destination), want exactly 1", deliveries) + } + if delivered := countRows(t, ctx, pool, + `SELECT count(*) FROM webhook_delivery_attempts WHERE webhook_delivery_id=$1 AND outcome='delivered'`, + leased.Delivery.ID); delivered != 1 { + t.Fatalf("%d delivered attempts recorded, want exactly 1", delivered) + } +} + +// Two workers polling at the same instant must not both claim one delivery. +// +// The claim is SELECT ... FOR UPDATE SKIP LOCKED inside a transaction that +// commits before any HTTP request. If the skip-locked claim were ever relaxed +// into a plain read, both workers would send the same entitlement change and +// both would try to record attempt number one — and the attempt-number +// uniqueness is what makes the second write a silent no-op rather than a +// visible failure, so a double send would leave no trace in history at all. +func TestConcurrentWorkersClaimOneDeliveryExactlyOnce(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + f := seedWebhookFixture(t, ctx, pool, "race") + now := time.Now().UTC() + + if _, err := repository.FanOut(ctx, now, 10); err != nil { + t.Fatal(err) + } + + const workers = 4 + var start sync.WaitGroup + var done sync.WaitGroup + start.Add(1) + results := make([]billingwebhook.LeasedDelivery, workers) + claimed := make([]bool, workers) + errs := make([]error, workers) + for index := 0; index < workers; index++ { + done.Add(1) + go func(index int) { + defer done.Done() + start.Wait() + results[index], claimed[index], errs[index] = repository.LeaseDelivery(ctx, + "worker-"+string(rune('a'+index)), now, now.Add(billingwebhook.DeliveryLease)) + }(index) + } + start.Done() + done.Wait() + + winners := 0 + winner := billingwebhook.LeasedDelivery{} + for index := range results { + if errs[index] != nil { + t.Fatalf("worker %d failed to poll: %v", index, errs[index]) + } + if claimed[index] { + winners++ + winner = results[index] + } + } + if winners != 1 { + t.Fatalf("%d of %d workers claimed the same delivery, want exactly 1", winners, workers) + } + + // The losers still record what they would have: the uniqueness on + // (event, destination, attempt_number) is the last line of defence, and it + // is worth proving it holds rather than assuming the claim always will. + completedAt := now.Add(time.Second) + for attempt := 0; attempt < 2; attempt++ { + if _, err := repository.CompleteAttempt(ctx, billingwebhook.AttemptResult{ + Delivery: winner.Delivery, AttemptNumber: winner.Delivery.AttemptCount, + Outcome: billingwebhook.OutcomeDelivered, Status: billingwebhook.DeliverySucceeded, + AttemptedAt: now, RespondedAt: &completedAt, CompletedAt: &completedAt, + ResetDestinationFailures: true, + }); err != nil { + t.Fatal(err) + } + } + + if attempts := countRows(t, ctx, pool, + `SELECT count(*) FROM webhook_delivery_attempts + WHERE webhook_event_id=$1 AND webhook_destination_id=$2 AND attempt_number=1`, + f.eventID, f.destinationID); attempts != 1 { + t.Fatalf("%d rows recorded for attempt number 1, want exactly 1", attempts) + } + if deliveries := countRows(t, ctx, pool, + `SELECT count(*) FROM webhook_deliveries WHERE webhook_event_id=$1`, f.eventID); deliveries != 1 { + t.Fatalf("%d delivery rows for one event, want exactly 1", deliveries) + } +} + +// A replayed delivery must re-send byte-identical signed content. +// +// An operator replays because the receiver did not get, or could not process, +// the original. If the replay sent a re-rendered body, the receiver would +// verify a signature over bytes that differ from what Mosaic first sent, and +// any receiver deduplicating on a content digest would treat the replay as a +// new change. The event id is stable across the replay for the same reason: a +// replay is a new delivery attempt, never a new logical event. +func TestExhaustedDeliveryReplaysByteIdenticalSignedBody(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + f := seedWebhookFixture(t, ctx, pool, "replay") + now := time.Now().UTC() + + if _, err := repository.FanOut(ctx, now, 10); err != nil { + t.Fatal(err) + } + // One attempt of one, so the delivery exhausts on its first failure rather + // than looping eight times to reach the state under test. + if _, err := pool.Exec(ctx, + `UPDATE webhook_deliveries SET max_attempts=1 WHERE webhook_event_id=$1`, f.eventID); err != nil { + t.Fatal(err) + } + + first, ok, err := repository.LeaseDelivery(ctx, "worker-a", now, now.Add(billingwebhook.DeliveryLease)) + if err != nil || !ok { + t.Fatalf("first lease: ok=%t err=%v", ok, err) + } + failedAt := now.Add(time.Second) + if _, err := repository.CompleteAttempt(ctx, billingwebhook.AttemptResult{ + Delivery: first.Delivery, AttemptNumber: first.Delivery.AttemptCount, + Outcome: billingwebhook.OutcomeExhausted, Status: billingwebhook.DeliveryExhausted, + ErrorCode: "destination_error", AttemptedAt: now, RespondedAt: &failedAt, CompletedAt: &failedAt, + IncrementDestinationFailures: true, + }); err != nil { + t.Fatal(err) + } + + replayed, err := repository.ReplayDelivery(ctx, f.projectID, first.Delivery.ID, "actor_operator", failedAt) + if err != nil { + t.Fatal(err) + } + if replayed.Status != billingwebhook.DeliveryPending { + t.Fatalf("replayed delivery is %q, want pending", replayed.Status) + } + + second, ok, err := repository.LeaseDelivery(ctx, "worker-b", failedAt, + failedAt.Add(billingwebhook.DeliveryLease)) + if err != nil || !ok { + t.Fatalf("lease after replay: ok=%t err=%v", ok, err) + } + if second.Delivery.EventID != first.Delivery.EventID { + t.Fatalf("replay changed the event id from %s to %s; a retry must never be a new logical event", + first.Delivery.EventID, second.Delivery.EventID) + } + if second.Delivery.AttemptCount == first.Delivery.AttemptCount { + t.Fatalf("replay reused attempt number %d; the replayed attempt would be discarded by the "+ + "attempt-number uniqueness and vanish from history", second.Delivery.AttemptCount) + } + if !bytes.Equal(first.Body, second.Body) { + t.Fatalf("replayed body differs from the original:\nfirst: %s\nsecond: %s", + first.Body, second.Body) + } + + // The signature is over the body, so identical bytes under one timestamp and + // one secret must produce one signature. This is the property a receiver + // actually checks. + const secret = "whsec_fixture_secret_value" + timestamp := failedAt.Unix() + if a, b := billingwebhook.Sign(secret, timestamp, first.Delivery.EventID, first.Body), + billingwebhook.Sign(secret, timestamp, second.Delivery.EventID, second.Body); a != b { + t.Fatalf("replayed signature %s differs from the original %s", b, a) + } +} diff --git a/apps/api/internal/platform/billingwebhookpostgres/queue_metrics.go b/apps/api/internal/platform/billingwebhookpostgres/queue_metrics.go new file mode 100644 index 00000000..d31790d7 --- /dev/null +++ b/apps/api/internal/platform/billingwebhookpostgres/queue_metrics.go @@ -0,0 +1,90 @@ +package billingwebhookpostgres + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const queueMetricTimeout = 5 * time.Second + +// RegisterQueueMetrics publishes backlog depth, oldest-job age, and exhausted +// count for the webhook delivery queue. +// +// Same three signals and the same metric names as the billing queues, so the +// existing backlog and dead-letter runbooks apply to webhooks without a second +// vocabulary. Depth alone cannot distinguish a busy queue from a stuck one, +// which is why oldest age is the alerting signal. +// +// The exhausted gauge is the one that matters most here and has no equivalent +// elsewhere in Mosaic: an exhausted delivery is a customer's backend that was +// never told about an entitlement change, and nothing else in the system +// surfaces that. It is deliberately published from day one rather than added +// after the first "our access never updated" report. +func (r *Repository) RegisterQueueMetrics() error { + meter := otel.Meter("mosaic/billingwebhook") + depth, err := meter.Int64ObservableGauge("mosaic.worker.queue.depth", + metric.WithDescription("Jobs waiting or leased in a Mosaic worker queue.")) + if err != nil { + return fmt.Errorf("register webhook queue depth gauge: %w", err) + } + oldest, err := meter.Float64ObservableGauge("mosaic.worker.queue.oldest_age_seconds", + metric.WithDescription("Age of the oldest unfinished job in a Mosaic worker queue."), + metric.WithUnit("s")) + if err != nil { + return fmt.Errorf("register webhook queue age gauge: %w", err) + } + deadLettered, err := meter.Int64ObservableGauge("mosaic.worker.queue.dead_lettered", + metric.WithDescription("Jobs that exhausted their attempts in a Mosaic worker queue.")) + if err != nil { + return fmt.Errorf("register webhook dead-letter gauge: %w", err) + } + disabled, err := meter.Int64ObservableGauge("mosaic.billing.webhook.destinations.disabled", + metric.WithDescription("Webhook destinations Mosaic disabled automatically.")) + if err != nil { + return fmt.Errorf("register webhook auto-disable gauge: %w", err) + } + + attributes := metric.WithAttributes( + attribute.String("family", "billing"), + attribute.String("queue", "webhook_delivery"), + ) + _, err = meter.RegisterCallback(func(ctx context.Context, observer metric.Observer) error { + ctx, cancel := context.WithTimeout(ctx, queueMetricTimeout) + defer cancel() + + var count, exhausted int64 + var age *float64 + row := r.pool.QueryRow(ctx, + `SELECT + count(*) FILTER (WHERE status = 'pending'), + max(extract(epoch from (now()-created_at))) FILTER (WHERE status = 'pending'), + count(*) FILTER (WHERE status IN ('exhausted','failed')) + FROM webhook_deliveries`) + if err := row.Scan(&count, &age, &exhausted); err == nil { + observer.ObserveInt64(depth, count, attributes) + observer.ObserveInt64(deadLettered, exhausted, attributes) + seconds := 0.0 + if age != nil { + seconds = *age + } + observer.ObserveFloat64(oldest, seconds, attributes) + } + + var autoDisabled int64 + if err := r.pool.QueryRow(ctx, + `SELECT count(*) FROM webhook_destinations WHERE auto_disabled_at IS NOT NULL`). + Scan(&autoDisabled); err == nil { + observer.ObserveInt64(disabled, autoDisabled) + } + return nil + }, depth, oldest, deadLettered, disabled) + if err != nil { + return fmt.Errorf("register webhook queue metric callback: %w", err) + } + return nil +} diff --git a/apps/api/internal/platform/billingwebhookpostgres/repository.go b/apps/api/internal/platform/billingwebhookpostgres/repository.go new file mode 100644 index 00000000..908cc5e4 --- /dev/null +++ b/apps/api/internal/platform/billingwebhookpostgres/repository.go @@ -0,0 +1,1023 @@ +// Package billingwebhookpostgres is the PostgreSQL implementation of the +// webhook persistence port. +// +// Two invariants shape almost every statement here. Every tenant-owned read +// and write filters on project_id, so isolation is a property of the query +// rather than of a caller remembering to check. And the delivery lease is +// committed before the caller makes any HTTP request: SELECT ... FOR UPDATE +// SKIP LOCKED claims the row, the transaction closes, and the network call +// happens with no lock held. +package billingwebhookpostgres + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +var _ billingwebhook.Repository = (*Repository)(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, ''), + 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.DisabledReason, &destination.ConsecutiveFailureCount, + &destination.AutoDisabledAt, &destination.AutoDisableReason) + return destination, err +} + +func (r *Repository) BillingEnabled(ctx context.Context, projectID string) (bool, error) { + var enabled bool + err := r.pool.QueryRow(ctx, + `SELECT billing_enabled FROM billing_project_settings WHERE project_id = $1`, projectID). + Scan(&enabled) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read billing enablement: %w", err) + } + return enabled, nil +} + +func (r *Repository) OrganizationForProject(ctx context.Context, projectID string) (string, error) { + var organizationID string + if err := r.pool.QueryRow(ctx, + `SELECT organization_id FROM projects WHERE id = $1`, projectID).Scan(&organizationID); err != nil { + return "", billingwebhook.ErrNotFound + } + return organizationID, nil +} + +// --------------------------------------------------------------------------- +// Destinations +// --------------------------------------------------------------------------- + +func (r *Repository) CreateDestination(ctx context.Context, destination billingwebhook.Destination, + secret billingwebhook.SealedSecret, actorID string, now time.Time) (billingwebhook.Destination, error) { + + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingwebhook.Destination{}, fmt.Errorf("begin webhook destination create: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, + `INSERT INTO webhook_destinations( + id, project_id, environment_id, url, status, event_types, description, + created_at, updated_at, created_by_actor_id, secret_last_rotated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8,$9,$8)`, + destination.ID, destination.ProjectID, destination.EnvironmentID, destination.URL, + destination.Status, destination.EventTypes, destination.Description, 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 { + return billingwebhook.Destination{}, err + } + // The destination and its first secret commit together. A destination that + // existed without a secret could never sign a delivery, and every event it + // received would fail for a reason no operator could act on. + if err := recordAudit(ctx, tx, destination.ProjectID, destination.EnvironmentID, actorID, + "billing.webhook.destination.created", destination.ID, nil, now); err != nil { + return billingwebhook.Destination{}, err + } + if err := tx.Commit(ctx); err != nil { + return billingwebhook.Destination{}, fmt.Errorf("commit webhook destination create: %w", err) + } + return r.Destination(ctx, destination.ProjectID, destination.ID) +} + +func insertSecret(ctx context.Context, tx pgx.Tx, projectID, destinationID string, + secret billingwebhook.SealedSecret, now time.Time) error { + + _, err := tx.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 ($1,$2,$3,'active',$4,$5,$6,$7,$8,$9,$10)`, + secret.ID, projectID, destinationID, secret.EnvelopeVersion, secret.Algorithm, + secret.KeyID, secret.Nonce, secret.Ciphertext, secret.Fingerprint, now) + if err != nil { + return translate(err, "insert webhook signing secret") + } + return nil +} + +func (r *Repository) ListDestinations(ctx context.Context, projectID, environmentID string) ([]billingwebhook.Destination, error) { + rows, err := r.pool.Query(ctx, + `SELECT `+destinationColumns+` FROM webhook_destinations + WHERE project_id = $1 AND ($2 = '' OR environment_id = $2) + ORDER BY created_at DESC, id`, projectID, environmentID) + if err != nil { + return nil, fmt.Errorf("list webhook destinations: %w", err) + } + defer rows.Close() + + destinations := make([]billingwebhook.Destination, 0, 8) + for rows.Next() { + destination, err := scanDestination(rows) + if err != nil { + return nil, fmt.Errorf("scan webhook destination: %w", err) + } + destinations = append(destinations, destination) + } + return destinations, rows.Err() +} + +func (r *Repository) Destination(ctx context.Context, projectID, destinationID string) (billingwebhook.Destination, error) { + destination, err := scanDestination(r.pool.QueryRow(ctx, + `SELECT `+destinationColumns+` FROM webhook_destinations WHERE id = $1 AND project_id = $2`, + destinationID, projectID)) + if errors.Is(err, pgx.ErrNoRows) { + // A destination in another Project is reported absent rather than + // forbidden: a caller must not be able to probe for another tenant's + // destinations by comparing 404 against 403. + return billingwebhook.Destination{}, billingwebhook.ErrNotFound + } + if err != nil { + return billingwebhook.Destination{}, fmt.Errorf("read webhook destination: %w", err) + } + return destination, nil +} + +func (r *Repository) UpdateDestination(ctx context.Context, projectID, destinationID string, + update billingwebhook.DestinationUpdate, actorID string, now time.Time) (billingwebhook.Destination, error) { + + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingwebhook.Destination{}, fmt.Errorf("begin webhook destination update: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // COALESCE against a typed NULL keeps "leave this alone" and "set this to + // the empty string" distinguishable, which a plain string parameter cannot. + tag, err := tx.Exec(ctx, + `UPDATE webhook_destinations + SET url = coalesce($3, url), + event_types = coalesce($4, event_types), + description = coalesce($5, description), + updated_at = $6 + WHERE id = $1 AND project_id = $2`, + destinationID, projectID, update.URL, nullableArray(update.EventTypes), update.Description, now) + if err != nil { + return billingwebhook.Destination{}, translate(err, "update webhook destination") + } + if tag.RowsAffected() == 0 { + return billingwebhook.Destination{}, billingwebhook.ErrNotFound + } + environmentID, err := environmentOf(ctx, tx, projectID, destinationID) + if err != nil { + return billingwebhook.Destination{}, err + } + if err := recordAudit(ctx, tx, projectID, environmentID, actorID, + "billing.webhook.destination.updated", destinationID, nil, now); err != nil { + return billingwebhook.Destination{}, err + } + if err := tx.Commit(ctx); err != nil { + return billingwebhook.Destination{}, fmt.Errorf("commit webhook destination update: %w", err) + } + return r.Destination(ctx, projectID, destinationID) +} + +func (r *Repository) SetDestinationStatus(ctx context.Context, projectID, destinationID, status, reason, actorID string, + now time.Time) (billingwebhook.Destination, error) { + + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingwebhook.Destination{}, fmt.Errorf("begin webhook destination status change: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // Returning a destination to active clears the automatic disable state and + // its failure counter. Without that, a destination Mosaic disabled would be + // re-disabled by the very next failure, and an operator's fix would look + // like it had not worked. + tag, err := tx.Exec(ctx, + `UPDATE webhook_destinations + SET status = $3, + disabled_reason = CASE WHEN $3 = 'active' OR $4 = '' THEN NULL ELSE $4 END, + auto_disabled_at = CASE WHEN $3 = 'active' THEN NULL ELSE auto_disabled_at END, + auto_disable_reason = CASE WHEN $3 = 'active' THEN NULL ELSE auto_disable_reason END, + consecutive_failure_count = CASE WHEN $3 = 'active' THEN 0 ELSE consecutive_failure_count END, + updated_at = $5 + WHERE id = $1 AND project_id = $2`, + destinationID, projectID, status, reason, now) + if err != nil { + return billingwebhook.Destination{}, translate(err, "update webhook destination status") + } + if tag.RowsAffected() == 0 { + return billingwebhook.Destination{}, billingwebhook.ErrNotFound + } + environmentID, err := environmentOf(ctx, tx, projectID, destinationID) + if err != nil { + return billingwebhook.Destination{}, err + } + if err := recordAudit(ctx, tx, projectID, environmentID, actorID, + "billing.webhook.destination.status_changed", destinationID, + map[string]string{"status": status}, now); err != nil { + return billingwebhook.Destination{}, err + } + if err := tx.Commit(ctx); err != nil { + return billingwebhook.Destination{}, fmt.Errorf("commit webhook destination status change: %w", err) + } + return r.Destination(ctx, projectID, destinationID) +} + +func (r *Repository) AutoDisableDestination(ctx context.Context, projectID, destinationID, reason string, now time.Time) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin webhook destination auto-disable: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // Guarded on the current status so two workers finishing failed deliveries + // at once produce one disable and one audit entry rather than two. + tag, err := tx.Exec(ctx, + `UPDATE webhook_destinations + SET status = 'disabled', auto_disabled_at = $4, auto_disable_reason = $3, updated_at = $4 + WHERE id = $1 AND project_id = $2 AND status <> 'disabled'`, + destinationID, projectID, reason, now) + if err != nil { + return translate(err, "auto-disable webhook destination") + } + if tag.RowsAffected() == 0 { + return nil + } + environmentID, err := environmentOf(ctx, tx, projectID, destinationID) + if err != nil { + return err + } + // The actor is Mosaic itself. An automatic disable is exactly the kind of + // change an operator later has to explain, so it is audited like any other. + if err := recordAudit(ctx, tx, projectID, environmentID, "system", + "billing.webhook.destination.auto_disabled", destinationID, + map[string]string{"reason": reason}, now); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (r *Repository) DeleteDestination(ctx context.Context, projectID, destinationID, actorID string, now time.Time) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin webhook destination delete: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + environmentID, err := environmentOf(ctx, tx, projectID, destinationID) + if err != nil { + return err + } + // Delivery history is the record of what a tenant's backend was told, and + // the destination row is what identifies it. Once history exists the + // supported answer is disable, not delete — the foreign keys would refuse + // anyway, and refusing here produces a conflict an operator can read + // instead of a constraint violation. + var deliveries int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM webhook_deliveries WHERE webhook_destination_id = $1 AND project_id = $2`, + destinationID, projectID).Scan(&deliveries); err != nil { + return fmt.Errorf("count webhook deliveries: %w", err) + } + if deliveries > 0 { + return billingwebhook.ErrConflict + } + if _, err := tx.Exec(ctx, + `DELETE FROM webhook_signing_secrets WHERE webhook_destination_id = $1 AND project_id = $2`, + destinationID, projectID); err != nil { + return translate(err, "delete webhook signing secrets") + } + tag, err := tx.Exec(ctx, + `DELETE FROM webhook_destinations WHERE id = $1 AND project_id = $2`, destinationID, projectID) + if err != nil { + return translate(err, "delete webhook destination") + } + if tag.RowsAffected() == 0 { + return billingwebhook.ErrNotFound + } + if err := recordAudit(ctx, tx, projectID, environmentID, actorID, + "billing.webhook.destination.deleted", destinationID, nil, now); err != nil { + return err + } + return tx.Commit(ctx) +} + +// --------------------------------------------------------------------------- +// Signing secrets +// --------------------------------------------------------------------------- + +func (r *Repository) RotateSecret(ctx context.Context, projectID, destinationID string, + secret billingwebhook.SealedSecret, honoredUntil time.Time, actorID string, now time.Time) (billingwebhook.SecretMetadata, error) { + + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingwebhook.SecretMetadata{}, fmt.Errorf("begin webhook secret rotation: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + environmentID, err := environmentOf(ctx, tx, projectID, destinationID) + if err != nil { + return billingwebhook.SecretMetadata{}, err + } + // Retire first, then insert. Doing it in this order means the new secret is + // never caught by the retirement statement, so a rotation cannot retire the + // secret it just created. + if _, err := tx.Exec(ctx, + `UPDATE webhook_signing_secrets + SET status = 'retired', retired_at = $3, honored_until = $4 + WHERE webhook_destination_id = $1 AND project_id = $2 AND status = 'active'`, + destinationID, projectID, now, honoredUntil); err != nil { + return billingwebhook.SecretMetadata{}, translate(err, "retire webhook signing secrets") + } + if err := insertSecret(ctx, tx, projectID, destinationID, secret, now); err != nil { + return billingwebhook.SecretMetadata{}, err + } + if _, err := tx.Exec(ctx, + `UPDATE webhook_destinations SET secret_last_rotated_at = $3, updated_at = $3 + WHERE id = $1 AND project_id = $2`, destinationID, projectID, now); err != nil { + return billingwebhook.SecretMetadata{}, translate(err, "stamp webhook secret rotation") + } + if err := recordAudit(ctx, tx, projectID, environmentID, actorID, + "billing.webhook.secret.rotated", destinationID, + map[string]string{"honoredUntil": honoredUntil.UTC().Format(time.RFC3339)}, now); err != nil { + return billingwebhook.SecretMetadata{}, err + } + if err := tx.Commit(ctx); err != nil { + return billingwebhook.SecretMetadata{}, fmt.Errorf("commit webhook secret rotation: %w", err) + } + return billingwebhook.SecretMetadata{ID: secret.ID, Status: billingwebhook.SecretActive, CreatedAt: now}, nil +} + +func (r *Repository) ListSecrets(ctx context.Context, projectID, destinationID string) ([]billingwebhook.SecretMetadata, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, status, created_at, retired_at, honored_until + FROM webhook_signing_secrets + WHERE webhook_destination_id = $1 AND project_id = $2 + ORDER BY created_at DESC, id`, destinationID, projectID) + if err != nil { + return nil, fmt.Errorf("list webhook signing secrets: %w", err) + } + defer rows.Close() + + secrets := make([]billingwebhook.SecretMetadata, 0, 4) + for rows.Next() { + var secret billingwebhook.SecretMetadata + if err := rows.Scan(&secret.ID, &secret.Status, &secret.CreatedAt, + &secret.RetiredAt, &secret.HonoredUntil); err != nil { + return nil, fmt.Errorf("scan webhook signing secret: %w", err) + } + secrets = append(secrets, secret) + } + return secrets, rows.Err() +} + +func (r *Repository) RetireSecret(ctx context.Context, projectID, destinationID, secretID, actorID string, + now time.Time) (billingwebhook.SecretMetadata, error) { + + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingwebhook.SecretMetadata{}, fmt.Errorf("begin webhook secret retirement: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // A destination must keep at least one secret that can sign. Retiring the + // last one would leave every subsequent delivery unsignable, and an + // unsigned entitlement webhook is an unauthenticated instruction to grant + // access — so the operation is refused rather than silently degrading. + var remaining int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM webhook_signing_secrets + WHERE webhook_destination_id = $1 AND project_id = $2 AND status = 'active' AND id <> $3`, + destinationID, projectID, secretID).Scan(&remaining); err != nil { + return billingwebhook.SecretMetadata{}, fmt.Errorf("count active webhook signing secrets: %w", err) + } + + var secret billingwebhook.SecretMetadata + // honored_until is set to the retirement instant rather than left in place: + // an explicit retirement ends the overlap now, which is the entire point of + // taking the action after a suspected compromise. + err = tx.QueryRow(ctx, + `UPDATE webhook_signing_secrets + SET status = 'retired', retired_at = coalesce(retired_at, $4), honored_until = $4 + WHERE id = $1 AND project_id = $2 AND webhook_destination_id = $3 + RETURNING id, status, created_at, retired_at, honored_until`, + secretID, projectID, destinationID, now). + Scan(&secret.ID, &secret.Status, &secret.CreatedAt, &secret.RetiredAt, &secret.HonoredUntil) + if errors.Is(err, pgx.ErrNoRows) { + return billingwebhook.SecretMetadata{}, billingwebhook.ErrNotFound + } + if err != nil { + return billingwebhook.SecretMetadata{}, translate(err, "retire webhook signing secret") + } + if remaining == 0 { + return billingwebhook.SecretMetadata{}, billingwebhook.ErrConflict + } + environmentID, err := environmentOf(ctx, tx, projectID, destinationID) + if err != nil { + return billingwebhook.SecretMetadata{}, err + } + if err := recordAudit(ctx, tx, projectID, environmentID, actorID, + "billing.webhook.secret.retired", destinationID, + map[string]string{"secretId": secretID}, now); err != nil { + return billingwebhook.SecretMetadata{}, err + } + if err := tx.Commit(ctx); err != nil { + return billingwebhook.SecretMetadata{}, fmt.Errorf("commit webhook secret retirement: %w", err) + } + return secret, nil +} + +// --------------------------------------------------------------------------- +// Fan-out +// --------------------------------------------------------------------------- + +// FanOut expands committed events into deliveries. +// +// The whole expansion of one event is one transaction, so an event is either +// fully expanded or not expanded at all: a partial fan-out that recorded its +// marker would permanently skip the destinations it had not reached. +func (r *Repository) FanOut(ctx context.Context, now time.Time, limit int) (int, error) { + if limit <= 0 { + limit = billingwebhook.FanOutBatch + } + rows, err := r.pool.Query(ctx, + `SELECT e.id, e.project_id + FROM webhook_events e + LEFT JOIN webhook_event_fanouts f ON f.webhook_event_id = e.id + WHERE f.webhook_event_id IS NULL AND e.created_at > $1 + ORDER BY e.created_at, e.id + LIMIT $2`, now.Add(-billingwebhook.FanOutHorizon), limit) + if err != nil { + return 0, fmt.Errorf("select unexpanded webhook events: %w", err) + } + type pending struct{ eventID, projectID string } + events := make([]pending, 0, limit) + for rows.Next() { + var event pending + if err := rows.Scan(&event.eventID, &event.projectID); err != nil { + rows.Close() + return 0, fmt.Errorf("scan unexpanded webhook event: %w", err) + } + events = append(events, event) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("select unexpanded webhook events: %w", err) + } + + expanded := 0 + for _, event := range events { + if err := r.fanOutEvent(ctx, event.eventID, event.projectID, now); err != nil { + return expanded, err + } + expanded++ + } + return expanded, nil +} + +func (r *Repository) fanOutEvent(ctx context.Context, eventID, projectID string, now time.Time) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin webhook fan-out: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // Delivery ids are derived from (event, destination) rather than random, so + // a fan-out interrupted after the insert and before the marker produces the + // same ids on its next run and the ON CONFLICT absorbs it. Combined with + // UNIQUE (webhook_event_id, webhook_destination_id), a crashed fan-out can + // never send a destination the same event twice. + // + // A destination that is paused, disabled, or not subscribed to this event + // type still gets a row, in terminal `skipped` state with its reason. An + // operator asking "why did my endpoint not receive this?" then has an + // answer other than silence. + if _, err := tx.Exec(ctx, + `INSERT INTO webhook_deliveries( + id, project_id, environment_id, webhook_event_id, webhook_destination_id, + status, skipped_reason, attempt_count, max_attempts, next_attempt_at, + created_at, updated_at, completed_at) + SELECT 'whdl_' || md5(e.id || ':' || d.id), e.project_id, e.environment_id, e.id, d.id, + CASE WHEN d.status = 'active' AND e.event_type = ANY(d.event_types) + THEN 'pending' ELSE 'skipped' END, + CASE WHEN d.status = 'active' AND e.event_type = ANY(d.event_types) THEN NULL + WHEN d.status <> 'active' THEN 'destination_disabled' + ELSE 'event_type_not_enabled' END, + 0, $3::integer, + -- The casts are load-bearing: inside a CASE whose other arm is + -- NULL, an uncast parameter is inferred as text and the insert + -- fails against a timestamptz column. + CASE WHEN d.status = 'active' AND e.event_type = ANY(d.event_types) THEN $2::timestamptz ELSE NULL END, + $2::timestamptz, $2::timestamptz, + CASE WHEN d.status = 'active' AND e.event_type = ANY(d.event_types) THEN NULL ELSE $2::timestamptz END + FROM webhook_events e + JOIN webhook_destinations d + ON d.project_id = e.project_id AND d.environment_id = e.environment_id + WHERE e.id = $1 + ON CONFLICT (webhook_event_id, webhook_destination_id) DO NOTHING`, + eventID, now, billingwebhook.DefaultMaxAttempts); err != nil { + return translate(err, "expand webhook event into deliveries") + } + + // A skip is a recorded attempt, not an absence of one. The delivery + // contract's attempt record has a `skipped` status precisely so this shows + // up in attempt history rather than only as a delivery status. + if _, err := tx.Exec(ctx, + `INSERT INTO webhook_delivery_attempts( + id, project_id, webhook_event_id, webhook_destination_id, webhook_delivery_id, + attempt_number, max_attempts, outcome, skipped_reason, attempted_at) + SELECT 'wha_' || md5(dl.id || ':1'), dl.project_id, dl.webhook_event_id, + dl.webhook_destination_id, dl.id, 1, dl.max_attempts, 'skipped', + dl.skipped_reason, $2 + FROM webhook_deliveries dl + WHERE dl.webhook_event_id = $1 AND dl.status = 'skipped' + ON CONFLICT (webhook_event_id, webhook_destination_id, attempt_number) DO NOTHING`, + eventID, now); err != nil { + return translate(err, "record skipped webhook attempts") + } + + if _, err := tx.Exec(ctx, + `INSERT INTO webhook_event_fanouts(webhook_event_id, project_id, delivery_count, skipped_count, fanned_out_at) + SELECT $1, $2, + count(*) FILTER (WHERE dl.status <> 'skipped'), + count(*) FILTER (WHERE dl.status = 'skipped'), + $3 + FROM webhook_deliveries dl WHERE dl.webhook_event_id = $1 + ON CONFLICT (webhook_event_id) DO NOTHING`, + eventID, projectID, now); err != nil { + return translate(err, "mark webhook event fanned out") + } + return tx.Commit(ctx) +} + +// --------------------------------------------------------------------------- +// Delivery +// --------------------------------------------------------------------------- + +// LeaseDelivery claims one due delivery. +// +// The claim commits before this returns. Everything the attempt needs — the +// destination row, the stored body, the organization, and every signing secret +// still permitted to sign — is read afterwards, outside the transaction: a +// delivery that takes twenty seconds against a slow destination must not hold +// a row lock for twenty seconds. +func (r *Repository) LeaseDelivery(ctx context.Context, workerID string, now, leaseUntil time.Time) (billingwebhook.LeasedDelivery, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingwebhook.LeasedDelivery{}, false, fmt.Errorf("begin webhook delivery lease: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var delivery billingwebhook.Delivery + err = tx.QueryRow(ctx, + `SELECT id, project_id, environment_id, webhook_event_id, webhook_destination_id, + attempt_count, max_attempts, created_at, updated_at + FROM webhook_deliveries + WHERE status = 'pending' AND next_attempt_at <= $1 + AND (leased_until IS NULL OR leased_until <= $1) + AND attempt_count < max_attempts + ORDER BY next_attempt_at, id + FOR UPDATE SKIP LOCKED LIMIT 1`, now). + Scan(&delivery.ID, &delivery.ProjectID, &delivery.EnvironmentID, &delivery.EventID, + &delivery.DestinationID, &delivery.AttemptCount, &delivery.MaxAttempts, + &delivery.CreatedAt, &delivery.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return billingwebhook.LeasedDelivery{}, false, nil + } + if err != nil { + return billingwebhook.LeasedDelivery{}, false, fmt.Errorf("select webhook delivery: %w", err) + } + // attempt_count advances at claim time, not at completion. A worker that + // dies mid-request has still consumed an attempt, which is what stops a + // 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 + WHERE id = $1`, delivery.ID, workerID, leaseUntil, now); err != nil { + return billingwebhook.LeasedDelivery{}, false, fmt.Errorf("lease webhook delivery: %w", err) + } + delivery.AttemptCount++ + delivery.Status = billingwebhook.DeliveryPending + if err := tx.Commit(ctx); err != nil { + return billingwebhook.LeasedDelivery{}, false, fmt.Errorf("commit webhook delivery lease: %w", err) + } + + leased := billingwebhook.LeasedDelivery{Delivery: delivery} + if err := r.pool.QueryRow(ctx, + `SELECT e.event_type, e.payload::text, 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 { + return billingwebhook.LeasedDelivery{}, false, fmt.Errorf("read webhook event body: %w", err) + } + destination, err := r.Destination(ctx, delivery.ProjectID, delivery.DestinationID) + if err != nil { + return billingwebhook.LeasedDelivery{}, false, err + } + leased.Destination = destination + + secrets, err := r.signingSecrets(ctx, delivery.ProjectID, delivery.DestinationID, now) + if err != nil { + return billingwebhook.LeasedDelivery{}, false, err + } + leased.Secrets = secrets + return leased, true, nil +} + +// signingSecrets reads every secret still permitted to sign: the active ones, +// plus retired ones whose overlap window has not lapsed. +func (r *Repository) signingSecrets(ctx context.Context, projectID, destinationID string, now time.Time) ([]billingwebhook.StoredSecret, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, status, envelope_version, algorithm, key_id, nonce, ciphertext, fingerprint, honored_until + FROM webhook_signing_secrets + WHERE webhook_destination_id = $1 AND project_id = $2 + AND (status = 'active' OR honored_until > $3) + ORDER BY status, created_at DESC, id`, destinationID, projectID, now) + if err != nil { + return nil, fmt.Errorf("read webhook signing secrets: %w", err) + } + defer rows.Close() + + secrets := make([]billingwebhook.StoredSecret, 0, 2) + for rows.Next() { + var secret billingwebhook.StoredSecret + if err := rows.Scan(&secret.ID, &secret.Status, &secret.EnvelopeVersion, &secret.Algorithm, + &secret.KeyID, &secret.Nonce, &secret.Ciphertext, &secret.Fingerprint, + &secret.HonoredUntil); err != nil { + return nil, fmt.Errorf("scan webhook signing secret: %w", err) + } + secrets = append(secrets, secret) + } + return secrets, rows.Err() +} + +// CompleteAttempt appends the attempt and applies the resulting delivery state +// in one transaction, then reports the destination's consecutive failure count. +func (r *Repository) CompleteAttempt(ctx context.Context, result billingwebhook.AttemptResult) (int, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin webhook attempt completion: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + attemptID := "wha_" + shortHash(result.Delivery.ID, result.AttemptNumber) + if _, err := tx.Exec(ctx, + `INSERT INTO webhook_delivery_attempts( + id, project_id, webhook_event_id, webhook_destination_id, webhook_delivery_id, + attempt_number, max_attempts, outcome, response_status, error_code, latency_ms, + attempted_at, responded_at, next_attempt_at, response_excerpt, skipped_reason) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NULLIF($10,''),$11,$12,$13,$14,NULLIF($15,''),NULLIF($16,'')) + ON CONFLICT (webhook_event_id, webhook_destination_id, attempt_number) DO NOTHING`, + attemptID, result.Delivery.ProjectID, result.Delivery.EventID, result.Delivery.DestinationID, + result.Delivery.ID, result.AttemptNumber, result.Delivery.MaxAttempts, result.Outcome, + result.ResponseStatus, result.ErrorCode, nullableInt(result.LatencyMS), + result.AttemptedAt, result.RespondedAt, result.NextAttemptAt, + result.ResponseExcerpt, result.SkippedReason); err != nil { + return 0, translate(err, "insert webhook delivery attempt") + } + + // The lease is released in the same statement that records the outcome, so + // a delivery is never left leased to a worker that has already finished + // with it. + settled := result.AttemptedAt + if result.RespondedAt != nil { + settled = *result.RespondedAt + } + if _, err := tx.Exec(ctx, + `UPDATE webhook_deliveries + SET status = $3, skipped_reason = NULLIF($4,''), next_attempt_at = $5, + completed_at = $6, leased_by = NULL, leased_until = NULL, updated_at = $7 + WHERE id = $1 AND project_id = $2`, + result.Delivery.ID, result.Delivery.ProjectID, result.Status, result.SkippedReason, + result.NextAttemptAt, result.CompletedAt, settled); err != nil { + return 0, translate(err, "update webhook delivery") + } + + failures := 0 + switch { + case result.ResetDestinationFailures: + if err := tx.QueryRow(ctx, + `UPDATE webhook_destinations SET consecutive_failure_count = 0, updated_at = $3 + WHERE id = $1 AND project_id = $2 + RETURNING consecutive_failure_count`, + result.Delivery.DestinationID, result.Delivery.ProjectID, settled). + 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 + WHERE id = $1 AND project_id = $2 + RETURNING consecutive_failure_count`, + result.Delivery.DestinationID, result.Delivery.ProjectID, settled). + Scan(&failures); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return 0, translate(err, "increment webhook destination failures") + } + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit webhook attempt completion: %w", err) + } + return failures, nil +} + +const deliveryColumns = `id, project_id, environment_id, webhook_event_id, webhook_destination_id, + status, coalesce(skipped_reason, ''), attempt_count, max_attempts, next_attempt_at, + created_at, updated_at, completed_at` + +func scanDelivery(row pgx.Row) (billingwebhook.Delivery, error) { + var delivery billingwebhook.Delivery + err := row.Scan(&delivery.ID, &delivery.ProjectID, &delivery.EnvironmentID, &delivery.EventID, + &delivery.DestinationID, &delivery.Status, &delivery.SkippedReason, &delivery.AttemptCount, + &delivery.MaxAttempts, &delivery.NextAttemptAt, &delivery.CreatedAt, &delivery.UpdatedAt, + &delivery.CompletedAt) + return delivery, err +} + +func (r *Repository) ListDeliveries(ctx context.Context, projectID string, filter billingwebhook.DeliveryFilter) ([]billingwebhook.Delivery, error) { + rows, err := r.pool.Query(ctx, + `SELECT `+deliveryColumns+` FROM webhook_deliveries + WHERE project_id = $1 + AND ($2 = '' OR environment_id = $2) + AND ($3 = '' OR webhook_event_id = $3) + AND ($4 = '' OR webhook_destination_id = $4) + AND ($5 = '' OR status = $5) + ORDER BY created_at DESC, id + LIMIT $6`, + projectID, filter.EnvironmentID, filter.EventID, filter.DestinationID, filter.Status, filter.Limit) + if err != nil { + return nil, fmt.Errorf("list webhook deliveries: %w", err) + } + defer rows.Close() + + deliveries := make([]billingwebhook.Delivery, 0, filter.Limit) + for rows.Next() { + delivery, err := scanDelivery(rows) + if err != nil { + return nil, fmt.Errorf("scan webhook delivery: %w", err) + } + deliveries = append(deliveries, delivery) + } + return deliveries, rows.Err() +} + +func (r *Repository) Delivery(ctx context.Context, projectID, deliveryID string) (billingwebhook.Delivery, error) { + delivery, err := scanDelivery(r.pool.QueryRow(ctx, + `SELECT `+deliveryColumns+` FROM webhook_deliveries WHERE id = $1 AND project_id = $2`, + deliveryID, projectID)) + if errors.Is(err, pgx.ErrNoRows) { + return billingwebhook.Delivery{}, billingwebhook.ErrNotFound + } + if err != nil { + return billingwebhook.Delivery{}, fmt.Errorf("read webhook delivery: %w", err) + } + return delivery, nil +} + +func (r *Repository) ListAttempts(ctx context.Context, projectID, deliveryID string) ([]billingwebhook.Attempt, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, webhook_delivery_id, webhook_event_id, webhook_destination_id, attempt_number, + max_attempts, outcome, response_status, coalesce(error_code, ''), latency_ms, + coalesce(response_excerpt, ''), coalesce(skipped_reason, ''), + attempted_at, responded_at, next_attempt_at + FROM webhook_delivery_attempts + WHERE webhook_delivery_id = $1 AND project_id = $2 + ORDER BY attempt_number`, deliveryID, projectID) + if err != nil { + return nil, fmt.Errorf("list webhook delivery attempts: %w", err) + } + defer rows.Close() + + attempts := make([]billingwebhook.Attempt, 0, 8) + for rows.Next() { + var attempt billingwebhook.Attempt + if err := rows.Scan(&attempt.ID, &attempt.DeliveryID, &attempt.EventID, &attempt.DestinationID, + &attempt.AttemptNumber, &attempt.MaxAttempts, &attempt.Outcome, &attempt.ResponseStatus, + &attempt.ErrorCode, &attempt.LatencyMS, &attempt.ResponseExcerpt, &attempt.SkippedReason, + &attempt.AttemptedAt, &attempt.RespondedAt, &attempt.NextAttemptAt); err != nil { + return nil, fmt.Errorf("scan webhook delivery attempt: %w", err) + } + attempts = append(attempts, attempt) + } + return attempts, rows.Err() +} + +// ReplayDelivery re-queues a terminal delivery with a fresh attempt budget. +// +// attempt_count is deliberately not reset. Attempt numbers are unique per +// delivery, so restarting the count would collide with the history already +// recorded and the replay's own attempt would be silently discarded; extending +// the budget instead keeps every attempt distinct and makes a replayed delivery +// readable as one continuous history. +func (r *Repository) ReplayDelivery(ctx context.Context, projectID, deliveryID, actorID string, now time.Time) (billingwebhook.Delivery, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billingwebhook.Delivery{}, fmt.Errorf("begin webhook delivery replay: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + existing, err := scanDelivery(tx.QueryRow(ctx, + `SELECT `+deliveryColumns+` FROM webhook_deliveries WHERE id = $1 AND project_id = $2 FOR UPDATE`, + deliveryID, projectID)) + if errors.Is(err, pgx.ErrNoRows) { + return billingwebhook.Delivery{}, billingwebhook.ErrNotFound + } + if err != nil { + return billingwebhook.Delivery{}, fmt.Errorf("read webhook delivery for replay: %w", err) + } + // Replaying a delivery that is still queued would double-send it. + if existing.Status == billingwebhook.DeliveryPending || + existing.AttemptCount >= billingwebhook.MaxAttemptsCeiling { + return billingwebhook.Delivery{}, billingwebhook.ErrConflict + } + + if _, 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, projectID, now, billingwebhook.MaxAttemptsCeiling, billingwebhook.DefaultMaxAttempts); err != nil { + return billingwebhook.Delivery{}, translate(err, "replay webhook delivery") + } + if err := recordAudit(ctx, tx, projectID, existing.EnvironmentID, actorID, + "billing.webhook.delivery.replayed", deliveryID, + map[string]string{"eventId": existing.EventID, "destinationId": existing.DestinationID}, now); err != nil { + return billingwebhook.Delivery{}, err + } + if err := tx.Commit(ctx); err != nil { + return billingwebhook.Delivery{}, fmt.Errorf("commit webhook delivery replay: %w", err) + } + return r.Delivery(ctx, projectID, deliveryID) +} + +// --------------------------------------------------------------------------- +// Audit and helpers +// --------------------------------------------------------------------------- + +func (r *Repository) RecordAudit(ctx context.Context, projectID, environmentID, actorID, action, resourceID string, + metadata map[string]string, at time.Time) error { + + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin webhook audit write: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := recordAudit(ctx, tx, projectID, environmentID, actorID, action, resourceID, metadata, at); err != nil { + return err + } + return tx.Commit(ctx) +} + +func recordAudit(ctx context.Context, tx pgx.Tx, projectID, environmentID, actorID, action, resourceID string, + metadata map[string]string, at time.Time) error { + + var organizationID string + if err := tx.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id = $1`, projectID). + Scan(&organizationID); err != nil { + return fmt.Errorf("read organization for webhook audit: %w", err) + } + encoded := []byte("{}") + if len(metadata) > 0 { + // Metadata is a fixed, Mosaic-authored map of identifiers and codes. No + // caller-supplied URL, secret, or response body ever reaches it. + var builder strings.Builder + builder.WriteByte('{') + first := true + for key, value := range metadata { + if !first { + builder.WriteByte(',') + } + first = false + builder.WriteString(quoteJSON(key)) + builder.WriteByte(':') + builder.WriteString(quoteJSON(value)) + } + builder.WriteByte('}') + encoded = []byte(builder.String()) + } + actor := actorID + if actor == "" { + actor = "system" + } + id := "aud_" + shortHash(resourceID+":"+action, int(at.UnixNano()%1_000_000_000)) + _, 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,NULLIF($5,''),$6,'webhook_destination',$7,$8,$9) + ON CONFLICT (id) DO NOTHING`, + id, actor, organizationID, projectID, environmentID, action, resourceID, encoded, at) + if err != nil { + return fmt.Errorf("insert webhook audit event: %w", err) + } + return nil +} + +// quoteJSON renders a JSON string. Audit metadata is a small map of +// Mosaic-owned keys and identifier values, so this avoids pulling a marshaller +// into the transaction path for two-key maps. +func quoteJSON(value string) string { + var builder strings.Builder + builder.WriteByte('"') + for _, character := range value { + switch character { + case '"': + builder.WriteString(`\"`) + case '\\': + builder.WriteString(`\\`) + default: + if character < 0x20 { + continue + } + builder.WriteRune(character) + } + } + builder.WriteByte('"') + return builder.String() +} + +func environmentOf(ctx context.Context, tx pgx.Tx, projectID, destinationID string) (string, error) { + var environmentID string + err := tx.QueryRow(ctx, + `SELECT environment_id FROM webhook_destinations WHERE id = $1 AND project_id = $2`, + destinationID, projectID).Scan(&environmentID) + if errors.Is(err, pgx.ErrNoRows) { + return "", billingwebhook.ErrNotFound + } + if err != nil { + return "", fmt.Errorf("read webhook destination environment: %w", err) + } + return environmentID, nil +} + +func nullableArray(values []string) any { + if values == nil { + return nil + } + return values +} + +func nullableInt(value int) any { + if value < 0 { + return nil + } + return value +} + +// shortHash builds a deterministic identifier suffix. Determinism is what makes +// the attempt insert idempotent under an ON CONFLICT retry. +func shortHash(value string, discriminator int) string { + seed := fmt.Sprintf("%s:%d", value, discriminator) + sum := uint64(1469598103934665603) + for index := 0; index < len(seed); index++ { + sum ^= uint64(seed[index]) + sum *= 1099511628211 + } + encoded := make([]byte, 8) + for index := 0; index < 8; index++ { + encoded[index] = byte(sum >> (index * 8)) + } + return base64.RawURLEncoding.EncodeToString(encoded) +} + +// translate maps the constraint violations this package can provoke onto stable +// domain errors, so no SQL error text ever reaches a handler. +func translate(err error, operation string) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "23503", "23514": + // Foreign key or check violation: the caller asked for a state the + // schema does not permit. + return billingwebhook.ErrConflict + case "23505": + return billingwebhook.ErrConflict + case "55000": + // An append-only trigger refused a rewrite of recorded history. + return billingwebhook.ErrConflict + } + } + return fmt.Errorf("%s: %w", operation, err) +} diff --git a/apps/api/internal/platform/config/config.go b/apps/api/internal/platform/config/config.go index bc55052a..c0ab5886 100644 --- a/apps/api/internal/platform/config/config.go +++ b/apps/api/internal/platform/config/config.go @@ -77,6 +77,41 @@ type BillingConfig struct { ObservationsPerMinute int `envconfig:"MOSAIC_BILLING_OBSERVATIONS_PER_MINUTE" default:"600"` ObservationBurst int `envconfig:"MOSAIC_BILLING_OBSERVATION_BURST" default:"120"` LimiterEntries int `envconfig:"MOSAIC_BILLING_LIMITER_ENTRIES" default:"10000"` + + // Entitlement sync is expected to be the highest-QPS authenticated surface + // Mosaic serves, so it carries its own bucket rather than sharing the + // observation one: shedding an observation costs latency, and shedding a + // sync costs a customer an answer about their own access. + EntitlementSyncPerMinute int `envconfig:"MOSAIC_BILLING_ENTITLEMENT_SYNC_PER_MINUTE" default:"1200"` + EntitlementSyncBurst int `envconfig:"MOSAIC_BILLING_ENTITLEMENT_SYNC_BURST" default:"240"` + + // Offline access policy (OD-5). The defaults are one hour to refresh and + // seven days of validity, with a twenty-four hour bounded grace past that. + // The combined horizon is capped at thirty days in code as well as here. + EntitlementRefreshAfter time.Duration `envconfig:"MOSAIC_BILLING_ENTITLEMENT_REFRESH_AFTER" default:"1h"` + EntitlementValidFor time.Duration `envconfig:"MOSAIC_BILLING_ENTITLEMENT_VALID_FOR" default:"168h"` + EntitlementStaleGraceHours int `envconfig:"MOSAIC_BILLING_ENTITLEMENT_STALE_GRACE_HOURS" default:"24"` + + // WebhookAllowPrivateDestinations is the self-hosted exception to the + // ADR-0024 SSRF policy: it permits webhook destinations that resolve to + // private address space, which operators running Mosaic and their + // application backend on one private network legitimately need. + // + // It is deployment-level on purpose and must never become a request field + // or a per-destination column. A per-destination override would let anyone + // holding destination-write permission point Mosaic at the internal + // network, which is the whole attack the policy exists to prevent. Off by + // default: a deployment that has not thought about it does not have it. + WebhookAllowPrivateDestinations bool `envconfig:"MOSAIC_BILLING_WEBHOOK_ALLOW_PRIVATE_DESTINATIONS" default:"false"` +} + +// EntitlementStaleGrace is the configured bounded-grace window as a duration. +// A strict policy is expressed as zero. +func (cfg BillingConfig) EntitlementStaleGrace() time.Duration { + if cfg.EntitlementStaleGraceHours <= 0 { + return 0 + } + return time.Duration(cfg.EntitlementStaleGraceHours) * time.Hour } // RawRetention is the configured retention window as a duration. diff --git a/apps/api/internal/platform/googleplay/client.go b/apps/api/internal/platform/googleplay/client.go index 7fce0afa..9ad74b39 100644 --- a/apps/api/internal/platform/googleplay/client.go +++ b/apps/api/internal/platform/googleplay/client.go @@ -171,7 +171,16 @@ type SubscriptionPurchase struct { TestPurchase *struct{} `json:"testPurchase"` CanceledStateContext json.RawMessage `json:"canceledStateContext"` PausedStateContext json.RawMessage `json:"pausedStateContext"` - LineItems []struct { + // ExternalAccountIdentifiers carries the obfuscated account id the app + // supplied at purchase time. It is a customer correlator and is treated + // exactly as Apple's appAccountToken is: hashed on the way out of this + // struct, never persisted raw, never logged. + ExternalAccountIdentifiers *struct { + ObfuscatedExternalAccountID string `json:"obfuscatedExternalAccountId"` + ObfuscatedExternalProfileID string `json:"obfuscatedExternalProfileId"` + ExternalAccountID string `json:"externalAccountId"` + } `json:"externalAccountIdentifiers"` + LineItems []struct { ProductID string `json:"productId"` ExpiryTime string `json:"expiryTime"` OfferDetails *struct { @@ -208,6 +217,10 @@ type ProductPurchase struct { ProductID string `json:"productId"` Quantity int `json:"quantity"` RegionCode string `json:"regionCode"` + // ObfuscatedExternalAccountID is the one-time-purchase form of the same + // customer correlator the subscription resource nests under + // externalAccountIdentifiers. + ObfuscatedExternalAccountID string `json:"obfuscatedExternalAccountId"` } // GetProduct performs purchases.products.get. diff --git a/apps/api/internal/platform/httpserver/router.go b/apps/api/internal/platform/httpserver/router.go index 6fbcd15c..1c5a0af3 100644 --- a/apps/api/internal/platform/httpserver/router.go +++ b/apps/api/internal/platform/httpserver/router.go @@ -14,6 +14,13 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/analytics" "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/billingdiagnostics" + "github.com/Mujhtech/mosaic/apps/api/internal/billinggrant" + "github.com/Mujhtech/mosaic/apps/api/internal/billingoperator" + "github.com/Mujhtech/mosaic/apps/api/internal/billingrestore" + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" "github.com/Mujhtech/mosaic/apps/api/internal/browserauth" "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" "github.com/Mujhtech/mosaic/apps/api/internal/experiment" @@ -24,6 +31,13 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" analyticshttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/analytics" billinghttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billing" + billingaccesshttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingaccess" + 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" + 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" browserauthhttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/browserauth" cloudworkspacehttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/cloudworkspace" experimenthttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/experiment" @@ -89,6 +103,41 @@ type Dependencies struct { Experiment *experiment.Service // Billing is nil unless MOSAIC_BILLING_ENABLED is set. Billing *billing.Service + // BillingAccess owns the Phase 9B authoritative access surfaces: Customer + // Access Tokens, the SDK entitlement sync endpoint, and the trusted-server + // entitlement reads. It is nil whenever Billing is. + BillingAccess *billingaccess.Service + // BillingDiagnostics owns the Phase 9B projection health surface. It is a + // sibling of Phase 9A billing health, not a field on it: the two summaries + // answer different operator questions. + BillingDiagnostics *billingdiagnostics.Service + // BillingGrant owns the Phase 9B Product-to-Entitlement Grant Version + // management surface: history, impact preview, and publish. It is nil + // whenever Billing is. + BillingGrant *billinggrant.Service + // 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. + BillingRestore *billingrestore.Service + // BillingOperator owns the Phase 9B dashboard surface over billing customer, + // subscription, entitlement, identity-conflict, and restore state. It is a + // separate dependency from BillingCustomer and BillingAccess because it + // authenticates differently: those two take their tenant from a secret + // server key, which a browser session does not hold, so without this the + // dashboard can reach none of the state they serve. It is nil whenever + // Billing is. + BillingOperator *billingoperator.Service + // BillingCustomer owns the Phase 9B billing-identity APIs: customer + // create-or-get, aliases, identity conflicts, and manual sync requests. It + // is nil whenever Billing is. + BillingCustomer *billingcustomer.Service + // BillingWebhook owns the Phase 9B application-webhook destinations, + // signing secrets, and delivery history (OD-1(b)). It is nil whenever + // Billing is. + BillingWebhook *billingwebhook.Service + // EntitlementSyncLimiter bounds the SDK sync endpoint, which is the + // highest-QPS authenticated surface Mosaic serves. + EntitlementSyncLimiter httpmiddleware.Limiter // BillingIPLimiter and BillingKeyLimiter bound the observation endpoints // only. The store notification endpoint is deliberately unlimited. BillingIPLimiter httpmiddleware.Limiter @@ -135,13 +184,13 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende // Compatibility aliases retained for existing probes while documented callers migrate. router.Mount("/health", health.LiveRoutes()) router.Mount("/ready", readinessRoutes(dependencies)) - if dependencies.BrowserAuth != nil || dependencies.CloudWorkspace != nil || dependencies.HostedPublishing != nil || dependencies.PlacementDecision != nil || dependencies.Analytics != nil || dependencies.Billing != nil { + if dependencies.BrowserAuth != nil || dependencies.CloudWorkspace != nil || dependencies.HostedPublishing != nil || dependencies.PlacementDecision != nil || dependencies.Analytics != nil || dependencies.BillingAccess != nil || hasBillingSurface(dependencies) { router.Route("/v1", func(versioned chi.Router) { versioned.Use(trustedMutationOrigins(cfg.AllowedOrigins)) if dependencies.BrowserAuth != nil { browserauthhttp.RegisterRoutes(versioned, dependencies.BrowserAuth, dependencies.BrowserAuthConfig) } - if dependencies.CloudWorkspace != nil || dependencies.HostedPublishing != nil || dependencies.Analytics != nil || dependencies.Billing != nil { + if dependencies.CloudWorkspace != nil || dependencies.HostedPublishing != nil || dependencies.Analytics != nil || hasBillingSurface(dependencies) { versioned.Group(func(authenticated chi.Router) { authenticated.Use(authn.Middleware(dependencies.PrincipalResolver)) // Authenticated dashboard APIs had no limit at all before @@ -180,6 +229,65 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende billinghttp.RegisterProjectRoutes(project, dependencies.Billing, httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) } + if dependencies.BillingDiagnostics != nil { + // A replay recomputes committed state for every + // scope it names, so it shares the export-class + // bucket with the other history-scanning billing + // operations rather than the baseline API one. + billingdiagnosticshttp.RegisterProjectRoutes(project, dependencies.BillingDiagnostics, + httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) + } + if dependencies.BillingGrant != nil { + // Publishing enqueues a reprojection for every + // affected customer and the preview counts across + // the Project's current snapshots, so both share the + // export-class bucket with the other + // history-scanning billing operations. + billinggranthttp.RegisterProjectRoutes(project, dependencies.BillingGrant, + 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 + // that accepts an attacker-chosen identifier and + // reports whether it matched, and the sync enqueues + // projection work. The reads keep the baseline API + // bucket the whole authenticated subtree already has. + billingoperatorhttp.RegisterProjectRoutes(project, dependencies.BillingOperator, + httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) + } + if dependencies.BillingWebhook != nil { + // Destination creation and secret rotation each + // perform a DNS resolution or an envelope seal, and + // a manual replay enqueues delivery work. + billingwebhookhttp.RegisterProjectRoutes(project, dependencies.BillingWebhook, + httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) + } + // The Environment-scoped billing subtree is created once, + // here, and every module that publishes under it registers + // into it. Three modules do, and each of them used to open + // its own chi Route() on the identical pattern — which chi + // refuses, so the deployed composition panicked during + // router construction whenever billing was enabled (defect + // D-3). Creating the subrouter at the composition is what + // makes a fourth module structurally unable to reintroduce + // the collision. No URL changed. + if hasEnvironmentBillingSurface(dependencies) { + project.Route("/environments/{environmentId}/billing", func(environment chi.Router) { + if dependencies.Billing != nil { + billinghttp.RegisterEnvironmentRoutes(environment, dependencies.Billing, + httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) + } + if dependencies.BillingOperator != nil { + billingoperatorhttp.RegisterEnvironmentRoutes(environment, dependencies.BillingOperator, + httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) + } + if dependencies.BillingWebhook != nil { + billingwebhookhttp.RegisterEnvironmentRoutes(environment, dependencies.BillingWebhook, + httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) + } + }) + } if dependencies.Experiment != nil { project.Group(func(decision chi.Router) { decision.Use(httpmiddleware.RateLimit("decision", dependencies.DecisionLimiter, principalKey)) @@ -206,6 +314,31 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende billinghttp.RegisterPublicRoutes(versioned, dependencies.Billing, dependencies.BillingIPLimiter, dependencies.BillingKeyLimiter) } + if dependencies.BillingAccess != nil { + // Both surfaces authenticate by API key rather than by browser + // session, so they are registered outside the principal + // middleware. The SDK sync endpoint is bucketed by the public + // SDK key it presents; the trusted APIs share the baseline API + // bucket keyed on the caller's client address, because a secret + // server key has no dashboard principal to bucket on. + billingaccesshttp.RegisterSDKRoutes(versioned, dependencies.BillingAccess, + httpmiddleware.RateLimit("entitlement_sync", dependencies.EntitlementSyncLimiter, sdkKeyBucket)) + billingaccesshttp.RegisterTrustedRoutes(versioned, dependencies.BillingAccess, + httpmiddleware.RateLimit("billing_server_api", dependencies.APILimiter, clientAddressBucket)) + } + if dependencies.BillingCustomer != nil { + billingcustomerhttp.RegisterTrustedRoutes(versioned, dependencies.BillingCustomer, + httpmiddleware.RateLimit("billing_server_api", dependencies.APILimiter, clientAddressBucket)) + } + if dependencies.BillingRestore != nil { + // A restore is a burst of work per device, not a poll, so the + // SDK surface shares the observation bucket rather than the + // sync one: a device restoring is submitting, not reading. + billingrestorehttp.RegisterSDKRoutes(versioned, dependencies.BillingRestore, + httpmiddleware.RateLimit("billing_restore", dependencies.BillingKeyLimiter, sdkKeyBucket)) + billingrestorehttp.RegisterTrustedRoutes(versioned, dependencies.BillingRestore, + httpmiddleware.RateLimit("billing_server_api", dependencies.APILimiter, clientAddressBucket)) + } }) } router.NotFound(func(w http.ResponseWriter, r *http.Request) { @@ -226,6 +359,30 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende return router } +// hasBillingSurface reports whether any dashboard-facing billing module is +// wired. +// +// The `/v1` subtree and the authenticated Project subtree used to be gated on +// `Billing != nil` alone, which silently made every other billing module a +// dependent of the Phase 9A ingestion module. That was never a real +// requirement — the 9B operator, grant, diagnostics, and webhook surfaces read +// and write their own tables and hold their own services — and its effect was +// that the entire Phase 9B operator surface was unreachable in every +// composition that did not also enable 9A ingestion, while the composition that +// did enable both panicked on the route collision (defect D-3). +func hasBillingSurface(dependencies Dependencies) bool { + return dependencies.Billing != nil || dependencies.BillingOperator != nil || + dependencies.BillingGrant != nil || dependencies.BillingDiagnostics != nil || + dependencies.BillingWebhook != nil +} + +// hasEnvironmentBillingSurface reports whether any module publishes routes +// under the shared `/environments/{environmentId}/billing` subrouter. +func hasEnvironmentBillingSurface(dependencies Dependencies) bool { + return dependencies.Billing != nil || dependencies.BillingOperator != nil || + dependencies.BillingWebhook != nil +} + func readinessRoutes(dependencies Dependencies) http.Handler { if dependencies.Readiness != nil { return health.ReadinessRoutes(dependencies.Readiness) @@ -252,6 +409,27 @@ func principalKey(r *http.Request) string { return "ip:" + httpmiddleware.ClientIP(r) } +// sdkKeyBucket buckets the entitlement sync endpoint by the public SDK key +// presented. Bucketing by client address alone would put every customer behind +// one mobile carrier NAT into a single bucket. +func sdkKeyBucket(r *http.Request) string { + if key := strings.TrimSpace(r.Header.Get(billingaccesshttp.SDKKeyHeader)); key != "" { + // Only the key prefix is used as the bucket label: it identifies the key + // without the bucket map ever holding a credential. + if index := strings.Index(key, "."); index > 0 { + return "sdkkey:" + key[:index] + } + } + return "ip:" + httpmiddleware.ClientIP(r) +} + +// clientAddressBucket buckets a trusted-server call. The secret key itself is +// never used as a bucket key, so the limiter map cannot become a place +// credentials accumulate. +func clientAddressBucket(r *http.Request) string { + return "ip:" + httpmiddleware.ClientIP(r) +} + func trustedMutationOrigins(allowedOrigins []string) func(http.Handler) http.Handler { allowed := make(map[string]struct{}, len(allowedOrigins)) for _, origin := range allowedOrigins { diff --git a/apps/api/internal/platform/httpserver/router_test.go b/apps/api/internal/platform/httpserver/router_test.go index 4f736c1e..3321060f 100644 --- a/apps/api/internal/platform/httpserver/router_test.go +++ b/apps/api/internal/platform/httpserver/router_test.go @@ -16,6 +16,10 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/analytics" "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/billingdiagnostics" + "github.com/Mujhtech/mosaic/apps/api/internal/billinggrant" + "github.com/Mujhtech/mosaic/apps/api/internal/billingoperator" + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" "github.com/Mujhtech/mosaic/apps/api/internal/hostedpublishing" "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" @@ -434,3 +438,86 @@ func TestStoreNotificationIntakeAcceptsOriginlessPostsAndIsNotRateLimited(t *tes t.Fatalf("a cross-origin browser POST to the intake route returned %d, want 403", forgedRecorder.Code) } } + +// TestFullBillingCompositionMountsWithoutCollision is the regression for defect +// D-3. +// +// Three modules publish routes under `/environments/{environmentId}/billing`, +// and each of them used to open its own chi Route() on that identical pattern. +// chi refuses to Mount() twice on one path, so `cmd/api` panicked during router +// construction whenever billing was enabled and never served a request. Nothing +// caught it because no test ever constructed the combination the deployed +// composition uses. +// +// This test constructs exactly that combination — every billing module wired +// together, as cmd/api wires them — and asserts two things: the router builds +// without panicking, and a route from each of the colliding surfaces is +// reachable rather than shadowed by whichever module registered first. +func TestFullBillingCompositionMountsWithoutCollision(t *testing.T) { + limiter := &exhaustedLimiter{} + handler := NewWithDependencies(Config{ + ServiceName: "mosaic-api-test", AllowedOrigins: []string{"https://studio.example"}, + RequestTimeout: time.Second, + }, zerolog.Nop(), Dependencies{ + Billing: billing.NewService(nil, nil, nil), + BillingOperator: billingoperator.NewService(nil, nil, nil), + BillingWebhook: billingwebhook.NewService(nil, nil, nil), + BillingGrant: billinggrant.NewService(nil), + BillingDiagnostics: billingdiagnostics.NewService(nil), + PrincipalResolver: authn.ResolverFunc(func(*http.Request) (authn.Principal, error) { + return authn.Principal{ActorID: "actor-owner", Method: "test"}, nil + }), + APILimiter: limiter, + ExportLimiter: limiter, + }) + + // One route from each module that shares the Environment-scoped subtree. A + // shadowed route answers 404 from the router itself; a reachable one gets + // as far as its handler, which fails on the nil repository instead. + for _, path := range []string{ + "/v1/projects/prj_test/environments/env_test/billing/facts", + "/v1/projects/prj_test/environments/env_test/billing/customers", + "/v1/projects/prj_test/environments/env_test/billing/webhook-destinations", + } { + func() { + defer func() { + if recovered := recover(); recovered != nil { + t.Fatalf("%s panicked in the handler chain: %v", path, recovered) + } + }() + request := httptest.NewRequest(http.MethodGet, path, nil) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code == http.StatusNotFound { + t.Fatalf("%s was not routed; a module's routes were shadowed by another's", path) + } + }() + } +} + +// TestBillingOperatorRegistersWithoutPhase9AIngestion is the second half of +// defect D-3: the `/v1` subtree and the authenticated Project subtree were +// gated on the Phase 9A ingestion module alone, so the entire Phase 9B operator +// surface was unreachable in any composition that did not also enable 9A. The +// dependency was never real. +func TestBillingOperatorRegistersWithoutPhase9AIngestion(t *testing.T) { + limiter := &exhaustedLimiter{} + handler := NewWithDependencies(Config{ + ServiceName: "mosaic-api-test", AllowedOrigins: []string{"https://studio.example"}, + RequestTimeout: time.Second, + }, zerolog.Nop(), Dependencies{ + BillingOperator: billingoperator.NewService(nil, nil, nil), + PrincipalResolver: authn.ResolverFunc(func(*http.Request) (authn.Principal, error) { + return authn.Principal{ActorID: "actor-owner", Method: "test"}, nil + }), + APILimiter: limiter, + ExportLimiter: limiter, + }) + + request := httptest.NewRequest(http.MethodGet, "/v1/projects/prj_test/billing/identity-conflicts", nil) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code == http.StatusNotFound { + t.Fatal("the operator surface was not registered without the 9A ingestion module") + } +} diff --git a/apps/api/internal/providercredential/subject.go b/apps/api/internal/providercredential/subject.go index 5bb039fe..9ac0b396 100644 --- a/apps/api/internal/providercredential/subject.go +++ b/apps/api/internal/providercredential/subject.go @@ -28,6 +28,12 @@ const envelopeAADDomainV2 = "mosaic-billing-envelope-v2" const ( SubjectStoreServerCredential = "store_server_credential" SubjectBillingRawInput = "billing_raw_input" + // SubjectWebhookSigningSecret is the Phase 9B addition recorded in + // ADR-0024. Binding the AAD to the destination row means a sealed secret + // moved to another destination — by a bug, or by a compromise that can + // write the table but not decrypt it — fails to open rather than signing + // deliveries for the wrong tenant. + SubjectWebhookSigningSecret = "webhook_signing_secret" ) // SubjectScope binds a v2 envelope to one tenant and one row. Every field is @@ -47,7 +53,7 @@ func validSubjectScope(scope SubjectScope) bool { return false } switch scope.SubjectKind { - case SubjectStoreServerCredential, SubjectBillingRawInput: + case SubjectStoreServerCredential, SubjectBillingRawInput, SubjectWebhookSigningSecret: return true default: return false diff --git a/apps/api/internal/transport/billing/handler.go b/apps/api/internal/transport/billing/handler.go index fcb65e0f..74f1b089 100644 --- a/apps/api/internal/transport/billing/handler.go +++ b/apps/api/internal/transport/billing/handler.go @@ -67,7 +67,37 @@ func RegisterPublicRoutes(router chi.Router, service *billing.Service, ip, key L router.Post("/billing/server/observations", h.serverObservation) } -// RegisterProjectRoutes mounts the authenticated operator API. +// RegisterEnvironmentRoutes mounts the Environment-scoped 9A operator reads on +// the shared `/environments/{environmentId}/billing` subrouter. +// +// It is separate from RegisterProjectRoutes because three modules — 9A billing, +// the 9B operator surface, and webhook destinations — all publish routes under +// that one path. Each of them used to call chi's Route() with the same pattern, +// and chi refuses to Mount() twice on one path, so any composition that +// registered two of them panicked before the process served a request (defect +// D-3). The subrouter is now created once by the composition and every module +// registers into it, which makes the collision structurally impossible rather +// than a thing reviewers have to notice. +// +// Every path below is byte-identical to what it was when it was nested inside +// this package's own Route() call, so no published URL moved. +func RegisterEnvironmentRoutes(environment chi.Router, service *billing.Service, expensive ...func(http.Handler) http.Handler) { + h := &Handler{service: service} + guarded := nonNil(expensive) + + environment.Get("/facts", h.listFacts) + environment.Get("/validation-attempts", h.listAttempts) + environment.Get("/ledger", h.listLedger) + environment.Get("/quarantine", h.listQuarantine) + environment.Get("/health", h.health) + environment.Get("/reconciliation-runs", h.listReconciliations) + environment.With(guarded...).Post("/reconciliation-runs", h.createReconciliation) + environment.Get("/replay-jobs", h.listReplays) + environment.With(guarded...).Post("/replay-jobs", h.createReplay) +} + +// RegisterProjectRoutes mounts the authenticated operator API that is not +// Environment-scoped. The Environment-scoped half is RegisterEnvironmentRoutes. func RegisterProjectRoutes(router chi.Router, service *billing.Service, expensive ...func(http.Handler) http.Handler) { h := &Handler{service: service} guarded := nonNil(expensive) @@ -84,17 +114,6 @@ func RegisterProjectRoutes(router chi.Router, service *billing.Service, expensiv credentials.Post("/{credentialId}/revoke", h.revokeCredential) credentials.With(guarded...).Post("/{credentialId}/test", h.testCredential) }) - router.Route("/environments/{environmentId}/billing", func(environment chi.Router) { - environment.Get("/facts", h.listFacts) - environment.Get("/validation-attempts", h.listAttempts) - environment.Get("/ledger", h.listLedger) - environment.Get("/quarantine", h.listQuarantine) - environment.Get("/health", h.health) - environment.Get("/reconciliation-runs", h.listReconciliations) - environment.With(guarded...).Post("/reconciliation-runs", h.createReconciliation) - environment.Get("/replay-jobs", h.listReplays) - environment.With(guarded...).Post("/replay-jobs", h.createReplay) - }) router.Route("/billing/quarantine/{recordId}", func(record chi.Router) { record.Get("/", h.getQuarantine) // The recovery surface is exactly two actions. There is no @@ -524,7 +543,8 @@ func (h *Handler) clientObservation(w http.ResponseWriter, r *http.Request) { writeSubmission(w, billing.Reject(submissionID, h.now(), code)) return } - result, err := h.service.SubmitClientObservation(r.Context(), bearer(r), envelope.Payload.toObservation(), correlationID(r)) + result, err := h.service.SubmitClientObservationAs(r.Context(), bearer(r), customerToken(r), + envelope.Payload.toObservation(), correlationID(r)) if err != nil { h.writeSubmissionError(w, r, submissionID, err) return @@ -548,7 +568,8 @@ func (h *Handler) serverObservation(w http.ResponseWriter, r *http.Request) { writeSubmission(w, billing.Reject(submissionID, h.now(), code)) return } - result, err := h.service.SubmitServerObservation(r.Context(), bearer(r), envelope.Payload.toObservation(), correlationID(r)) + result, err := h.service.SubmitServerObservationAs(r.Context(), bearer(r), customerToken(r), + envelope.Payload.toObservation(), correlationID(r)) if err != nil { h.writeSubmissionError(w, r, submissionID, err) return @@ -1186,6 +1207,31 @@ func bearer(r *http.Request) string { return "" } +// CustomerTokenHeader carries an optional Customer Access Token alongside an +// observation submission. +// +// It is a header rather than a body member because it is a credential, and the +// observation body is a ratified contract record that Mosaic seals and can +// replay — a credential must never be a thing that gets stored and replayed. +// `Authorization` is already taken on this surface by the API key that +// establishes the tenant, so the token needs its own name, exactly as the +// entitlement sync surface gives the SDK key its own. +// +// It is optional everywhere. An observation without one is the ordinary +// anonymous purchase and behaves exactly as it did before. +const CustomerTokenHeader = "Mosaic-Customer-Token" + +// customerToken reads the optional Customer Access Token. The value is passed +// straight to the application service and is never logged: writeError below +// deliberately never populates a cause on this surface for the same reason. +func customerToken(r *http.Request) billing.CustomerToken { + value := strings.TrimSpace(r.Header.Get(CustomerTokenHeader)) + if value == "" { + return "" + } + return billing.CustomerToken(value) +} + // writeError maps billing errors onto HTTP. // // The Cause field is deliberately never populated. response.Error logs the diff --git a/apps/api/internal/transport/billingaccess/handler.go b/apps/api/internal/transport/billingaccess/handler.go new file mode 100644 index 00000000..ae2d4b7a --- /dev/null +++ b/apps/api/internal/transport/billingaccess/handler.go @@ -0,0 +1,622 @@ +// Package billingaccesshttp exposes Mosaic's authoritative access surfaces over +// HTTP: Customer Access Token issuance and revocation, the SDK entitlement sync +// endpoint, and the trusted-server entitlement reads. +// +// Handlers here are strictly thin: read the request, decode, validate transport +// shape, call the application service, write a standardized response. No handler +// queries the database, decides authorization, interprets a projection, or calls +// render.JSON directly. +// +// Two response shapes coexist deliberately. Contract records — snapshots, check +// results, subscription snapshots — are written with response.Representation +// because their wire contract is the Authoritative Entitlement Contract, not the +// dashboard data envelope; every other response uses the envelope. +package billingaccesshttp + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + validation "github.com/go-ozzo/ozzo-validation/v4" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +// contractContentType is the media type every Authoritative Entitlement record +// is served as. It is plain JSON: the contract is identified by the record +// envelope, not by a bespoke media type nobody's HTTP client understands. +const contractContentType = "application/json; charset=utf-8" + +// maxBodyBytes bounds every request body on these surfaces. The largest +// legitimate body is a sync request with sixty-four entitlement keys. +const maxBodyBytes = 16 * 1024 + +type Handler struct { + service *billingaccess.Service +} + +// RegisterSDKRoutes mounts the untrusted SDK read surface. +// +// The route sits under /v1/sdk to match the existing SDK surfaces, and it is +// rate limited: an SDK holds a cache and a retry schedule, so shedding load +// costs latency rather than correctness. This is expected to be the +// highest-QPS authenticated surface Mosaic has, and the in-process limiter +// buckets per instance — a limitation documented rather than hidden. +func RegisterSDKRoutes(router chi.Router, service *billingaccess.Service, limiters ...func(http.Handler) http.Handler) { + h := &Handler{service: service} + router.Group(func(sdk chi.Router) { + for _, limiter := range limiters { + if limiter != nil { + sdk.Use(limiter) + } + } + sdk.Get("/sdk/billing/entitlements", h.syncEntitlements) + sdk.Post("/sdk/billing/entitlements", h.syncEntitlements) + }) +} + +// RegisterTrustedRoutes mounts the secret-server-authenticated APIs. They are +// authenticated by the API key itself rather than by the dashboard principal +// middleware, because the caller is an application backend, not an operator in +// a browser session. +func RegisterTrustedRoutes(router chi.Router, service *billingaccess.Service, limiters ...func(http.Handler) http.Handler) { + h := &Handler{service: service} + router.Group(func(trusted chi.Router) { + for _, limiter := range limiters { + if limiter != nil { + trusted.Use(limiter) + } + } + trusted.Route("/billing/server", func(server chi.Router) { + server.Post("/customer-tokens", h.issueToken) + server.Get("/customer-tokens", h.listTokens) + server.Post("/customer-tokens/{tokenId}/revoke", h.revokeToken) + + server.Get("/customers/{customerId}", h.customer) + server.Get("/customers/{customerId}/entitlements", h.snapshot) + server.Post("/customers/{customerId}/entitlement-checks", h.check) + server.Get("/customers/{customerId}/subscriptions", h.subscriptions) + + server.Get("/subscriptions/{instanceId}", h.subscription) + server.Get("/subscriptions/{instanceId}/timeline", h.timeline) + }) + }) +} + +// --------------------------------------------------------------------------- +// Customer Access Tokens +// --------------------------------------------------------------------------- + +// tokenIssuanceEnvelope is the Customer Access Token Contract v1 issuance +// record. It is decoded with DisallowUnknownFields because the contract +// declares additionalProperties:false at every level, so a member the contract +// does not define is a rejection rather than a silently ignored value. +type tokenIssuanceEnvelope struct { + CustomerAccessTokenContractVersion string `json:"customerAccessTokenContractVersion"` + RecordType string `json:"recordType"` + Payload tokenIssuancePayload `json:"payload"` +} + +type tokenIssuancePayload struct { + BillingCustomerID string `json:"billingCustomerId"` + Audience string `json:"audience"` + Scopes []string `json:"scopes"` + RequestedTTLSeconds int `json:"requestedTtlSeconds,omitempty"` + CorrelationID string `json:"correlationId"` +} + +func (p tokenIssuancePayload) Validate() error { + return validation.ValidateStruct(&p, + validation.Field(&p.BillingCustomerID, validation.Required, validation.Length(1, 128)), + validation.Field(&p.Audience, validation.Required, + validation.In(billingaccess.AudienceSDKSync, billingaccess.AudienceServerCheck)), + validation.Field(&p.Scopes, validation.Required, validation.Length(1, 3)), + validation.Field(&p.RequestedTTLSeconds, validation.Min(60), validation.Max(86400)), + validation.Field(&p.CorrelationID, validation.Required, validation.Length(1, 128)), + ) +} + +func (h *Handler) issueToken(w http.ResponseWriter, r *http.Request) { + var envelope tokenIssuanceEnvelope + if !decode(w, r, &envelope) { + return + } + if envelope.CustomerAccessTokenContractVersion != billingaccess.TokenContractVersion || + envelope.RecordType != "customerAccessTokenIssuanceRequest" { + writeValidation(w, r, map[string][]string{ + "recordType": {"The record is not a Customer Access Token Contract v1 issuance request."}}) + return + } + if err := envelope.Payload.Validate(); err != nil { + writeValidation(w, r, validationFields(err)) + return + } + + issued, err := h.service.IssueToken(r.Context(), bearer(r), billingaccess.IssuanceRequest{ + CustomerID: envelope.Payload.BillingCustomerID, + Audience: envelope.Payload.Audience, + Scopes: envelope.Payload.Scopes, + RequestedTTLSecond: envelope.Payload.RequestedTTLSeconds, + CorrelationID: envelope.Payload.CorrelationID, + }) + if err != nil { + writeError(w, r, err) + return + } + + // This is the one response in Mosaic that carries a bearer credential. It + // is written directly to the caller over its authenticated server-to-server + // channel and never reaches a log, a metric attribute, or a span. + response.Created(w, r, map[string]any{ + "customerAccessTokenContractVersion": billingaccess.TokenContractVersion, + "recordType": "customerAccessTokenIssuanceResult", + "payload": map[string]any{ + "token": issued.Value, + "metadata": tokenMetadata(issued.Metadata, time.Now().UTC()), + "correlationId": envelope.Payload.CorrelationID, + }, + }) +} + +type tokenRevocationRequest struct { + RevocationReason string `json:"revocationReason"` +} + +func (h *Handler) revokeToken(w http.ResponseWriter, r *http.Request) { + var request tokenRevocationRequest + if !decode(w, r, &request) { + return + } + token, err := h.service.RevokeToken(r.Context(), bearer(r), + strings.TrimSpace(chi.URLParam(r, "tokenId")), request.RevocationReason) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, tokenMetadata(token, time.Now().UTC())) +} + +func (h *Handler) listTokens(w http.ResponseWriter, r *http.Request) { + customerID := strings.TrimSpace(r.URL.Query().Get("billingCustomerId")) + if customerID == "" { + writeValidation(w, r, map[string][]string{ + "billingCustomerId": {"A Billing Customer identifier is required."}}) + return + } + tokens, err := h.service.ListTokens(r.Context(), bearer(r), customerID, intQuery(r, "limit")) + if err != nil { + writeError(w, r, err) + return + } + now := time.Now().UTC() + items := make([]map[string]any, 0, len(tokens)) + for _, token := range tokens { + items = append(items, tokenMetadata(token, now)) + } + response.OK(w, r, map[string]any{"items": items}) +} + +// tokenMetadata renders the contract's customerAccessTokenMetadata. Everything +// here is metadata *about* a token; the token itself has no parseable structure +// and carries none of it. +func tokenMetadata(token billingaccess.Token, now time.Time) map[string]any { + metadata := map[string]any{ + "tokenId": token.ID, + "projectId": token.ProjectID, + "environmentId": token.EnvironmentID, + "billingCustomerId": token.CustomerID, + "audience": token.Audience, + "scopes": token.Scopes, + "issuer": "mosaic", + "issuedAt": billingaccess.ContractTimestamp(token.IssuedAt), + "expiresAt": billingaccess.ContractTimestamp(token.ExpiresAt), + "status": token.Status(now), + "tokenPrefix": billingaccess.TokenPrefix, + "digestAlgorithm": "sha256", + } + if token.RevokedAt != nil { + metadata["revokedAt"] = billingaccess.ContractTimestamp(*token.RevokedAt) + metadata["revocationReason"] = token.RevocationReason + } + if token.LastUsedAt != nil { + metadata["lastUsedAt"] = billingaccess.ContractTimestamp(*token.LastUsedAt) + } + return metadata +} + +// --------------------------------------------------------------------------- +// SDK entitlement sync +// --------------------------------------------------------------------------- + +// SDKKeyHeader carries the public SDK key alongside the customer token. The +// token travels in Authorization: Bearer and decides *which* customer is read; +// the SDK key decides which Environment is asking. Both are required, and a +// public key alone can never select a customer. +const SDKKeyHeader = "Mosaic-SDK-Key" + +type syncEnvelope struct { + AuthoritativeEntitlementContractVersion string `json:"authoritativeEntitlementContractVersion"` + RecordType string `json:"recordType"` + Payload syncPayload `json:"payload"` +} + +type syncPayload struct { + BillingCustomerID string `json:"billingCustomerId,omitempty"` + KnownSnapshotVersion int64 `json:"knownSnapshotVersion,omitempty"` + EntityTag string `json:"entityTag,omitempty"` + SupportedAuthoritativeEntitlementContracts []string `json:"supportedAuthoritativeEntitlementContracts"` + RequestedEntitlementKeys []string `json:"requestedEntitlementKeys,omitempty"` + CorrelationID string `json:"correlationId"` +} + +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))) + if err != nil { + writeError(w, r, err) + return + } + + request := billingaccess.SyncRequest{CorrelationID: correlationID(r)} + if r.Method == http.MethodPost { + var envelope syncEnvelope + if !decode(w, r, &envelope) { + return + } + if envelope.AuthoritativeEntitlementContractVersion != billingaccess.ContractVersion || + envelope.RecordType != "entitlementSyncRequest" { + writeValidation(w, r, map[string][]string{ + "recordType": {"The record is not an Authoritative Entitlement Contract v1 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.")) + 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) + if err != nil { + writeError(w, r, err) + return + } + + 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. + response.Representation(w, http.StatusOK, contractContentType, result.Payload) +} + +func supportsContract(offered []string) bool { + if len(offered) == 0 { + return false + } + for _, version := range offered { + if version == billingaccess.ContractVersion { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Trusted-server reads +// --------------------------------------------------------------------------- + +func (h *Handler) customer(w http.ResponseWriter, r *http.Request) { + view, err := h.service.Customer(r.Context(), bearer(r), chi.URLParam(r, "customerId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, customerResponse(view)) +} + +// customerResponse is the API shape of a Billing Customer. It deliberately +// carries no alias values: aliases are stored as digests and the digest is +// never a read-side field. +func customerResponse(view billingaccess.CustomerView) map[string]any { + payload := map[string]any{ + "billingCustomerId": view.ID, + "projectId": view.ProjectID, + "status": view.Status, + "diagnosticsStatus": view.DiagnosticsStatus, + "currentProjectionVersion": view.CurrentProjectionVersion, + // The distinction the dashboard and every operator needs first: a + // customer anchored to a purchase but never identified is not the same + // as one a backend has named. + "identified": view.Identified, + "createdAt": billingaccess.ContractTimestamp(view.CreatedAt), + "updatedAt": billingaccess.ContractTimestamp(view.UpdatedAt), + } + if view.LastProjectedAt != nil { + payload["lastProjectedAt"] = billingaccess.ContractTimestamp(*view.LastProjectedAt) + } + return payload +} + +func (h *Handler) snapshot(w http.ResponseWriter, r *http.Request) { + payload, err := h.service.Snapshot(r.Context(), bearer(r), + strings.TrimSpace(r.URL.Query().Get("environmentId")), + chi.URLParam(r, "customerId"), correlationID(r)) + if err != nil { + writeError(w, r, err) + return + } + response.Representation(w, http.StatusOK, contractContentType, payload) +} + +type checkEnvelope struct { + AuthoritativeEntitlementContractVersion string `json:"authoritativeEntitlementContractVersion"` + RecordType string `json:"recordType"` + Payload checkPayload `json:"payload"` +} + +type checkPayload struct { + BillingCustomerID string `json:"billingCustomerId"` + EntitlementKeys []string `json:"entitlementKeys"` + ExpectedSnapshotVersion int64 `json:"expectedSnapshotVersion,omitempty"` + SupportedAuthoritativeEntitlementContracts []string `json:"supportedAuthoritativeEntitlementContracts"` + CorrelationID string `json:"correlationId"` +} + +func (p checkPayload) Validate() error { + return validation.ValidateStruct(&p, + validation.Field(&p.BillingCustomerID, validation.Required, validation.Length(1, 128)), + validation.Field(&p.EntitlementKeys, validation.Required, validation.Length(1, 64)), + validation.Field(&p.CorrelationID, validation.Required, validation.Length(1, 128)), + ) +} + +func (h *Handler) check(w http.ResponseWriter, r *http.Request) { + var envelope checkEnvelope + if !decode(w, r, &envelope) { + return + } + if envelope.AuthoritativeEntitlementContractVersion != billingaccess.ContractVersion || + envelope.RecordType != "entitlementCheckRequest" { + writeValidation(w, r, map[string][]string{ + "recordType": {"The record is not an Authoritative Entitlement Contract v1 check request."}}) + return + } + if err := envelope.Payload.Validate(); err != nil { + writeValidation(w, r, validationFields(err)) + return + } + if !supportsContract(envelope.Payload.SupportedAuthoritativeEntitlementContracts) { + response.Error(w, r, response.NewAPIError(http.StatusNotAcceptable, + "contract_version_unsupported", + "No supported Authoritative Entitlement Contract version was offered.")) + return + } + if pathCustomer := chi.URLParam(r, "customerId"); pathCustomer != "" && + pathCustomer != envelope.Payload.BillingCustomerID { + writeValidation(w, r, map[string][]string{ + "billingCustomerId": {"The body names a different Billing Customer than the path."}}) + return + } + + payload, err := h.service.Check(r.Context(), bearer(r), + strings.TrimSpace(r.URL.Query().Get("environmentId")), billingaccess.CheckRequest{ + CustomerID: envelope.Payload.BillingCustomerID, + EntitlementKeys: envelope.Payload.EntitlementKeys, + ExpectedSnapshotVersion: envelope.Payload.ExpectedSnapshotVersion, + CorrelationID: envelope.Payload.CorrelationID, + }) + if err != nil { + writeError(w, r, err) + return + } + response.Representation(w, http.StatusOK, contractContentType, payload) +} + +func (h *Handler) subscriptions(w http.ResponseWriter, r *http.Request) { + views, next, err := h.service.Subscriptions(r.Context(), bearer(r), + strings.TrimSpace(r.URL.Query().Get("environmentId")), chi.URLParam(r, "customerId"), + intQuery(r, "limit"), strings.TrimSpace(r.URL.Query().Get("cursor"))) + if err != nil { + writeError(w, r, err) + return + } + items := make([]map[string]any, 0, len(views)) + for _, view := range views { + items = append(items, map[string]any{ + "subscriptionInstanceId": view.SubscriptionInstanceID, + "subscriptionSnapshotId": view.SnapshotID, + "projectionVersion": view.ProjectionVersion, + "accessState": view.AccessState, + "lifecycleState": view.LifecycleState, + "renewalIntent": view.RenewalIntent, + "billingState": view.BillingState, + "isTestSource": view.IsTestSource, + "asOf": billingaccess.ContractTimestamp(view.AsOf), + }) + } + payload := map[string]any{"items": items} + if next != "" { + payload["nextCursor"] = next + } + response.OK(w, r, payload) +} + +func (h *Handler) subscription(w http.ResponseWriter, r *http.Request) { + payload, err := h.service.Subscription(r.Context(), bearer(r), + chi.URLParam(r, "instanceId"), correlationID(r)) + if err != nil { + writeError(w, r, err) + return + } + response.Representation(w, http.StatusOK, contractContentType, payload) +} + +func (h *Handler) timeline(w http.ResponseWriter, r *http.Request) { + entries, next, err := h.service.Timeline(r.Context(), bearer(r), + chi.URLParam(r, "instanceId"), intQuery(r, "limit"), + strings.TrimSpace(r.URL.Query().Get("cursor"))) + if err != nil { + writeError(w, r, err) + return + } + items := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + item := map[string]any{ + "timelineEntryId": entry.ID, + "entryType": entry.EntryType, + "effectiveAt": billingaccess.ContractTimestamp(entry.EffectiveAt), + "observedAt": billingaccess.ContractTimestamp(entry.ObservedAt), + "explanationCode": entry.ExplanationCode, + } + if entry.ProductID != "" { + item["mosaicProductId"] = entry.ProductID + } + if len(entry.Detail) > 0 { + item["detail"] = entry.Detail + } + items = append(items, item) + } + payload := map[string]any{"items": items} + if next != "" { + payload["nextCursor"] = next + } + response.OK(w, r, payload) +} + +// --------------------------------------------------------------------------- +// Transport helpers +// --------------------------------------------------------------------------- + +func decode(w http.ResponseWriter, r *http.Request, target any) bool { + if encoding := r.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + writeValidation(w, r, map[string][]string{"body": {"The request encoding is not supported."}}) + return false + } + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + decoder := json.NewDecoder(r.Body) + // The contracts declare additionalProperties:false at every level, so an + // unknown member is a rejection rather than a value quietly dropped. + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeValidation(w, r, map[string][]string{"body": {"The request body could not be read."}}) + 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 ") { + return strings.TrimSpace(value[7:]) + } + return "" +} + +func correlationID(r *http.Request) string { + if id := chimiddleware.GetReqID(r.Context()); id != "" { + return id + } + return "mosaic" +} + +func intQuery(r *http.Request, name string) int { + value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(name))) + if err != nil { + return 0 + } + return value +} + +func validationFields(err error) map[string][]string { + fields := map[string][]string{} + var errs validation.Errors + if errors.As(err, &errs) { + for name, fieldErr := range errs { + fields[name] = []string{fieldErr.Error()} + } + return fields + } + fields["body"] = []string{"The request contains invalid fields."} + return fields +} + +func writeValidation(w http.ResponseWriter, r *http.Request, fields map[string][]string) { + response.Error(w, r, response.ValidationFailed(fields)) +} + +// writeError maps access-domain errors onto HTTP in one place. +// +// The Cause is never populated: response.Error logs the cause behind every 5xx, +// and on this surface a cause can quote a token digest or an Authorization +// header. Authentication failures are deliberately indistinguishable from one +// another — expired, revoked, unknown, and wrong-audience all answer 401 — +// because telling a caller which half of a guess was right is how a credential +// gets brute-forced. +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, billingaccess.ErrUnauthenticated): + status, code, message = http.StatusUnauthorized, "unauthenticated", "Authentication is required." + case errors.Is(err, billingaccess.ErrForbidden): + status, code, message = http.StatusForbidden, "forbidden", "The credential does not cover this resource." + case errors.Is(err, billingaccess.ErrNotFound): + status, code, message = http.StatusNotFound, "not_found", "The requested resource was not found." + case errors.Is(err, billingaccess.ErrInvalid): + status, code, message = http.StatusUnprocessableEntity, "validation_failed", "The request contains invalid fields." + case errors.Is(err, billingaccess.ErrConflict): + status, code, message = http.StatusConflict, "conflict", "The resource is in a conflicting state." + case errors.Is(err, billingaccess.ErrBillingDisabled): + // Mosaic Billing being off is a service state, never a statement about + // the customer. 409 rather than 404 so a caller can tell "not enabled" + // from "no such customer". + status, code, message = http.StatusConflict, "billing_not_enabled", + "Mosaic Billing is not enabled for this Project." + case errors.Is(err, billingaccess.ErrUnavailable): + status, code, message = http.StatusServiceUnavailable, "billing_storage_unavailable", + "Billing state could not be read." + } + 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 new file mode 100644 index 00000000..4328e2c4 --- /dev/null +++ b/apps/api/internal/transport/billingaccess/sync_contract_test.go @@ -0,0 +1,268 @@ +package billingaccesshttp_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingaccess" + billingaccesshttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/billingaccess" +) + +// The negotiated (POST) sync must never answer a bare 304. +// +// The risk is a cross-platform one rather than an HTTP one. A 304 carries no +// body, so the only place freshness could travel is the `Mosaic-…` response +// headers — and no frozen schema defines those names. Three SDKs each reading +// freshness out of undocumented headers is freshness the Authoritative +// Entitlement Contract cannot guarantee; the first platform to mistype one +// silently expires a paying customer's cache while the device is demonstrably +// in contact with the server. +// +// The canonical `snapshotUnchanged` record carries refreshAfter, validUntil, +// and staleGraceSeconds inside the schema every SDK already validates, so the +// negotiated form always answers 200 with it. The GET form is not conditional +// at all (defect D-5): it has no way to state a snapshot version, so it always +// answers 200 with the full snapshot. + +const ( + testCustomerID = "bcu_sync_test" + testProjectID = "proj_sync_test" + testEnvID = "env_sync_test" + testToken = "mcat_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + testSDKKey = "pk_sync.test" + testVersion = int64(7) +) + +// stubRepository serves one committed snapshot. Only the methods the sync path +// reaches do anything; the rest satisfy the port. +type stubRepository struct{} + +func (stubRepository) BillingEnabled(context.Context, string) (bool, error) { return true, 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) + return billingaccess.SnapshotView{ + SnapshotID: "ces_sync_test", ProjectID: projectID, EnvironmentID: environmentID, + CustomerID: customerID, SnapshotVersion: testVersion, RuleVersion: 1, + ComputedAt: at, AsOf: at, ChangeReason: "projection", + Projection: billingaccess.ProjectionStatus{ + State: billingaccess.ProjectionCurrent, LastProjectedAt: at, + }, + }, nil +} + +func (stubRepository) ProjectionStatusFor(context.Context, string, string, string) (billingaccess.ProjectionStatus, error) { + return billingaccess.ProjectionStatus{ + State: billingaccess.ProjectionCurrent, + LastProjectedAt: time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC), + }, nil +} + +func (stubRepository) TokenByDigest(context.Context, []byte) (billingaccess.Token, error) { + issued := time.Now().UTC().Add(-time.Minute) + return billingaccess.Token{ + ID: "cat_sync_test", ProjectID: testProjectID, EnvironmentID: testEnvID, + CustomerID: testCustomerID, Audience: billingaccess.AudienceSDKSync, + Scopes: []string{billingaccess.ScopeEntitlementsRead, billingaccess.ScopeEntitlementsSync}, + IssuedAt: issued, + ExpiresAt: issued.Add(time.Hour), + }, nil +} + +func (stubRepository) TouchToken(context.Context, string, time.Time) error { return nil } + +func (stubRepository) CreateToken(_ context.Context, token billingaccess.Token, _ []byte, _ string) (billingaccess.Token, error) { + return token, nil +} + +func (stubRepository) RevokeToken(context.Context, billingaccess.KeyScope, string, string, string, time.Time) (billingaccess.Token, error) { + return billingaccess.Token{}, nil +} +func (stubRepository) ListTokens(context.Context, billingaccess.KeyScope, string, int) ([]billingaccess.Token, error) { + return nil, nil +} +func (stubRepository) Customer(context.Context, string, string) (billingaccess.CustomerView, error) { + return billingaccess.CustomerView{}, nil +} +func (stubRepository) Subscriptions(context.Context, string, string, string, int, string) ([]billingaccess.SubscriptionView, string, error) { + return nil, "", nil +} +func (stubRepository) Subscription(context.Context, string, string) (billingaccess.SubscriptionView, error) { + return billingaccess.SubscriptionView{}, nil +} +func (stubRepository) Timeline(context.Context, string, string, int, string) ([]billingaccess.TimelineEntry, string, error) { + return nil, "", nil +} +func (stubRepository) RecordAudit(context.Context, string, string, string, string, string, string, map[string]string, time.Time) error { + return nil +} + +type stubKeys struct{} + +func (stubKeys) AuthenticateServerKey(context.Context, string) (billingaccess.KeyScope, error) { + return billingaccess.KeyScope{}, billingaccess.ErrUnauthenticated +} + +func (stubKeys) AuthenticateSDKKey(_ context.Context, raw string) (billingaccess.KeyScope, error) { + if strings.TrimSpace(raw) != testSDKKey { + return billingaccess.KeyScope{}, billingaccess.ErrUnauthenticated + } + return billingaccess.KeyScope{ProjectID: testProjectID, EnvironmentID: testEnvID}, nil +} + +func syncRouter() http.Handler { + service := billingaccess.NewService(stubRepository{}, stubKeys{}) + router := chi.NewRouter() + billingaccesshttp.RegisterSDKRoutes(router, service) + return router +} + +// entityTag reads the validator off a full snapshot response, so the +// conditional cases present a tag the server actually issued rather than a +// guess. +func entityTag(t *testing.T, handler http.Handler) string { + t.Helper() + request := httptest.NewRequest(http.MethodGet, "/sdk/billing/entitlements", nil) + request.Header.Set("Authorization", "Bearer "+testToken) + request.Header.Set(billingaccesshttp.SDKKeyHeader, testSDKKey) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("priming read: status %d, want 200 (%s)", recorder.Code, recorder.Body.String()) + } + return strings.Trim(recorder.Header().Get("ETag"), `"`) +} + +func syncBody(version int64, tag string) string { + payload := map[string]any{ + "authoritativeEntitlementContractVersion": billingaccess.ContractVersion, + "recordType": "entitlementSyncRequest", + "payload": map[string]any{ + "knownSnapshotVersion": version, + "entityTag": tag, + "supportedAuthoritativeEntitlementContracts": []string{billingaccess.ContractVersion}, + "correlationId": "corr_sync_test", + }, + } + encoded, _ := json.Marshal(payload) + return string(encoded) +} + +func TestNegotiatedSyncAnswersUnchangedRecordRatherThanBare304(t *testing.T) { + handler := syncRouter() + tag := entityTag(t, handler) + + // The hostile case: a matching version *and* an If-None-Match header, which + // is exactly what made the old handler take the 304 branch on POST. + request := httptest.NewRequest(http.MethodPost, "/sdk/billing/entitlements", + strings.NewReader(syncBody(testVersion, tag))) + request.Header.Set("Authorization", "Bearer "+testToken) + request.Header.Set(billingaccesshttp.SDKKeyHeader, testSDKKey) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("If-None-Match", `"`+tag+`"`) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("negotiated sync with a matching version: status %d, want 200 (never a bare 304)", recorder.Code) + } + + var envelope struct { + ContractVersion string `json:"authoritativeEntitlementContractVersion"` + RecordType string `json:"recordType"` + Payload struct { + SnapshotVersion int64 `json:"snapshotVersion"` + RefreshAfter string `json:"refreshAfter"` + ValidUntil string `json:"validUntil"` + StaleGraceSeconds *int `json:"staleGraceSeconds"` + } `json:"payload"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode unchanged record: %v (%s)", err, recorder.Body.String()) + } + if envelope.RecordType != "snapshotUnchanged" { + t.Fatalf("recordType %q, want snapshotUnchanged", envelope.RecordType) + } + if envelope.ContractVersion != billingaccess.ContractVersion { + t.Fatalf("contract version %q, want %q", envelope.ContractVersion, billingaccess.ContractVersion) + } + if envelope.Payload.SnapshotVersion != testVersion { + t.Fatalf("snapshotVersion %d, want %d", envelope.Payload.SnapshotVersion, testVersion) + } + + // The whole reason the negotiated form does not use 304: the freshness + // window has to be in the body, under the frozen schema, not only in + // headers no contract defines. + if envelope.Payload.RefreshAfter == "" || envelope.Payload.ValidUntil == "" || + envelope.Payload.StaleGraceSeconds == nil { + t.Fatalf("unchanged record must carry refreshed freshness windows: %s", recorder.Body.String()) + } + refreshAfter, err := time.Parse(time.RFC3339, envelope.Payload.RefreshAfter) + if err != nil { + t.Fatalf("parse refreshAfter: %v", err) + } + validUntil, err := time.Parse(time.RFC3339, envelope.Payload.ValidUntil) + if err != nil { + t.Fatalf("parse validUntil: %v", err) + } + // Refreshed, not echoed back from whatever the caller last held: both + // windows are ahead of now, which is what makes a device that keeps + // confirming the same version stay valid. + now := time.Now().UTC() + if !refreshAfter.After(now) || !validUntil.After(refreshAfter) { + t.Fatalf("freshness not refreshed: refreshAfter=%s validUntil=%s now=%s", + refreshAfter, validUntil, now) + } +} + +// The GET form is a plain full-snapshot read (defect D-5, ratified). +// +// There is one conditional mechanism on this surface and it is the POST body's +// `knownSnapshotVersion`. The GET form has no way to state a version, and +// version equality is a precondition of `unchanged` because a matching entity +// tag alone would confirm a cache without proving monotonicity — so the +// handler's old 304 branch was unreachable on every request that could ever +// have taken it. The branch is gone; this test pins what replaced it: a 200 +// with the full snapshot and the freshness headers, whatever the caller sends +// in If-None-Match. +// +// The If-None-Match header is still sent here deliberately. It is the header an +// ordinary HTTP client sends without being asked, and the risk this test +// protects against is a future edit reintroducing a bodyless answer to it on +// the highest-QPS authenticated surface Mosaic serves. +func TestConditionalGetAnswersFullSnapshotWithFreshnessHeaders(t *testing.T) { + handler := syncRouter() + tag := entityTag(t, handler) + + request := httptest.NewRequest(http.MethodGet, "/sdk/billing/entitlements", nil) + request.Header.Set("Authorization", "Bearer "+testToken) + request.Header.Set(billingaccesshttp.SDKKeyHeader, testSDKKey) + request.Header.Set("If-None-Match", `"`+tag+`"`) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("GET with If-None-Match: status %d, want 200 (%s)", recorder.Code, recorder.Body.String()) + } + var envelope struct { + RecordType string `json:"recordType"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode snapshot: %v", err) + } + if envelope.RecordType != "customerEntitlementSnapshot" { + t.Fatalf("recordType %q, want customerEntitlementSnapshot", envelope.RecordType) + } + for _, header := range []string{"ETag", "Mosaic-Refresh-After", "Mosaic-Valid-Until", "Mosaic-Stale-Grace-Seconds"} { + if recorder.Header().Get(header) == "" { + t.Fatalf("GET is missing %s; the freshness window must be visible without parsing the body", header) + } + } +} diff --git a/apps/api/internal/transport/billingcustomer/handler.go b/apps/api/internal/transport/billingcustomer/handler.go new file mode 100644 index 00000000..c9203098 --- /dev/null +++ b/apps/api/internal/transport/billingcustomer/handler.go @@ -0,0 +1,374 @@ +// Package billingcustomerhttp exposes Mosaic's billing-identity APIs over HTTP: +// the trusted create-or-get of a Billing Customer, alias attach/revoke/list, +// identity-conflict inspection, and manual projection requests (plan §11). +// +// Everything here is authenticated by a secret server API key presented in +// `Authorization: Bearer`. There is deliberately no public-SDK-key path and no +// route that accepts an installation identifier: the application-user alias is +// assertable only by the customer's own backend, and a client-generated +// installation id must never be able to create or select a customer (plan §5a, +// OD-4(a)). Those two properties are enforced by the absence of a surface, not +// by a check a future edit could remove. +// +// Handlers are strictly thin: decode, validate transport shape, call the +// application service, map the result. No handler decides authorization, +// touches the database, or calls render.JSON. +// +// No response in this package carries an alias value or an alias digest. An +// alias digest is still a stable per-person identifier, and nothing on a server +// or operator surface needs one. +package billingcustomerhttp + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + validation "github.com/go-ozzo/ozzo-validation/v4" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingcustomer" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +// maxBodyBytes bounds every request body here. The largest legitimate body is +// an identify request carrying one application user id. +const maxBodyBytes = 8 * 1024 + +type Handler struct { + service *billingcustomer.Service +} + +// RegisterTrustedRoutes mounts the secret-server-authenticated identity APIs. +// +// They live under /billing/identity rather than under the /billing/server +// subtree the access APIs mount, so the two modules own disjoint route trees +// and neither can shadow the other. +func RegisterTrustedRoutes(router chi.Router, service *billingcustomer.Service, limiters ...func(http.Handler) http.Handler) { + h := &Handler{service: service} + router.Group(func(trusted chi.Router) { + for _, limiter := range limiters { + if limiter != nil { + trusted.Use(limiter) + } + } + trusted.Route("/billing/identity", func(identity chi.Router) { + identity.Post("/customers", h.identifyCustomer) + identity.Get("/customers/{customerId}/aliases", h.listAliases) + identity.Post("/customers/{customerId}/aliases", h.attachAlias) + identity.Post("/customers/{customerId}/sync-requests", h.requestSync) + + identity.Post("/aliases/{aliasId}/revoke", h.revokeAlias) + + identity.Get("/conflicts", h.listConflicts) + identity.Get("/conflicts/{conflictId}", h.conflict) + }) + }) +} + +// --------------------------------------------------------------------------- +// Customers and aliases +// --------------------------------------------------------------------------- + +// identifyRequest is the only body that can bring a Billing Customer into +// existence over HTTP. It accepts exactly one field, so a caller cannot smuggle +// an installation identifier, a Project, or an Environment into the creation +// path: the tenant comes from the authenticated key. +type identifyRequest struct { + ApplicationUserID string `json:"applicationUserId"` +} + +func (q identifyRequest) Validate() error { + return validation.ValidateStruct(&q, + validation.Field(&q.ApplicationUserID, validation.Required, validation.Length(1, 512)), + ) +} + +func (h *Handler) identifyCustomer(w http.ResponseWriter, r *http.Request) { + var request identifyRequest + if !decode(w, r, &request) { + return + } + if err := request.Validate(); err != nil { + writeValidation(w, r, validationFields(err)) + return + } + customer, created, err := h.service.IdentifyCustomer(r.Context(), bearer(r), request.ApplicationUserID) + if err != nil { + writeError(w, r, err) + return + } + if created { + response.Created(w, r, customerResponse(customer)) + return + } + response.OK(w, r, customerResponse(customer)) +} + +type attachAliasRequest struct { + ApplicationUserID string `json:"applicationUserId"` +} + +func (q attachAliasRequest) Validate() error { + return validation.ValidateStruct(&q, + validation.Field(&q.ApplicationUserID, validation.Required, validation.Length(1, 512)), + ) +} + +func (h *Handler) attachAlias(w http.ResponseWriter, r *http.Request) { + var request attachAliasRequest + if !decode(w, r, &request) { + return + } + if err := request.Validate(); err != nil { + writeValidation(w, r, validationFields(err)) + return + } + alias, err := h.service.AttachAliasForServer(r.Context(), bearer(r), + strings.TrimSpace(chi.URLParam(r, "customerId")), request.ApplicationUserID) + if err != nil { + writeError(w, r, err) + return + } + response.Created(w, r, aliasResponse(alias)) +} + +func (h *Handler) revokeAlias(w http.ResponseWriter, r *http.Request) { + if err := h.service.RevokeAliasForServer(r.Context(), bearer(r), + strings.TrimSpace(chi.URLParam(r, "aliasId"))); err != nil { + writeError(w, r, err) + return + } + response.NoContent(w, r) +} + +func (h *Handler) listAliases(w http.ResponseWriter, r *http.Request) { + aliases, err := h.service.ListAliasesForServer(r.Context(), bearer(r), + strings.TrimSpace(chi.URLParam(r, "customerId"))) + if err != nil { + writeError(w, r, err) + return + } + items := make([]map[string]any, 0, len(aliases)) + for _, alias := range aliases { + items = append(items, aliasResponse(alias)) + } + response.OK(w, r, map[string]any{"items": items}) +} + +// customerResponse is the API shape of a Billing Customer. It carries no alias +// values, because Mosaic stores none. +func customerResponse(customer billingcustomer.Customer) map[string]any { + payload := map[string]any{ + "billingCustomerId": customer.ID, + "projectId": customer.ProjectID, + "status": customer.Status, + "diagnosticsStatus": customer.DiagnosticsStatus, + "currentProjectionVersion": customer.CurrentProjectionVersion, + "createdAt": customer.CreatedAt.UTC().Format(timestampLayout), + "updatedAt": customer.UpdatedAt.UTC().Format(timestampLayout), + } + if customer.LastProjectedAt != nil { + payload["lastProjectedAt"] = customer.LastProjectedAt.UTC().Format(timestampLayout) + } + return payload +} + +// aliasResponse renders one alias. The digest is unreachable from here by +// construction: billingcustomer.Alias keeps it in an unexported field and this +// function never calls the accessor. +func aliasResponse(alias billingcustomer.Alias) map[string]any { + payload := map[string]any{ + "aliasId": alias.ID, + "billingCustomerId": alias.BillingCustomerID, + "aliasType": alias.AliasType, + "sourceAuthority": alias.SourceAuthority, + "verificationStatus": alias.VerificationStatus, + "effectiveStart": alias.EffectiveStart.UTC().Format(timestampLayout), + "createdAt": alias.CreatedAt.UTC().Format(timestampLayout), + } + if alias.EffectiveEnd != nil { + payload["effectiveEnd"] = alias.EffectiveEnd.UTC().Format(timestampLayout) + } + return payload +} + +// --------------------------------------------------------------------------- +// Identity conflicts +// --------------------------------------------------------------------------- + +func (h *Handler) listConflicts(w http.ResponseWriter, r *http.Request) { + conflicts, err := h.service.ListConflictsForServer(r.Context(), bearer(r), + strings.TrimSpace(r.URL.Query().Get("status"))) + if err != nil { + writeError(w, r, err) + return + } + items := make([]map[string]any, 0, len(conflicts)) + for _, conflict := range conflicts { + items = append(items, conflictResponse(conflict)) + } + response.OK(w, r, map[string]any{"items": items}) +} + +func (h *Handler) conflict(w http.ResponseWriter, r *http.Request) { + detail, err := h.service.ConflictDetailForServer(r.Context(), bearer(r), + strings.TrimSpace(chi.URLParam(r, "conflictId"))) + if err != nil { + writeError(w, r, err) + return + } + payload := map[string]any{"conflict": conflictResponse(detail.Conflict)} + if detail.Lineage != nil { + payload["lineage"] = map[string]any{ + "purchaseLineageId": detail.Lineage.ID, + "environmentId": detail.Lineage.EnvironmentID, + "provider": detail.Lineage.Provider, + "storeEnvironment": detail.Lineage.StoreEnvironment, + "lineageType": detail.Lineage.LineageType, + "projectionFrozen": detail.Lineage.ProjectionFrozen, + "diagnosticStatus": detail.Lineage.DiagnosticStatus, + } + } + response.OK(w, r, payload) +} + +// conflictResponse renders one conflict. The disputed alias *type* is useful to +// an operator; the disputed alias digest is not rendered under any scope. +func conflictResponse(conflict billingcustomer.Conflict) map[string]any { + payload := map[string]any{ + "conflictId": conflict.ID, + "projectId": conflict.ProjectID, + "scope": conflict.Scope, + "status": conflict.Status, + "firstCustomerId": conflict.FirstCustomerID, + "secondCustomerId": conflict.SecondCustomerID, + "openedAt": conflict.OpenedAt.UTC().Format(timestampLayout), + } + if conflict.PurchaseLineageID != "" { + payload["purchaseLineageId"] = conflict.PurchaseLineageID + } + if conflict.AliasType != "" { + payload["aliasType"] = conflict.AliasType + } + if conflict.DiagnosticCode != "" { + payload["diagnosticCode"] = conflict.DiagnosticCode + } + if conflict.ResolvedAt != nil { + payload["resolvedAt"] = conflict.ResolvedAt.UTC().Format(timestampLayout) + payload["resolutionAction"] = conflict.ResolutionAction + } + return payload +} + +// --------------------------------------------------------------------------- +// Manual sync +// --------------------------------------------------------------------------- + +// requestSync schedules a projection and answers 202 with the handle. It is +// deliberately not a read: the answer is "this has been queued", and a caller +// that needs the result reads the entitlement surfaces once the projection +// version moves. +func (h *Handler) requestSync(w http.ResponseWriter, r *http.Request) { + request, err := h.service.RequestSync(r.Context(), bearer(r), + strings.TrimSpace(chi.URLParam(r, "customerId"))) + if err != nil { + writeError(w, r, err) + return + } + response.Accepted(w, r, map[string]any{ + "billingCustomerId": request.BillingCustomerID, + "projectId": request.ProjectID, + "environmentId": request.EnvironmentID, + "projectionScopeKey": request.ScopeKey, + "triggerKind": request.Kind, + "requestedAt": request.RequestedAt.UTC().Format(timestampLayout), + "status": "queued", + }) +} + +// --------------------------------------------------------------------------- +// Transport helpers +// --------------------------------------------------------------------------- + +const timestampLayout = "2006-01-02T15:04:05.000Z" + +func decode(w http.ResponseWriter, r *http.Request, target any) bool { + if encoding := r.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + writeValidation(w, r, map[string][]string{"body": {"The request encoding is not supported."}}) + return false + } + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + decoder := json.NewDecoder(r.Body) + // An unknown member is rejected rather than dropped. On this surface a + // silently ignored field would let a caller believe it had supplied an + // identifier that Mosaic never read. + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeValidation(w, r, map[string][]string{"body": {"The request body could not be read."}}) + 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 ") { + return strings.TrimSpace(value[7:]) + } + return "" +} + +func validationFields(err error) map[string][]string { + fields := map[string][]string{} + var errs validation.Errors + if errors.As(err, &errs) { + for name, fieldErr := range errs { + fields[name] = []string{fieldErr.Error()} + } + return fields + } + fields["body"] = []string{"The request contains invalid fields."} + return fields +} + +func writeValidation(w http.ResponseWriter, r *http.Request, fields map[string][]string) { + response.Error(w, r, response.ValidationFailed(fields)) +} + +// writeError maps identity-domain errors onto HTTP in one place. The Cause is +// never populated: response.Error logs the cause behind every 5xx, and on this +// surface a cause can quote an Authorization header. +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, billingcustomer.ErrUnauthenticated): + status, code, message = http.StatusUnauthorized, "unauthenticated", "Authentication is required." + case errors.Is(err, billingcustomer.ErrForbidden): + status, code, message = http.StatusForbidden, "forbidden", "The credential does not cover this resource." + case errors.Is(err, billingcustomer.ErrNotFound): + status, code, message = http.StatusNotFound, "not_found", "The requested resource was not found." + case errors.Is(err, billingcustomer.ErrInvalidAlias): + status, code, message = http.StatusUnprocessableEntity, "validation_failed", + "The request contains invalid fields." + case errors.Is(err, billingcustomer.ErrIdentityConflict): + // The distinct code matters. `conflict` invites a retry; this one tells + // the caller an identity conflict was opened, nothing was reassigned, + // and an operator now owns the repair (OD-10). + status, code, message = http.StatusConflict, "identity_conflict", + "The identity is claimed by another Billing Customer and is held for operator resolution." + case errors.Is(err, billingcustomer.ErrFrozen): + status, code, message = http.StatusConflict, "identity_frozen", + "The Billing Customer's identity is frozen by an open conflict." + case errors.Is(err, billingcustomer.ErrConflict): + status, code, message = http.StatusConflict, "conflict", "The resource is in a conflicting state." + case errors.Is(err, billingcustomer.ErrBillingDisabled): + status, code, message = http.StatusConflict, "billing_not_enabled", + "Mosaic Billing is not enabled for this Project." + case errors.Is(err, billingcustomer.ErrUnavailable): + status, code, message = http.StatusServiceUnavailable, "billing_storage_unavailable", + "Billing identity could not be read." + } + response.Error(w, r, response.NewAPIError(status, code, message)) +} diff --git a/apps/api/internal/transport/billingdiagnostics/handler.go b/apps/api/internal/transport/billingdiagnostics/handler.go new file mode 100644 index 00000000..b03a8ec5 --- /dev/null +++ b/apps/api/internal/transport/billingdiagnostics/handler.go @@ -0,0 +1,163 @@ +// Package billingdiagnosticshttp exposes the Phase 9B projection health surface +// over HTTP. +// +// The handler is a transport adapter and nothing else: it reads the path, +// calls the application service, and writes a standardized response. It makes +// no authorization decision — the repository does, against organization +// membership — and it never calls render.JSON. +package billingdiagnosticshttp + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + validation "github.com/go-ozzo/ozzo-validation/v4" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingdiagnostics" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +type Handler struct { + service *billingdiagnostics.Service +} + +// RegisterProjectRoutes mounts the operator surface. Projection health is a +// sibling of the Phase 9A billing health route (`/billing/health`) rather than a +// field on it, because the two summaries are read by operators answering +// different questions and merging them would make one page that is wrong for +// both. +// +// `expensive` is the export-class rate limit the other history-scanning billing +// operations already share. A replay recomputes committed state for every scope +// it names, so it belongs in that bucket rather than the baseline API one. +func RegisterProjectRoutes(router chi.Router, service *billingdiagnostics.Service, + expensive ...func(http.Handler) http.Handler) { + + h := &Handler{service: service} + router.Get("/environments/{environmentId}/billing/projection-health", h.projectionHealth) + + guarded := make([]func(http.Handler) http.Handler, 0, len(expensive)) + for _, middleware := range expensive { + if middleware != nil { + guarded = append(guarded, middleware) + } + } + router.With(guarded...). + Post("/environments/{environmentId}/billing/projection-replays", h.replay) +} + +// replayRequest is the transport shape of a bounded replay. +// +// There is deliberately no "replay everything" member. An unbounded replay is +// not a replay, it is a migration, and bulk migration tooling is out of Phase 9B +// (plan §18) — so the absence of the field, rather than a check, is what makes +// it unavailable. +type replayRequest struct { + SubscriptionInstanceID string `json:"subscriptionInstanceId,omitempty"` + BillingCustomerID string `json:"billingCustomerId,omitempty"` + WindowStart string `json:"windowStart,omitempty"` + WindowEnd string `json:"windowEnd,omitempty"` + ProjectionRuleVersion int `json:"projectionRuleVersion,omitempty"` + Limit int `json:"limit,omitempty"` +} + +func (q replayRequest) Validate() error { + return validation.ValidateStruct(&q, + validation.Field(&q.SubscriptionInstanceID, validation.Length(0, 128)), + validation.Field(&q.BillingCustomerID, validation.Length(0, 128)), + validation.Field(&q.WindowStart, validation.Date(time.RFC3339)), + validation.Field(&q.WindowEnd, validation.Date(time.RFC3339)), + validation.Field(&q.ProjectionRuleVersion, validation.Min(0), validation.Max(1000000)), + validation.Field(&q.Limit, validation.Min(0), validation.Max(500)), + ) +} + +func (h *Handler) replay(w http.ResponseWriter, r *http.Request) { + var request replayRequest + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + response.Error(w, r, response.ValidationFailed(map[string][]string{ + "body": {"The request body could not be read."}})) + return + } + if err := request.Validate(); err != nil { + response.Error(w, r, response.ValidationFailed(map[string][]string{ + "body": {"The request contains invalid fields."}})) + return + } + + principal, _ := authn.FromContext(r.Context()) + result, err := h.service.Replay(r.Context(), billingdiagnostics.Actor{ID: principal.ActorID}, + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), + billingdiagnostics.ReplayRequest{ + SubscriptionInstanceID: request.SubscriptionInstanceID, + CustomerID: request.BillingCustomerID, + WindowStart: optionalTime(request.WindowStart), + WindowEnd: optionalTime(request.WindowEnd), + RuleVersion: request.ProjectionRuleVersion, + Limit: request.Limit, + }) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, result) +} + +func optionalTime(value string) *time.Time { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil + } + utc := parsed.UTC() + return &utc +} + +// maxBodyBytes bounds the replay request. The largest legitimate body names one +// instance, one customer, a window, a rule version, and a limit. +const maxBodyBytes = 4 * 1024 + +func (h *Handler) projectionHealth(w http.ResponseWriter, r *http.Request) { + principal, _ := authn.FromContext(r.Context()) + health, err := h.service.ProjectionHealth(r.Context(), + billingdiagnostics.Actor{ID: principal.ActorID}, + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, health) +} + +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, billingdiagnostics.ErrUnauthenticated): + status, code, message = http.StatusUnauthorized, "unauthenticated", "Authentication is required." + case errors.Is(err, billingdiagnostics.ErrForbidden): + status, code, message = http.StatusForbidden, "forbidden", "The actor may not read this resource." + case errors.Is(err, billingdiagnostics.ErrNotFound): + status, code, message = http.StatusNotFound, "not_found", "The requested resource was not found." + case errors.Is(err, billingdiagnostics.ErrInvalid): + status, code, message = http.StatusUnprocessableEntity, "validation_failed", + "The replay must be bounded and must name a rule version this build derives under." + case errors.Is(err, billingdiagnostics.ErrBillingDisabled): + status, code, message = http.StatusConflict, "billing_not_enabled", + "Mosaic Billing is not enabled for this Project." + case errors.Is(err, billingdiagnostics.ErrUnavailable): + status, code, message = http.StatusServiceUnavailable, "billing_storage_unavailable", + "Projection health could not be read." + } + response.Error(w, r, response.NewAPIError(status, code, message)) +} diff --git a/apps/api/internal/transport/billinggrant/handler.go b/apps/api/internal/transport/billinggrant/handler.go new file mode 100644 index 00000000..9afba0f5 --- /dev/null +++ b/apps/api/internal/transport/billinggrant/handler.go @@ -0,0 +1,377 @@ +// Package billinggranthttp exposes the Phase 9B Product-to-Entitlement Grant +// Version management surface over HTTP (WP9). +// +// Three operations, and the boundaries between them are the design: +// +// - reading a pair's history changes nothing and is available to any member +// of the owning organization; +// - previewing impact changes nothing, not even the audit trail, so an +// operator may ask as often as they like before deciding; +// - publishing is the one call that changes what a Product grants, and it +// requires an actor, a reason, and an admin role. +// +// There is deliberately no update route that succeeds. A published version is +// immutable — that is what makes historical access reproducible — so the edit +// verbs answer a specific 409 telling the caller to publish a superseding +// version, rather than a bare 405 that reads like a routing mistake. +// +// Handlers are strictly thin: decode, validate transport shape, call the +// application service, map the result. No handler decides authorization, +// touches the database, or calls render.JSON. +package billinggranthttp + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + validation "github.com/go-ozzo/ozzo-validation/v4" + + "github.com/Mujhtech/mosaic/apps/api/internal/billinggrant" + "github.com/Mujhtech/mosaic/apps/api/internal/billingprojection" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +// maxBodyBytes bounds every request body here. The largest legitimate body +// names one pair, one instant, five policy flags, and a reason. +const maxBodyBytes = 8 * 1024 + +const timestampLayout = "2006-01-02T15:04:05.000Z" + +type Handler struct { + service *billinggrant.Service +} + +// RegisterProjectRoutes mounts the grant-version surface under the +// Project-scoped authenticated subtree. +// +// `expensive` is the export-class rate limit the other history-scanning billing +// operations share. Publishing enqueues a reprojection for every affected +// customer and the preview counts across the Project's current snapshots, so +// both belong in that bucket rather than the baseline API one. The history read +// does not: it is a plain indexed lookup an operator refreshes while working. +func RegisterProjectRoutes(router chi.Router, service *billinggrant.Service, + expensive ...func(http.Handler) http.Handler) { + + h := &Handler{service: service} + guarded := make([]func(http.Handler) http.Handler, 0, len(expensive)) + for _, middleware := range expensive { + if middleware != nil { + guarded = append(guarded, middleware) + } + } + + router.Route("/billing/grant-versions", func(versions chi.Router) { + versions.Get("/", h.list) + versions.Get("/{versionId}", h.version) + versions.With(guarded...).Post("/", h.publish) + versions.With(guarded...).Post("/impact-preview", h.preview) + + // The three verbs a caller reaches for when it wants to edit. Each + // answers the same 409, because the answer is the same: a published + // grant version is a historical fact, and changing it would change what + // a customer was entitled to at a moment that has already passed. + versions.Patch("/{versionId}", h.immutable) + versions.Put("/{versionId}", h.immutable) + versions.Delete("/{versionId}", h.immutable) + }) +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + limit := 0 + if raw := strings.TrimSpace(query.Get("limit")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > billinggrant.MaxListLimit { + writeValidation(w, r, map[string][]string{ + "limit": {"limit must be between 1 and " + strconv.Itoa(billinggrant.MaxListLimit) + "."}}) + return + } + limit = parsed + } + versions, err := h.service.ListVersions(r.Context(), actor(r), chi.URLParam(r, "projectId"), + billinggrant.ListFilter{ + ProductID: strings.TrimSpace(query.Get("productId")), + EntitlementID: strings.TrimSpace(query.Get("entitlementId")), + CurrentOnly: query.Get("currentOnly") == "true", + Limit: limit, + }) + if err != nil { + writeError(w, r, err) + return + } + items := make([]map[string]any, 0, len(versions)) + for _, version := range versions { + items = append(items, versionResponse(version)) + } + response.OK(w, r, map[string]any{"items": items}) +} + +func (h *Handler) version(w http.ResponseWriter, r *http.Request) { + version, err := h.service.Version(r.Context(), actor(r), chi.URLParam(r, "projectId"), + strings.TrimSpace(chi.URLParam(r, "versionId"))) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, versionResponse(version)) +} + +// --------------------------------------------------------------------------- +// Preview and publish +// --------------------------------------------------------------------------- + +// proposalRequest is the transport shape of a proposed grant version. Preview +// and publish take the same body, so an operator previews exactly what they are +// about to publish rather than something adjacent to it. +type proposalRequest struct { + ProductID string `json:"productId"` + EntitlementID string `json:"entitlementId"` + EffectiveStart string `json:"effectiveStart"` + Retroactive bool `json:"retroactive,omitempty"` + SupportedPurchaseTypes []string `json:"supportedPurchaseTypes,omitempty"` + GrantsInActive *bool `json:"grantsInActive,omitempty"` + GrantsInTrial *bool `json:"grantsInTrial,omitempty"` + GrantsInGrace *bool `json:"grantsInGrace,omitempty"` + GrantsInBillingRetry *bool `json:"grantsInBillingRetry,omitempty"` + GrantsInPaused *bool `json:"grantsInPaused,omitempty"` + GrantsInOneTime *bool `json:"grantsInOneTimeOwnership,omitempty"` + Reason string `json:"reason,omitempty"` +} + +func (q proposalRequest) Validate() error { + return validation.ValidateStruct(&q, + validation.Field(&q.ProductID, validation.Required, validation.Length(1, 128)), + validation.Field(&q.EntitlementID, validation.Required, validation.Length(1, 128)), + validation.Field(&q.EffectiveStart, validation.Required, validation.Date(time.RFC3339)), + validation.Field(&q.SupportedPurchaseTypes, validation.Length(0, 4)), + validation.Field(&q.Reason, validation.Length(0, 512)), + ) +} + +// input maps the request onto the domain proposal. +// +// The policy flags are pointers so an omitted flag takes the documented default +// rather than Go's zero value. A missing `grantsInActive` defaulting to false +// would publish a version that grants nothing during an active subscription — +// the exact opposite of what an operator who left the field out meant. +func (q proposalRequest) input() billinggrant.PublishInput { + start, _ := time.Parse(time.RFC3339, strings.TrimSpace(q.EffectiveStart)) + return billinggrant.PublishInput{ + ProductID: strings.TrimSpace(q.ProductID), + EntitlementID: strings.TrimSpace(q.EntitlementID), + EffectiveStart: start.UTC(), + Retroactive: q.Retroactive, + SupportedPurchaseTypes: q.SupportedPurchaseTypes, + Policy: billingprojection.Policy{ + GrantsInActive: boolOr(q.GrantsInActive, true), + GrantsInTrial: boolOr(q.GrantsInTrial, true), + GrantsInGrace: boolOr(q.GrantsInGrace, true), + GrantsInBillingRetry: boolOr(q.GrantsInBillingRetry, false), + GrantsInOneTime: boolOr(q.GrantsInOneTime, true), + }, + GrantsInPaused: boolOr(q.GrantsInPaused, false), + Reason: strings.TrimSpace(q.Reason), + } +} + +func boolOr(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} + +func (h *Handler) preview(w http.ResponseWriter, r *http.Request) { + var request proposalRequest + if !decode(w, r, &request) { + return + } + if err := request.Validate(); err != nil { + writeValidation(w, r, validationFields(err)) + return + } + impact, err := h.service.PreviewImpact(r.Context(), actor(r), chi.URLParam(r, "projectId"), request.input()) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, impactResponse(impact)) +} + +func (h *Handler) publish(w http.ResponseWriter, r *http.Request) { + var request proposalRequest + if !decode(w, r, &request) { + return + } + if err := request.Validate(); err != nil { + writeValidation(w, r, validationFields(err)) + return + } + published, err := h.service.Publish(r.Context(), actor(r), chi.URLParam(r, "projectId"), request.input()) + if err != nil { + writeError(w, r, err) + return + } + response.Created(w, r, versionResponse(published)) +} + +// immutable answers every in-place edit of a published version. +func (h *Handler) immutable(w http.ResponseWriter, r *http.Request) { + writeError(w, r, billinggrant.ErrImmutable) +} + +// --------------------------------------------------------------------------- +// Responses +// --------------------------------------------------------------------------- + +func versionResponse(version billinggrant.Version) map[string]any { + payload := map[string]any{ + "grantVersionId": version.ID, + "projectId": version.ProjectID, + "productId": version.ProductID, + "productKey": version.ProductKey, + "entitlementId": version.EntitlementID, + "entitlementKey": version.EntitlementKey, + "version": version.Version, + "grantPolicyVersion": version.GrantPolicyVersion, + "effectiveStart": version.EffectiveStart.UTC().Format(timestampLayout), + "current": version.Current(), + "retroactive": version.Retroactive, + "supportedPurchaseTypes": version.SupportedPurchaseTypes, + "accessPolicy": map[string]any{ + "grantsInActive": version.Policy.GrantsInActive, + "grantsInTrial": version.Policy.GrantsInTrial, + "grantsInGrace": version.Policy.GrantsInGrace, + "grantsInBillingRetry": version.Policy.GrantsInBillingRetry, + "grantsInPaused": false, + "grantsInOneTimeOwnership": version.Policy.GrantsInOneTime, + }, + "createdAt": version.CreatedAt.UTC().Format(timestampLayout), + "reason": version.Reason, + } + if version.EffectiveEnd != nil { + payload["effectiveEnd"] = version.EffectiveEnd.UTC().Format(timestampLayout) + } + if version.CreatedByActorID != "" { + payload["createdByActorId"] = version.CreatedByActorID + } + return payload +} + +func impactResponse(impact billinggrant.Impact) map[string]any { + payload := map[string]any{ + "productId": impact.ProductID, + "entitlementId": impact.EntitlementID, + "impactedProducts": impact.ImpactedProducts, + "impactedEntitlements": impact.ImpactedEntitlements, + "impactedCustomers": impact.ImpactedCustomers, + "impactedActiveSources": impact.ImpactedActiveSources, + "impactedLineages": impact.ImpactedLineages, + "retroactive": impact.Retroactive, + "additiveSuperset": impact.AdditiveSuperset, + "observedAt": impact.ObservedAt.UTC().Format(timestampLayout), + } + if impact.NarrowingCode != "" { + payload["narrowingCode"] = impact.NarrowingCode + } + if impact.CurrentVersion != nil { + payload["currentVersion"] = versionResponse(*impact.CurrentVersion) + } + return payload +} + +// --------------------------------------------------------------------------- +// Transport helpers +// --------------------------------------------------------------------------- + +func actor(r *http.Request) billinggrant.Actor { + principal, _ := authn.FromContext(r.Context()) + return billinggrant.Actor{ID: principal.ActorID} +} + +func decode(w http.ResponseWriter, r *http.Request, target any) bool { + if encoding := r.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + writeValidation(w, r, map[string][]string{"body": {"The request encoding is not supported."}}) + return false + } + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + decoder := json.NewDecoder(r.Body) + // An unknown member is rejected rather than dropped. On a surface that + // decides access, a silently ignored field would let an operator believe + // they had set a policy Mosaic never read. + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeValidation(w, r, map[string][]string{"body": {"The request body could not be read."}}) + return false + } + return true +} + +func validationFields(err error) map[string][]string { + fields := map[string][]string{} + var errs validation.Errors + if errors.As(err, &errs) { + for name, fieldErr := range errs { + fields[name] = []string{fieldErr.Error()} + } + return fields + } + fields["body"] = []string{"The request contains invalid fields."} + return fields +} + +func writeValidation(w http.ResponseWriter, r *http.Request, fields map[string][]string) { + response.Error(w, r, response.ValidationFailed(fields)) +} + +// writeError maps grant-domain errors onto HTTP in one place. +// +// The four refusal codes are deliberately distinct rather than one `conflict`. +// An operator told "conflict" retries; an operator told +// `grant_version_immutable`, `grant_interval_overlap`, or +// `grant_not_additive_superset` knows which rule they hit and what to do +// instead, and each of those is a different next action. +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, billinggrant.ErrUnauthenticated): + status, code, message = http.StatusUnauthorized, "unauthenticated", "Authentication is required." + case errors.Is(err, billinggrant.ErrForbidden): + status, code, message = http.StatusForbidden, "forbidden", + "The actor may not publish grant versions for this Project." + case errors.Is(err, billinggrant.ErrNotFound): + status, code, message = http.StatusNotFound, "not_found", "The requested resource was not found." + case errors.Is(err, billinggrant.ErrImmutable): + status, code, message = http.StatusConflict, "grant_version_immutable", + "A published grant version cannot be edited. Publish a superseding version instead." + case errors.Is(err, billinggrant.ErrOverlap): + status, code, message = http.StatusUnprocessableEntity, "grant_interval_overlap", + "The proposed effective start overlaps a recorded grant version." + case errors.Is(err, billinggrant.ErrNotAdditiveSuperset): + status, code, message = http.StatusUnprocessableEntity, "grant_not_additive_superset", + "A retroactive grant version may add or widen access, never remove or narrow it." + case errors.Is(err, billinggrant.ErrInvalid): + status, code, message = http.StatusUnprocessableEntity, "validation_failed", + "The proposed grant version is not permitted." + case errors.Is(err, billinggrant.ErrConflict): + status, code, message = http.StatusConflict, "conflict", + "The grant history changed while this change was being validated. Review it and try again." + case errors.Is(err, billinggrant.ErrBillingDisabled): + status, code, message = http.StatusConflict, "billing_not_enabled", + "Mosaic Billing is not enabled for this Project." + case errors.Is(err, billinggrant.ErrUnavailable): + status, code, message = http.StatusServiceUnavailable, "billing_storage_unavailable", + "Grant versions could not be read." + } + response.Error(w, r, response.NewAPIError(status, code, message)) +} diff --git a/apps/api/internal/transport/billingoperator/handler.go b/apps/api/internal/transport/billingoperator/handler.go new file mode 100644 index 00000000..ac6e1fb4 --- /dev/null +++ b/apps/api/internal/transport/billingoperator/handler.go @@ -0,0 +1,438 @@ +// Package billingoperatorhttp exposes Mosaic's Phase 9B operator surface over +// HTTP: billing customer search, list, and detail; entitlement snapshot, +// subscription, and timeline reads; identity conflict inspection and +// resolution; and restore/sync visibility. +// +// Every route here is registered inside the authenticated dashboard subtree, so +// it is reached with a browser session principal and nothing else. That is the +// whole point of the package: the equivalent trusted-server surfaces +// authenticate a secret server API key, which a browser does not hold and must +// not be given, so the dashboard could not reach any 9B customer state at all +// before these routes existed. The secret-key surfaces are unchanged — they are +// the application-backend contract. +// +// Handlers are strictly thin: read the path, decode, validate transport shape, +// call the application service, write a standardized response. No handler +// decides authorization, touches the database, or calls render.JSON. +// +// No response in this package carries an alias value or an alias digest. The +// application service's view types have no field for one. +package billingoperatorhttp + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + validation "github.com/go-ozzo/ozzo-validation/v4" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingoperator" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +// maxBodyBytes bounds every request body here. The largest legitimate body is a +// conflict resolution carrying a five-hundred-character reason. +const maxBodyBytes = 8 * 1024 + +type Handler struct { + service *billingoperator.Service +} + +// RegisterEnvironmentRoutes mounts the Environment-scoped half of the operator +// surface on the shared `/environments/{environmentId}/billing` subrouter, +// following exactly the shape the Phase 9A billing operator pages already use. +// +// The subrouter is created once by the composition rather than here. Three +// modules publish routes under that path, and each of them used to call chi's +// Route() with the same pattern; chi refuses to Mount() twice on one path, so +// the deployed composition panicked during router construction whenever billing +// was enabled (defect D-3). Registering into a shared subrouter makes that +// collision impossible instead of latent, and every path below is unchanged. +// +// `guarded` is the export-class rate limit. Two routes take it. The lookup is +// bounded because it is the one surface that accepts an attacker-chosen +// identifier and reports whether it matched, and an unbounded one is an +// enumeration oracle over a Project's users even though it returns nothing on a +// miss. The sync request is bounded because it enqueues projection work. +func RegisterEnvironmentRoutes(environment chi.Router, service *billingoperator.Service, + guarded ...func(http.Handler) http.Handler) { + + h := &Handler{service: service} + limited := nonNil(guarded) + + environment.Get("/customers", h.listCustomers) + environment.With(limited...).Post("/customer-lookups", h.lookupCustomer) + environment.Get("/customers/{customerId}", h.customer) + environment.Get("/customers/{customerId}/entitlements", h.snapshot) + environment.Get("/customers/{customerId}/subscriptions", h.subscriptions) + environment.With(limited...).Post("/customers/{customerId}/sync-requests", h.requestSync) + + environment.Get("/subscriptions/{instanceId}", h.subscription) + environment.Get("/subscriptions/{instanceId}/timeline", h.timeline) + + environment.Get("/restore-jobs", h.listRestoreJobs) + environment.Get("/restore-jobs/{restoreId}", h.restoreJob) +} + +// RegisterProjectRoutes mounts the Project-scoped half of the operator surface. +// The Environment-scoped half is RegisterEnvironmentRoutes. +func RegisterProjectRoutes(router chi.Router, service *billingoperator.Service, + guarded ...func(http.Handler) http.Handler) { + + h := &Handler{service: service} + limited := nonNil(guarded) + + // Identity conflicts are Project-scoped, and the route says so. A conflict + // is a dispute about who a person is, and identity in Mosaic belongs to the + // Project (OD-3(b)); filing it under an Environment would imply it could be + // resolved differently in staging than in production. + router.Route("/billing/identity-conflicts", func(conflicts chi.Router) { + conflicts.Get("/", h.listConflicts) + conflicts.Get("/{conflictId}", h.conflict) + conflicts.With(limited...).Post("/{conflictId}/resolution", h.resolveConflict) + }) +} + +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 +} + +// --------------------------------------------------------------------------- +// Customer lookup +// --------------------------------------------------------------------------- + +// lookupRequest is the typed-identifier search. +// +// It is a POST with a body rather than a GET with a query parameter, and that +// is a privacy decision rather than a REST one: the submitted value is an +// application user id or an installation id — a person — and a query string is +// written to access logs, proxy logs, browser history, and referrer headers. +// The body is read once, digested, and dropped. +type lookupRequest struct { + IdentifierType string `json:"identifierType"` + IdentifierValue string `json:"identifierValue"` +} + +func (q lookupRequest) Validate() error { + return validation.ValidateStruct(&q, + validation.Field(&q.IdentifierType, validation.Required, + validation.In(billingoperator.IdentifierBillingCustomerID, + billingoperator.IdentifierApplicationUserID, + billingoperator.IdentifierInstallationID)), + validation.Field(&q.IdentifierValue, validation.Required, validation.Length(1, 512)), + ) +} + +func (h *Handler) lookupCustomer(w http.ResponseWriter, r *http.Request) { + var request lookupRequest + if !decode(w, r, &request) { + return + } + if err := request.Validate(); err != nil { + // The field errors are not echoed. A validation message on this surface + // would be the one place the submitted identifier could be reflected back + // into a response body. + writeValidation(w, r, map[string][]string{ + "identifierType": {"The request must name a supported identifier type and a value."}}) + return + } + result, err := h.service.LookupCustomer(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), + request.IdentifierType, request.IdentifierValue) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, result) +} + +// --------------------------------------------------------------------------- +// Customers +// --------------------------------------------------------------------------- + +func (h *Handler) listCustomers(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + filter := billingoperator.CustomerFilter{ + Status: strings.TrimSpace(query.Get("status")), + ConflictedOnly: query.Get("conflictedOnly") == "true", + } + switch strings.TrimSpace(query.Get("identified")) { + case "true": + value := true + filter.Identified = &value + case "false": + value := false + filter.Identified = &value + } + limit, _ := strconv.Atoi(query.Get("limit")) + + customers, next, err := h.service.ListCustomers(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), + filter, limit, strings.TrimSpace(query.Get("cursor"))) + if err != nil { + writeError(w, r, err) + return + } + payload := map[string]any{"items": customers} + if next != "" { + payload["nextCursor"] = next + } + response.OK(w, r, payload) +} + +func (h *Handler) customer(w http.ResponseWriter, r *http.Request) { + detail, err := h.service.Customer(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), chi.URLParam(r, "customerId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, detail) +} + +func (h *Handler) snapshot(w http.ResponseWriter, r *http.Request) { + snapshot, status, err := h.service.Snapshot(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), chi.URLParam(r, "customerId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, map[string]any{"snapshot": snapshot, "projectionStatus": status}) +} + +func (h *Handler) subscriptions(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + subscriptions, next, err := h.service.Subscriptions(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), chi.URLParam(r, "customerId"), + limit, strings.TrimSpace(r.URL.Query().Get("cursor"))) + if err != nil { + writeError(w, r, err) + return + } + payload := map[string]any{"items": subscriptions} + if next != "" { + payload["nextCursor"] = next + } + response.OK(w, r, payload) +} + +func (h *Handler) subscription(w http.ResponseWriter, r *http.Request) { + subscription, err := h.service.Subscription(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), chi.URLParam(r, "instanceId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, subscription) +} + +func (h *Handler) timeline(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + entries, next, err := h.service.Timeline(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), chi.URLParam(r, "instanceId"), + limit, strings.TrimSpace(r.URL.Query().Get("cursor"))) + if err != nil { + writeError(w, r, err) + return + } + payload := map[string]any{"items": entries} + if next != "" { + payload["nextCursor"] = next + } + response.OK(w, r, payload) +} + +// --------------------------------------------------------------------------- +// Identity conflicts +// --------------------------------------------------------------------------- + +func (h *Handler) listConflicts(w http.ResponseWriter, r *http.Request) { + conflicts, err := h.service.ListConflicts(r.Context(), actor(r), + chi.URLParam(r, "projectId"), strings.TrimSpace(r.URL.Query().Get("status"))) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, map[string]any{"items": conflicts}) +} + +func (h *Handler) conflict(w http.ResponseWriter, r *http.Request) { + detail, err := h.service.Conflict(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "conflictId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, detail) +} + +// resolutionRequest is the OD-10 operator decision. +// +// `reason` is required by the schema of this request and again by the +// application service. It is not ceremony: every action here moves committed +// access for at least one paying customer, and the audit entry an investigation +// reads months later is worth nothing without the why. +type resolutionRequest struct { + Action string `json:"action"` + AssignedCustomerID string `json:"assignedBillingCustomerId,omitempty"` + Reason string `json:"reason"` +} + +func (q resolutionRequest) Validate() error { + return validation.ValidateStruct(&q, + validation.Field(&q.Action, validation.Required, + validation.In(billingoperator.ActionKeepExisting, + billingoperator.ActionReassign, billingoperator.ActionSplit)), + validation.Field(&q.AssignedCustomerID, validation.Length(0, 128)), + validation.Field(&q.Reason, validation.Required, validation.Length(1, 500)), + ) +} + +func (h *Handler) resolveConflict(w http.ResponseWriter, r *http.Request) { + var request resolutionRequest + if !decode(w, r, &request) { + return + } + if err := request.Validate(); err != nil { + writeValidation(w, r, validationFields(err)) + return + } + conflict, err := h.service.ResolveConflict(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "conflictId"), + request.Action, request.AssignedCustomerID, request.Reason) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, conflict) +} + +// --------------------------------------------------------------------------- +// Restore and sync +// --------------------------------------------------------------------------- + +func (h *Handler) requestSync(w http.ResponseWriter, r *http.Request) { + request, err := h.service.RequestSync(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), chi.URLParam(r, "customerId")) + if err != nil { + writeError(w, r, err) + return + } + response.Accepted(w, r, map[string]any{ + "billingCustomerId": request.BillingCustomerID, + "projectId": request.ProjectID, + "environmentId": request.EnvironmentID, + "projectionScopeKey": request.ScopeKey, + "triggerKind": request.Kind, + "requestedAt": request.RequestedAt.UTC(), + "status": "queued", + }) +} + +func (h *Handler) listRestoreJobs(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + limit, _ := strconv.Atoi(query.Get("limit")) + jobs, next, err := h.service.ListRestoreJobs(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), + strings.TrimSpace(query.Get("billingCustomerId")), limit, strings.TrimSpace(query.Get("cursor"))) + if err != nil { + writeError(w, r, err) + return + } + payload := map[string]any{"items": jobs} + if next != "" { + payload["nextCursor"] = next + } + response.OK(w, r, payload) +} + +func (h *Handler) restoreJob(w http.ResponseWriter, r *http.Request) { + job, err := h.service.RestoreJob(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId"), chi.URLParam(r, "restoreId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, job) +} + +// --------------------------------------------------------------------------- +// Transport helpers +// --------------------------------------------------------------------------- + +func actor(r *http.Request) billingoperator.Actor { + principal, _ := authn.FromContext(r.Context()) + return billingoperator.Actor{ID: principal.ActorID} +} + +func decode(w http.ResponseWriter, r *http.Request, target any) bool { + if encoding := r.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + writeValidation(w, r, map[string][]string{"body": {"The request encoding is not supported."}}) + return false + } + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + decoder := json.NewDecoder(r.Body) + // An unknown member is rejected rather than dropped: on a lookup surface a + // silently ignored field would let an operator believe they had searched by + // an identifier Mosaic never read. + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeValidation(w, r, map[string][]string{"body": {"The request body could not be read."}}) + return false + } + return true +} + +func validationFields(err error) map[string][]string { + fields := map[string][]string{} + var errs validation.Errors + if errors.As(err, &errs) { + for name, fieldErr := range errs { + fields[name] = []string{fieldErr.Error()} + } + return fields + } + fields["body"] = []string{"The request contains invalid fields."} + return fields +} + +func writeValidation(w http.ResponseWriter, r *http.Request, fields map[string][]string) { + response.Error(w, r, response.ValidationFailed(fields)) +} + +// writeError maps operator-domain errors onto HTTP in one place. The Cause is +// never populated: response.Error logs the cause behind every 5xx, and on this +// surface a cause can quote a query carrying an alias digest. +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, billingoperator.ErrUnauthenticated): + status, code, message = http.StatusUnauthorized, "unauthenticated", "Authentication is required." + case errors.Is(err, billingoperator.ErrForbidden): + status, code, message = http.StatusForbidden, "forbidden", "The actor may not read this resource." + case errors.Is(err, billingoperator.ErrNotFound): + status, code, message = http.StatusNotFound, "not_found", "The requested resource was not found." + case errors.Is(err, billingoperator.ErrInvalid): + status, code, message = http.StatusUnprocessableEntity, "validation_failed", + "The request contains invalid fields." + case errors.Is(err, billingoperator.ErrConflict): + status, code, message = http.StatusConflict, "conflict", "The resource is in a conflicting state." + case errors.Is(err, billingoperator.ErrBillingDisabled): + status, code, message = http.StatusConflict, "billing_not_enabled", + "Mosaic Billing is not enabled for this Project." + case errors.Is(err, billingoperator.ErrUnavailable): + status, code, message = http.StatusServiceUnavailable, "billing_storage_unavailable", + "Billing customer state could not be read." + } + response.Error(w, r, response.NewAPIError(status, code, message)) +} diff --git a/apps/api/internal/transport/billingrestore/handler.go b/apps/api/internal/transport/billingrestore/handler.go new file mode 100644 index 00000000..af2fec34 --- /dev/null +++ b/apps/api/internal/transport/billingrestore/handler.go @@ -0,0 +1,319 @@ +// Package billingrestorehttp exposes Mosaic's restore and sync surface over +// HTTP. +// +// Handlers here are strictly thin: read the request, decode, validate transport +// shape, call the application service, write a standardized response. No handler +// queries the database, decides authorization, interprets a chain state, or +// calls render.JSON directly. +// +// # Authentication choice +// +// The restore record is served with response.Representation because its wire +// contract is the Authoritative Entitlement Contract, not the dashboard data +// envelope. +// +// Two surfaces are mounted, and the split follows the contract's own semantics +// rather than convenience: +// +// - The SDK surface authenticates the public SDK key alone (Mosaic-SDK-Key), +// not a Customer Access Token. A Customer Access Token is customer-bound, +// and the contract makes `identity_unresolved` a first-class restore +// outcome — a restore is precisely the flow in which the customer may not +// be known yet, so requiring a customer-bound credential would make the +// most important restore case unrepresentable. Safety comes from the same +// place plan §5a requires: the request names no customer, a public key can +// never select one, and identity is resolved server-side from validated +// store lineage. The submitted references are not sent here at all — they +// were already submitted to the observation endpoint under the same key, +// and this request only names those submissions. +// - The trusted surface authenticates the secret server key and may name a +// Billing Customer, because an application backend has authenticated its +// own user. This is the "restore/sync jobs" entry in plan §11's trusted +// list. +package billingrestorehttp + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + validation "github.com/go-ozzo/ozzo-validation/v4" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingrestore" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +// contractContentType is the media type every Authoritative Entitlement record +// is served as. It is plain JSON: the contract is identified by the record +// envelope, not by a bespoke media type nobody's HTTP client understands. +const contractContentType = "application/json; charset=utf-8" + +// maxBodyBytes bounds the request body. The largest legitimate body is a +// restore naming two hundred observation submissions. +const maxBodyBytes = 32 * 1024 + +// SDKKeyHeader carries the public SDK key on the untrusted surface. It decides +// which Environment is asking and nothing else. +const SDKKeyHeader = "Mosaic-SDK-Key" + +type Handler struct { + service *billingrestore.Service +} + +// RegisterSDKRoutes mounts the untrusted SDK restore surface. +// +// It is rate limited: an SDK holds a retry schedule and polls a bounded number +// of times, so shedding load costs latency rather than correctness. +func RegisterSDKRoutes(router chi.Router, service *billingrestore.Service, limiters ...func(http.Handler) http.Handler) { + h := &Handler{service: service} + router.Group(func(sdk chi.Router) { + for _, limiter := range limiters { + if limiter != nil { + sdk.Use(limiter) + } + } + sdk.Post("/sdk/billing/restores", h.submitFromSDK) + sdk.Get("/sdk/billing/restores/{restoreId}", h.restoreForSDK) + }) +} + +// RegisterTrustedRoutes mounts the secret-server-authenticated APIs. They are +// authenticated by the API key itself rather than by the dashboard principal +// middleware, because the caller is an application backend rather than an +// operator in a browser session. +func RegisterTrustedRoutes(router chi.Router, service *billingrestore.Service, limiters ...func(http.Handler) http.Handler) { + h := &Handler{service: service} + router.Group(func(trusted chi.Router) { + for _, limiter := range limiters { + if limiter != nil { + trusted.Use(limiter) + } + } + // Registered as flat paths rather than through Route: the access + // surface already mounts a subrouter at /billing/server, and a second + // Mount inside that subtree is the kind of routing conflict that only + // shows up when the server starts. Static paths under an existing mount + // take precedence and are what the observation endpoint already does. + trusted.Post("/billing/server/restores", h.submitFromServer) + trusted.Get("/billing/server/restores/{restoreId}", h.restoreForServer) + }) +} + +// --------------------------------------------------------------------------- +// Submission +// --------------------------------------------------------------------------- + +// restoreEnvelope is the Authoritative Entitlement Contract v1 restore request. +// It is decoded with DisallowUnknownFields because the contract declares +// additionalProperties:false at every level, so a member the contract does not +// define is a rejection rather than a silently ignored value. +type restoreEnvelope struct { + AuthoritativeEntitlementContractVersion string `json:"authoritativeEntitlementContractVersion"` + RecordType string `json:"recordType"` + Payload restorePayload `json:"payload"` +} + +type restorePayload struct { + StorePlatform string `json:"storePlatform"` + // ProviderOutcome is what the native restore did. It is the caller's axis + // and Mosaic records it verbatim; it never becomes Mosaic's own outcome. + ProviderOutcome string `json:"providerOutcome"` + // ObservationSubmissionIds names the observations already submitted for + // this restore. No provider transaction reference travels on this surface. + ObservationSubmissionIDs []string `json:"observationSubmissionIds,omitempty"` + // BillingCustomerId is honoured only on the trusted surface. The SDK + // surface drops it, because a client-asserted identifier must never select + // a Billing Customer. + BillingCustomerID string `json:"billingCustomerId,omitempty"` + CorrelationID string `json:"correlationId"` +} + +func (p restorePayload) Validate() error { + return validation.ValidateStruct(&p, + validation.Field(&p.StorePlatform, validation.Required, + validation.In(billingrestore.StoreApple, billingrestore.StoreGoogle)), + validation.Field(&p.ProviderOutcome, validation.Required, + validation.In( + billingrestore.ProviderOutcomeCompleted, + billingrestore.ProviderOutcomeNoPurchasesFound, + billingrestore.ProviderOutcomeCancelled, + billingrestore.ProviderOutcomeFailed, + billingrestore.ProviderOutcomeUnsupported, + billingrestore.ProviderOutcomeNotAttempted)), + validation.Field(&p.ObservationSubmissionIDs, + validation.Length(0, billingrestore.MaxSubmittedObservations), + validation.Each(validation.Required, validation.Length(1, 128))), + validation.Field(&p.BillingCustomerID, validation.Length(0, 128)), + validation.Field(&p.CorrelationID, validation.Required, validation.Length(1, 128)), + ) +} + +func (h *Handler) submitFromSDK(w http.ResponseWriter, r *http.Request) { + payload, ok := decodeRestore(w, r) + if !ok { + return + } + record, err := h.service.SubmitFromSDK(r.Context(), + strings.TrimSpace(r.Header.Get(SDKKeyHeader)), submitRequest(payload)) + if err != nil { + writeError(w, r, err) + return + } + // 202: the restore is recorded and the chain is running. The body already + // carries the honest current answer, which is what a caller polls against. + response.Representation(w, http.StatusAccepted, contractContentType, record) +} + +func (h *Handler) submitFromServer(w http.ResponseWriter, r *http.Request) { + payload, ok := decodeRestore(w, r) + if !ok { + return + } + record, err := h.service.SubmitFromServer(r.Context(), bearer(r), submitRequest(payload)) + if err != nil { + writeError(w, r, err) + return + } + response.Representation(w, http.StatusAccepted, contractContentType, record) +} + +func submitRequest(payload restorePayload) billingrestore.SubmitRequest { + return billingrestore.SubmitRequest{ + StorePlatform: payload.StorePlatform, + ProviderOutcome: payload.ProviderOutcome, + ObservationSubmissionIDs: payload.ObservationSubmissionIDs, + CustomerID: payload.BillingCustomerID, + CorrelationID: payload.CorrelationID, + } +} + +func decodeRestore(w http.ResponseWriter, r *http.Request) (restorePayload, bool) { + var envelope restoreEnvelope + if !decode(w, r, &envelope) { + return restorePayload{}, false + } + if envelope.AuthoritativeEntitlementContractVersion != billingrestore.ContractVersion || + envelope.RecordType != "restoreRequest" { + writeValidation(w, r, map[string][]string{ + "recordType": {"The record is not an Authoritative Entitlement Contract v1 restore request."}}) + return restorePayload{}, false + } + if err := envelope.Payload.Validate(); err != nil { + writeValidation(w, r, validationFields(err)) + return restorePayload{}, false + } + if strings.TrimSpace(envelope.Payload.CorrelationID) == "" { + envelope.Payload.CorrelationID = correlationID(r) + } + return envelope.Payload, true +} + +// --------------------------------------------------------------------------- +// Status +// --------------------------------------------------------------------------- + +func (h *Handler) restoreForSDK(w http.ResponseWriter, r *http.Request) { + record, err := h.service.RestoreForSDK(r.Context(), + strings.TrimSpace(r.Header.Get(SDKKeyHeader)), chi.URLParam(r, "restoreId")) + if err != nil { + writeError(w, r, err) + return + } + // A restore result is per-request state, never cacheable by an + // intermediary: the whole point of polling it is that the answer changes. + w.Header().Set("Cache-Control", "private, no-store") + response.Representation(w, http.StatusOK, contractContentType, record) +} + +func (h *Handler) restoreForServer(w http.ResponseWriter, r *http.Request) { + record, err := h.service.RestoreForServer(r.Context(), bearer(r), chi.URLParam(r, "restoreId")) + if err != nil { + writeError(w, r, err) + return + } + w.Header().Set("Cache-Control", "private, no-store") + response.Representation(w, http.StatusOK, contractContentType, record) +} + +// --------------------------------------------------------------------------- +// Transport helpers +// --------------------------------------------------------------------------- + +func decode(w http.ResponseWriter, r *http.Request, target any) bool { + if encoding := r.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + writeValidation(w, r, map[string][]string{"body": {"The request encoding is not supported."}}) + return false + } + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeValidation(w, r, map[string][]string{"body": {"The request body could not be read."}}) + 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 ") { + return strings.TrimSpace(value[7:]) + } + return "" +} + +func correlationID(r *http.Request) string { + if id := chimiddleware.GetReqID(r.Context()); id != "" { + return id + } + return "mosaic" +} + +func validationFields(err error) map[string][]string { + fields := map[string][]string{} + var errs validation.Errors + if errors.As(err, &errs) { + for name, fieldErr := range errs { + fields[name] = []string{fieldErr.Error()} + } + return fields + } + fields["body"] = []string{"The request contains invalid fields."} + return fields +} + +func writeValidation(w http.ResponseWriter, r *http.Request, fields map[string][]string) { + response.Error(w, r, response.ValidationFailed(fields)) +} + +// writeError maps restore-domain errors onto HTTP in one place. +// +// The Cause is never populated: response.Error logs the cause behind every 5xx, +// and on this surface a cause can quote an API key. ErrUnprovenRestore and +// ErrInvalidOutcome are programming errors, not caller errors, so they fall +// through to the generic 500 rather than telling a caller anything about the +// invariant that stopped them. +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, billingrestore.ErrUnauthenticated): + status, code, message = http.StatusUnauthorized, "unauthenticated", "Authentication is required." + case errors.Is(err, billingrestore.ErrNotFound): + status, code, message = http.StatusNotFound, "not_found", "The requested resource was not found." + case errors.Is(err, billingrestore.ErrInvalid): + status, code, message = http.StatusUnprocessableEntity, "validation_failed", "The request contains invalid fields." + case errors.Is(err, billingrestore.ErrBillingDisabled): + // Mosaic Billing being off is a service state, never a statement about + // the customer. 409 rather than 404 so a caller can tell "not enabled" + // from "no such restore". + status, code, message = http.StatusConflict, "billing_not_enabled", + "Mosaic Billing is not enabled for this Project." + case errors.Is(err, billingrestore.ErrUnavailable): + status, code, message = http.StatusServiceUnavailable, "billing_storage_unavailable", + "Restore state could not be read." + } + response.Error(w, r, response.NewAPIError(status, code, message)) +} diff --git a/apps/api/internal/transport/billingwebhook/handler.go b/apps/api/internal/transport/billingwebhook/handler.go new file mode 100644 index 00000000..52c3435f --- /dev/null +++ b/apps/api/internal/transport/billingwebhook/handler.go @@ -0,0 +1,426 @@ +// Package billingwebhookhttp exposes webhook destination management and +// delivery history over the authenticated operator API. +// +// Handlers here are strictly thin: read the request, decode, validate the +// transport shape, call the application service, write a standardized +// response. No handler screens a URL, decides an authorization question, +// constructs SQL, or calls render.JSON directly. +package billingwebhookhttp + +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/go-ozzo/ozzo-validation/v4/is" + "github.com/rs/zerolog" + + "github.com/Mujhtech/mosaic/apps/api/internal/billingwebhook" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +// maxRequestBytes bounds a destination request body. Every field is short; the +// largest legitimate value is a 2048-character URL. +const maxRequestBytes = 8 << 10 + +type Handler struct { + service *billingwebhook.Service +} + +// RegisterProjectRoutes mounts the operator API under a project router, in the +// same shape as the billing operator routes: Project-scoped resources at the +// top and Environment-scoped collections under /environments/{environmentId}. +// +// A destination belongs to one Environment, so creating and listing them is an +// Environment-scoped operation; addressing one afterwards is not, because the +// destination id already names its Environment. The `expensive` middleware +// covers the two actions that do real work per call: creating or re-pointing a +// destination performs a DNS resolution against an operator-supplied host, and +// a replay re-queues delivery work. +// RegisterEnvironmentRoutes mounts destination creation and listing on the +// shared `/environments/{environmentId}/billing` subrouter. The subrouter is +// created once by the composition because three modules publish routes beneath +// it, and chi refuses to Mount() two handlers on one path (defect D-3). The +// resulting URLs are unchanged. +func RegisterEnvironmentRoutes(environment chi.Router, service *billingwebhook.Service, expensive ...func(http.Handler) http.Handler) { + h := &Handler{service: service} + guarded := nonNil(expensive) + + environment.Route("/webhook-destinations", func(destinations chi.Router) { + destinations.Get("/", h.listDestinations) + destinations.With(guarded...).Post("/", h.createDestination) + }) +} + +func RegisterProjectRoutes(router chi.Router, service *billingwebhook.Service, expensive ...func(http.Handler) http.Handler) { + h := &Handler{service: service} + guarded := nonNil(expensive) + + router.Route("/billing/webhook-destinations/{destinationId}", func(destination chi.Router) { + destination.Get("/", h.getDestination) + destination.With(guarded...).Patch("/", h.updateDestination) + destination.Delete("/", h.deleteDestination) + destination.Post("/status", h.setStatus) + destination.Get("/secrets", h.listSecrets) + destination.Post("/secrets/rotate", h.rotateSecret) + destination.Post("/secrets/{secretId}/retire", h.retireSecret) + }) + router.Route("/billing/webhook-deliveries", func(deliveries chi.Router) { + deliveries.Get("/", h.listDeliveries) + deliveries.Get("/{deliveryId}", h.getDelivery) + deliveries.Get("/{deliveryId}/attempts", h.listAttempts) + deliveries.With(guarded...).Post("/{deliveryId}/replay", h.replayDelivery) + }) +} + +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 +} + +func actor(r *http.Request) billingwebhook.Actor { + principal, _ := authn.FromContext(r.Context()) + return billingwebhook.Actor{ID: principal.ActorID} +} + +// --------------------------------------------------------------------------- +// Destinations +// --------------------------------------------------------------------------- + +type destinationRequest struct { + URL string `json:"url"` + EventTypes []string `json:"eventTypes,omitempty"` + Description string `json:"description,omitempty"` +} + +// Validate covers transport shape only. Whether the URL resolves to a +// permitted address is an application decision that needs the network and the +// deployment's self-hosted flag, and it lives in the SSRF policy. +func (v *destinationRequest) Validate() error { + return validation.ValidateStruct(v, + validation.Field(&v.URL, validation.Required, validation.Length(1, 2048), is.RequestURI), + validation.Field(&v.EventTypes, validation.Length(0, 10), + validation.Each(validation.In(billingwebhook.EventTypeEntitlementsChanged))), + validation.Field(&v.Description, validation.Length(0, 500)), + ) +} + +func (h *Handler) createDestination(w http.ResponseWriter, r *http.Request) { + var request destinationRequest + if !decode(w, r, &request) { + return + } + created, err := h.service.CreateDestination(r.Context(), actor(r), billingwebhook.DestinationInput{ + ProjectID: chi.URLParam(r, "projectId"), + EnvironmentID: chi.URLParam(r, "environmentId"), + URL: request.URL, + EventTypes: request.EventTypes, + Description: request.Description, + }) + if err != nil { + writeError(w, r, err) + return + } + // The signing secret is in this response and in no other. There is no read + // that returns it again, because Mosaic keeps only the sealed form. + response.Created(w, r, created) +} + +func (h *Handler) listDestinations(w http.ResponseWriter, r *http.Request) { + destinations, err := h.service.ListDestinations(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, destinations) +} + +func (h *Handler) getDestination(w http.ResponseWriter, r *http.Request) { + destination, err := h.service.Destination(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "destinationId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, destination) +} + +type updateDestinationRequest struct { + URL *string `json:"url,omitempty"` + EventTypes []string `json:"eventTypes,omitempty"` + Description *string `json:"description,omitempty"` +} + +func (v *updateDestinationRequest) Validate() error { + return validation.ValidateStruct(v, + validation.Field(&v.URL, validation.Length(1, 2048), is.RequestURI), + validation.Field(&v.EventTypes, validation.Length(0, 10), + validation.Each(validation.In(billingwebhook.EventTypeEntitlementsChanged))), + validation.Field(&v.Description, validation.Length(0, 500)), + ) +} + +func (h *Handler) updateDestination(w http.ResponseWriter, r *http.Request) { + var request updateDestinationRequest + if !decode(w, r, &request) { + return + } + updated, err := h.service.UpdateDestination(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "destinationId"), + billingwebhook.DestinationUpdate{ + URL: request.URL, EventTypes: request.EventTypes, Description: request.Description, + }) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, updated) +} + +type statusRequest struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` +} + +func (v *statusRequest) Validate() error { + return validation.ValidateStruct(v, + validation.Field(&v.Status, validation.Required, validation.In( + billingwebhook.DestinationActive, billingwebhook.DestinationPaused, + billingwebhook.DestinationDisabled)), + validation.Field(&v.Reason, validation.Length(0, 128)), + ) +} + +func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request) { + var request statusRequest + if !decode(w, r, &request) { + return + } + updated, err := h.service.SetStatus(r.Context(), actor(r), chi.URLParam(r, "projectId"), + chi.URLParam(r, "destinationId"), request.Status, request.Reason) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, updated) +} + +func (h *Handler) deleteDestination(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteDestination(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "destinationId")) + if err != nil { + writeError(w, r, err) + return + } + response.NoContent(w, r) +} + +// --------------------------------------------------------------------------- +// Signing secrets +// --------------------------------------------------------------------------- + +func (h *Handler) rotateSecret(w http.ResponseWriter, r *http.Request) { + rotated, err := h.service.RotateSecret(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "destinationId")) + if err != nil { + writeError(w, r, err) + return + } + // The response carries the new secret once and the instant the superseded + // one stops signing, which is the whole information an integrator needs to + // schedule their own side of the rotation. + response.Created(w, r, rotated) +} + +func (h *Handler) listSecrets(w http.ResponseWriter, r *http.Request) { + secrets, err := h.service.ListSecrets(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "destinationId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, secrets) +} + +func (h *Handler) retireSecret(w http.ResponseWriter, r *http.Request) { + retired, err := h.service.RetireSecret(r.Context(), actor(r), chi.URLParam(r, "projectId"), + chi.URLParam(r, "destinationId"), chi.URLParam(r, "secretId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, retired) +} + +// --------------------------------------------------------------------------- +// Deliveries +// --------------------------------------------------------------------------- + +func (h *Handler) listDeliveries(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + limit, _ := strconv.Atoi(query.Get("limit")) + deliveries, err := h.service.ListDeliveries(r.Context(), actor(r), chi.URLParam(r, "projectId"), + billingwebhook.DeliveryFilter{ + EnvironmentID: strings.TrimSpace(query.Get("environmentId")), + EventID: strings.TrimSpace(query.Get("eventId")), + DestinationID: strings.TrimSpace(query.Get("destinationId")), + Status: allowed(query.Get("status")), + Limit: limit, + }) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, deliveries) +} + +// allowed drops a status the schema does not define rather than passing it to +// the query, so a filter value can never widen a result set by accident. +func allowed(value string) string { + switch strings.TrimSpace(value) { + case billingwebhook.DeliveryPending, billingwebhook.DeliverySucceeded, + billingwebhook.DeliveryFailed, billingwebhook.DeliveryExhausted, + billingwebhook.DeliverySkipped: + return strings.TrimSpace(value) + default: + return "" + } +} + +func (h *Handler) getDelivery(w http.ResponseWriter, r *http.Request) { + delivery, err := h.service.Delivery(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "deliveryId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, delivery) +} + +func (h *Handler) listAttempts(w http.ResponseWriter, r *http.Request) { + attempts, err := h.service.ListAttempts(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "deliveryId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, attempts) +} + +func (h *Handler) replayDelivery(w http.ResponseWriter, r *http.Request) { + delivery, err := h.service.ReplayDelivery(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "deliveryId")) + if err != nil { + writeError(w, r, err) + return + } + // 202: the delivery is queued, not performed. The worker owns the attempt. + response.Accepted(w, r, delivery) +} + +// --------------------------------------------------------------------------- +// Decoding and error mapping +// --------------------------------------------------------------------------- + +func decode(w http.ResponseWriter, r *http.Request, target any) bool { + if encoding := r.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + writeError(w, r, billingwebhook.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, billingwebhook.ErrInvalid) + return false + } + // A second decode asserting EOF rejects trailing JSON, which would + // otherwise let a caller smuggle a second document past the first. + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(w, r, billingwebhook.ErrInvalid) + return false + } + if validatable, ok := target.(interface{ Validate() error }); ok { + if err := validatable.Validate(); err != nil { + response.Error(w, r, response.ValidationFailed(fields(err))) + return false + } + } + return true +} + +// fields turns an ozzo validation error into the response envelope's field map. +func fields(err error) map[string][]string { + errs, ok := err.(validation.Errors) + if !ok { + return nil + } + result := make(map[string][]string, len(errs)) + for field, fieldErr := range errs { + result[field] = []string{fieldErr.Error()} + } + return result +} + +// writeError maps domain errors onto responses in one place. Nothing here +// reads an error message string, and no internal error, SQL error, destination +// URL, or response body reaches the client. +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, billingwebhook.ErrUnauthenticated): + status, code, message = http.StatusUnauthorized, "unauthenticated", "Authentication is required." + 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): + // A refused destination is the operator's own configuration, so it is + // reported precisely. The message names the policy, never the resolved + // address: which address a hostname resolved to inside Mosaic's network + // is information about Mosaic's network. + status, code = http.StatusUnprocessableEntity, "webhook_destination_refused" + message = "The destination must be an https URL that resolves to a public address. " + + "Private, loopback, link-local, shared, and unique-local addresses are not permitted." + case errors.Is(err, billingwebhook.ErrConflict): + status, code, message = http.StatusConflict, "conflict", "The request conflicts with the current state of this resource." + case errors.Is(err, billingwebhook.ErrBillingDisabled): + status, code, message = http.StatusConflict, "billing_not_enabled", "Mosaic Billing is not enabled for this Project." + case errors.Is(err, billingwebhook.ErrInvalid): + status, code, message = http.StatusUnprocessableEntity, "validation_failed", "The request contains invalid fields." + case errors.Is(err, billingwebhook.ErrSecretUnavailable): + status, code, message = http.StatusServiceUnavailable, "webhook_secret_unavailable", "The signing secret could not be prepared." + case errors.Is(err, billingwebhook.ErrUnavailable): + status, code, message = http.StatusServiceUnavailable, "webhook_storage_unavailable", "Webhook storage is temporarily unavailable." + default: + zerolog.Ctx(r.Context()).Error(). + Str("webhook_error_kind", errorTypeName(err)). + Msg("webhook request failed") + } + response.Error(w, r, response.NewAPIError(status, code, message)) +} + +// errorTypeName reports the Go type of an error and nothing else. +// +// The message is deliberately not logged. On this surface an error message can +// quote an operator-supplied destination URL, a resolved internal address, or +// a wrapped SQL statement, and the one thing this value must never be is +// caller content. %T is the precedent already used by the billing handler for +// the same reason. +func errorTypeName(err error) string { + if err == nil { + return "" + } + return fmt.Sprintf("%T", err) +} diff --git a/apps/api/migrations/00029_billing_fact_shape_v2.sql b/apps/api/migrations/00029_billing_fact_shape_v2.sql new file mode 100644 index 00000000..d67e5c99 --- /dev/null +++ b/apps/api/migrations/00029_billing_fact_shape_v2.sql @@ -0,0 +1,126 @@ +-- Phase 9B fact-shape pass (plan §8, validator version 2). +-- +-- Additive columns for provider statements that validator 1 parsed but never +-- persisted (quality finding B10): grace end, billing retry, scheduled renewal +-- product, upgrade marker, revocation reason, refund type, ownership type, +-- subscription group — plus a recovered provider event time for Google facts, +-- whose occurred_at is the lineage-constant startTime and would otherwise tie +-- during canonical ordering. Every column is nullable because existing v1 +-- facts are immutable and are never rewritten; all of them participate in +-- FactDigest under validator version 2. +-- +-- Stated consequence of the validator bump (OD-13, review finding I-13): +-- validator version 2 mints a SECOND fact for a provider transaction that was +-- already recorded under validator version 1. +-- +-- Fact identity is UNIQUE (environment_id, fact_digest), and FactDigest covers +-- the validator version and every column above. Re-validating a 9A input under +-- validator 2 therefore recomputes a different digest and inserts a new row +-- rather than colliding with the v1 row. Both rows describe the same provider +-- transaction. Neither is rewritten: 9A facts are immutable, which is the whole +-- reason the duplicate exists instead of an UPDATE. +-- +-- Where that duplication is absorbed, and where it is not: +-- +-- Access: absorbed. An Entitlement Source's identity is (purchase lineage, +-- product, grant version) — never a fact id — so two facts describing one +-- purchase produce one source and one grant. The projection engine is a fold +-- over the ordered timeline in which a restatement of the current position +-- changes nothing, so the derived snapshot and its checksum are unchanged. +-- This is why revalidation is safe to run. +-- +-- Timeline: NOT absorbed. subscription_timeline_entries emits one entry per +-- fact that changes the story, and a v2 restatement of a v1 fact is a +-- distinct fact id. A customer's timeline can therefore show the same +-- purchase, renewal, or refund twice after a revalidation pass. The entries +-- are append-only and are explanations rather than state, so the duplication +-- is cosmetic — but it is customer-visible in the operator console and in any +-- surface that renders the timeline, and it is not deduplicated anywhere. +-- +-- subscription_snapshot_facts: NOT absorbed. The snapshot-to-fact evidence +-- join lists every source fact by id, so a revalidated lineage cites both the +-- v1 and the v2 fact for the same provider statement. Counting rows there is +-- not a count of provider statements after a validator bump. +-- +-- Removing either duplication would mean either rewriting immutable 9A facts or +-- teaching the timeline a cross-validator identity that facts deliberately do +-- not carry. Both are worse than the stated consequence, so the consequence is +-- stated here rather than engineered away. +-- +-- Also extends the quarantine reason vocabulary with +-- 'missing_provider_timestamp' (9A correction B7): an input whose provider +-- payload carries no usable timestamp quarantines instead of producing a fact +-- dated with worker wall-clock. + +-- +goose Up +ALTER TABLE billing_transaction_facts + ADD COLUMN grace_period_expires_at timestamptz, + ADD COLUMN billing_retry_active boolean, + ADD COLUMN auto_renew_product_identifier text CHECK ( + auto_renew_product_identifier IS NULL OR + (btrim(auto_renew_product_identifier) <> '' AND length(auto_renew_product_identifier) <= 255) + ), + ADD COLUMN is_upgraded boolean, + ADD COLUMN revocation_reason integer CHECK (revocation_reason IS NULL OR revocation_reason >= 0), + ADD COLUMN refund_type text CHECK ( + refund_type IS NULL OR refund_type IN ('full', 'prorated', 'quantity_partial') + ), + ADD COLUMN in_app_ownership_type text CHECK ( + in_app_ownership_type IS NULL OR + (btrim(in_app_ownership_type) <> '' AND length(in_app_ownership_type) <= 64) + ), + ADD COLUMN subscription_group_identifier text CHECK ( + subscription_group_identifier IS NULL OR + (btrim(subscription_group_identifier) <> '' AND length(subscription_group_identifier) <= 128) + ), + ADD COLUMN provider_event_occurred_at timestamptz; + +ALTER TABLE billing_quarantine_records + DROP CONSTRAINT billing_quarantine_records_reason_code_check; +ALTER TABLE billing_quarantine_records + ADD CONSTRAINT billing_quarantine_records_reason_code_check CHECK (reason_code IN ( + 'signature_invalid', 'application_mismatch', 'environment_mismatch', + 'store_environment_mismatch', 'credential_unavailable', 'credential_revoked', + 'missing_validation_credential', 'missing_provider_timestamp', + 'product_unknown', 'product_ambiguous', 'cross_environment_mismatch', + 'unsupported_product_type', 'unsupported_transaction_type', + 'malformed_reference', 'input_content_conflict', 'replay_conflict', + 'provider_permanently_failed', 'validation_exhausted' + )); + +-- +goose Down +-- Quarantine rows carrying the new reason must go before the narrower CHECK +-- can return; the raw inputs, attempts, and ledger entries behind them all +-- survive (same rationale as migration 00026's down path). +ALTER TABLE billing_quarantine_actions DISABLE TRIGGER billing_quarantine_actions_append_only; +DELETE FROM billing_quarantine_actions + WHERE quarantine_record_id IN ( + SELECT id FROM billing_quarantine_records WHERE reason_code = 'missing_provider_timestamp'); +ALTER TABLE billing_quarantine_actions ENABLE TRIGGER billing_quarantine_actions_append_only; +DELETE FROM billing_quarantine_records WHERE reason_code = 'missing_provider_timestamp'; + +ALTER TABLE billing_quarantine_records + DROP CONSTRAINT billing_quarantine_records_reason_code_check; +ALTER TABLE billing_quarantine_records + ADD CONSTRAINT billing_quarantine_records_reason_code_check CHECK (reason_code IN ( + 'signature_invalid', 'application_mismatch', 'environment_mismatch', + 'store_environment_mismatch', 'credential_unavailable', 'credential_revoked', + 'missing_validation_credential', + 'product_unknown', 'product_ambiguous', 'cross_environment_mismatch', + 'unsupported_product_type', 'unsupported_transaction_type', + 'malformed_reference', 'input_content_conflict', 'replay_conflict', + 'provider_permanently_failed', 'validation_exhausted' + )); + +-- Dropping the v2 columns is plain DDL; the append-only trigger guards only +-- row UPDATE/DELETE and does not need to be lifted. +ALTER TABLE billing_transaction_facts + DROP COLUMN grace_period_expires_at, + DROP COLUMN billing_retry_active, + DROP COLUMN auto_renew_product_identifier, + DROP COLUMN is_upgraded, + DROP COLUMN revocation_reason, + DROP COLUMN refund_type, + DROP COLUMN in_app_ownership_type, + DROP COLUMN subscription_group_identifier, + DROP COLUMN provider_event_occurred_at; diff --git a/apps/api/migrations/00030_phase_9b_billing_customers.sql b/apps/api/migrations/00030_phase_9b_billing_customers.sql new file mode 100644 index 00000000..f8a48817 --- /dev/null +++ b/apps/api/migrations/00030_phase_9b_billing_customers.sql @@ -0,0 +1,155 @@ +-- Phase 9B: Billing Customers, aliases, association evidence, and identity +-- conflicts (plan §5, §5a; OD-2(b), OD-3(b), OD-4(a), OD-7(a), OD-10(a)). +-- +-- A Billing Customer is Project-scoped identity; everything that holds state +-- (lineages, instances, snapshots, pointers, tokens) is Environment-scoped in +-- later migrations. Customers are created lazily — by a trusted backend +-- identify or by a validated fact that needs somewhere to attach — never by +-- SDK init or installation registration. +-- +-- Aliases are the erasable PII surface (the person-to-purchase link): values +-- are SHA-256 digests under the domain separation 'mosaic-billing-alias-v1', +-- never raw. There is deliberately NO foreign key into any analytics identity +-- table: the Phase 6 deletion job hard-deletes analytics identity rows, and a +-- RESTRICT here would break accepted deletion behaviour while a CASCADE would +-- silently revoke entitlements (OD-7 hard constraint). + +-- +goose Up +CREATE TABLE billing_customers ( + id text PRIMARY KEY, + project_id text NOT NULL REFERENCES projects(id) ON DELETE RESTRICT, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'frozen', 'anonymized')), + -- Projection bookkeeping. The version is the CAS belt worn alongside the + -- advisory-lock braces; a no-change projection advances last_projected_at + -- without minting a snapshot version. + current_projection_version bigint NOT NULL DEFAULT 0 CHECK (current_projection_version >= 0), + last_projected_at timestamptz, + diagnostics_status text NOT NULL DEFAULT 'none' CHECK (diagnostics_status IN ( + 'none', 'identity_conflict', 'projection_stale', 'projection_failed' + )), + anonymized_at timestamptz, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + UNIQUE (id, project_id), + CHECK ((status = 'anonymized') = (anonymized_at IS NOT NULL)) +); +CREATE INDEX billing_customers_project_idx ON billing_customers(project_id, created_at DESC, id); + +CREATE TABLE billing_customer_aliases ( + id text PRIMARY KEY, + project_id text NOT NULL, + billing_customer_id text NOT NULL, + alias_type text NOT NULL CHECK (alias_type IN ( + 'application_user_id', 'installation_id', + 'apple_app_account_token', 'google_obfuscated_account_id' + )), + -- SHA-256 digest of the raw value under 'mosaic-billing-alias-v1' domain + -- separation. The raw value is never stored anywhere in this schema. + alias_digest bytea NOT NULL CHECK (octet_length(alias_digest) = 32), + source_authority text NOT NULL CHECK (source_authority IN ( + 'trusted_server', 'sdk_installation', 'provider_payload', 'operator', 'restore' + )), + verification_status text NOT NULL DEFAULT 'asserted' CHECK (verification_status IN ('asserted', 'verified')), + effective_start timestamptz NOT NULL, + -- End-dated history: a revoked or superseded alias keeps its row with + -- effective_end set. Only one active resolution may exist per + -- (project, type, digest) at a time. + effective_end timestamptz, + revoked_by_actor_id text, + created_at timestamptz NOT NULL, + UNIQUE (id, project_id), + FOREIGN KEY (billing_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + CHECK (effective_end IS NULL OR effective_end >= effective_start) +); +CREATE UNIQUE INDEX billing_customer_aliases_active_resolution_idx + ON billing_customer_aliases(project_id, alias_type, alias_digest) + WHERE effective_end IS NULL; +CREATE INDEX billing_customer_aliases_customer_idx + ON billing_customer_aliases(billing_customer_id, alias_type, effective_start DESC); + +-- Association evidence is append-only forensic history: every observation the +-- resolver considered, with its outcome. The purchase-lineage linkage column +-- is added in migration 00031, after purchase_lineages exists. +CREATE TABLE billing_association_evidence ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text, + evidence_type text NOT NULL CHECK (evidence_type IN ( + 'app_account_token', 'obfuscated_external_account_id', + 'trusted_server_observation', 'prior_lineage_association', + 'restore_link', 'operator_repair', 'installation_observation' + )), + -- Digest of the correlator value (same alias domain separation). Nullable: + -- prior_lineage_association carries no correlator value of its own. + evidence_digest bytea CHECK (evidence_digest IS NULL OR octet_length(evidence_digest) = 32), + raw_input_id text, + transaction_reference_digest bytea CHECK ( + transaction_reference_digest IS NULL OR octet_length(transaction_reference_digest) = 32 + ), + billing_customer_id text, + resolver_version integer NOT NULL CHECK (resolver_version >= 1), + outcome text NOT NULL CHECK (outcome IN ('resolved', 'unresolved', 'conflicting', 'unsupported')), + diagnostic_code text CHECK (diagnostic_code IS NULL OR (btrim(diagnostic_code) <> '' AND length(diagnostic_code) <= 128)), + observed_at timestamptz NOT NULL, + created_at timestamptz NOT NULL, + UNIQUE (id, project_id), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(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 (billing_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT +); +CREATE INDEX billing_association_evidence_reference_idx + ON billing_association_evidence(transaction_reference_digest) + WHERE transaction_reference_digest IS NOT NULL; +CREATE INDEX billing_association_evidence_customer_idx + ON billing_association_evidence(billing_customer_id, observed_at DESC) + WHERE billing_customer_id IS NOT NULL; + +CREATE TRIGGER billing_association_evidence_append_only +BEFORE UPDATE OR DELETE ON billing_association_evidence +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- One open conflict per disputed lineage (OD-10(a)): projection freezes, the +-- last committed state is preserved, and resolution is an explicit audited +-- operator action. The lineage FK is added in 00031. +CREATE TABLE billing_identity_conflicts ( + id text PRIMARY KEY, + project_id text NOT NULL, + purchase_lineage_id text NOT NULL, + status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved')), + first_customer_id text NOT NULL, + second_customer_id text NOT NULL, + detected_by_evidence_id text, + detail jsonb NOT NULL DEFAULT '{}'::jsonb CHECK ( + jsonb_typeof(detail) = 'object' AND octet_length(detail::text) <= 2048 + ), + opened_at timestamptz NOT NULL, + resolved_at timestamptz, + resolved_by_actor_id text, + resolution_action text CHECK (resolution_action IS NULL OR resolution_action IN ( + 'assigned_first', 'assigned_second', 'detached_both' + )), + UNIQUE (id, project_id), + FOREIGN KEY (first_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (second_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (detected_by_evidence_id, project_id) + REFERENCES billing_association_evidence(id, project_id) ON DELETE RESTRICT, + CHECK (first_customer_id <> second_customer_id), + CHECK ((status = 'resolved') = (resolved_at IS NOT NULL)), + CHECK ((status = 'resolved') = (resolution_action IS NOT NULL)) +); +CREATE UNIQUE INDEX billing_identity_conflicts_open_idx + ON billing_identity_conflicts(purchase_lineage_id) + WHERE status = 'open'; + +-- +goose Down +DROP TABLE billing_identity_conflicts; +DROP TRIGGER billing_association_evidence_append_only ON billing_association_evidence; +DROP TABLE billing_association_evidence; +DROP TABLE billing_customer_aliases; +DROP TABLE billing_customers; diff --git a/apps/api/migrations/00031_phase_9b_purchase_lineages.sql b/apps/api/migrations/00031_phase_9b_purchase_lineages.sql new file mode 100644 index 00000000..d35c2793 --- /dev/null +++ b/apps/api/migrations/00031_phase_9b_purchase_lineages.sql @@ -0,0 +1,151 @@ +-- Phase 9B: Purchase Lineages and projection instances (plan §5). +-- +-- A Purchase Lineage groups validated facts describing one provider purchase +-- chain: the Apple original-transaction chain (lineage key = digest of +-- originalTransactionId) or the Google token chain root (walked through +-- linkedPurchaseToken). Lineages are Environment-scoped and never merged by +-- Product or customer similarity; supersession is an explicit edge. + +-- +goose Up +CREATE TABLE purchase_lineages ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + environment_mode text NOT NULL CHECK (environment_mode IN ('development', 'staging', 'production')), + application_id text NOT NULL, + provider text NOT NULL CHECK (provider IN ('app_store', 'google_play')), + store_environment text NOT NULL CHECK (store_environment IN ('sandbox', 'production')), + -- Digest of the provider lineage key (Apple originalTransactionId key, + -- Google purchase-token chain root digest). Never a raw token. + lineage_key_digest bytea NOT NULL CHECK (octet_length(lineage_key_digest) = 32), + lineage_type text NOT NULL CHECK (lineage_type IN ('subscription', 'one_time')), + billing_customer_id text, + superseded_by_lineage_id text REFERENCES purchase_lineages(id) ON DELETE RESTRICT, + -- Set while an identity conflict is open for this lineage (OD-10): the + -- projector skips a frozen lineage and preserves the last committed state. + projection_frozen boolean NOT NULL DEFAULT false, + diagnostic_status text NOT NULL DEFAULT 'none' CHECK (diagnostic_status IN ( + 'none', 'identity_unresolved', 'identity_conflict', 'product_unresolved' + )), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + UNIQUE (id, project_id), + UNIQUE (environment_id, provider, lineage_key_digest), + FOREIGN KEY (environment_id, project_id, environment_mode) + REFERENCES environments(id, project_id, mode) ON DELETE RESTRICT, + FOREIGN KEY (application_id, project_id) + REFERENCES applications(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (billing_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + CHECK (superseded_by_lineage_id IS NULL OR superseded_by_lineage_id <> id), + -- Sandbox and production never mix (same alignment CHECK as the facts). + CONSTRAINT purchase_lineages_environment_alignment_check CHECK ( + (environment_mode = 'production') = (store_environment = 'production') + ) +); +CREATE INDEX purchase_lineages_customer_idx + ON purchase_lineages(billing_customer_id, created_at DESC) + WHERE billing_customer_id IS NOT NULL; +CREATE INDEX purchase_lineages_environment_idx + ON purchase_lineages(environment_id, created_at DESC, id); + +-- Now that lineages exist, link the identity tables created in 00030. +ALTER TABLE billing_association_evidence + ADD COLUMN purchase_lineage_id text, + ADD CONSTRAINT billing_association_evidence_lineage_fkey + FOREIGN KEY (purchase_lineage_id, project_id) + REFERENCES purchase_lineages(id, project_id) ON DELETE RESTRICT; +ALTER TABLE billing_identity_conflicts + ADD CONSTRAINT billing_identity_conflicts_lineage_fkey + FOREIGN KEY (purchase_lineage_id, project_id) + REFERENCES purchase_lineages(id, project_id) ON DELETE RESTRICT; + +-- Subscription Instance: the authoritative projection unit for one recurring +-- lineage (1:1). The current-snapshot pointer column is added in 00032, after +-- subscription_snapshots exists. +CREATE TABLE subscription_instances ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + application_id text NOT NULL, + purchase_lineage_id text NOT NULL, + billing_customer_id text, + provider text NOT NULL CHECK (provider IN ('app_store', 'google_play')), + current_mosaic_product_id text, + current_provider_product_identifier text, + subscription_group_identifier text, + current_projection_version bigint NOT NULL DEFAULT 0 CHECK (current_projection_version >= 0), + terminal_at timestamptz, + diagnostic_status text NOT NULL DEFAULT 'none' CHECK (diagnostic_status IN ( + 'none', 'identity_unresolved', 'product_unresolved', 'projection_failed' + )), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + UNIQUE (id, project_id), + UNIQUE (purchase_lineage_id), + 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, + FOREIGN KEY (purchase_lineage_id, project_id) + REFERENCES purchase_lineages(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (billing_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (current_mosaic_product_id, project_id) + REFERENCES products(id, project_id) ON DELETE RESTRICT +); +CREATE INDEX subscription_instances_customer_idx + ON subscription_instances(billing_customer_id, created_at DESC) + WHERE billing_customer_id IS NOT NULL; +CREATE INDEX subscription_instances_environment_idx + ON subscription_instances(environment_id, created_at DESC, id); + +-- One-Time Purchase Instance: validated ownership of a non-consumable. +-- Consumables remain excluded. +CREATE TABLE one_time_purchase_instances ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + application_id text NOT NULL, + purchase_lineage_id text NOT NULL, + billing_customer_id text, + provider text NOT NULL CHECK (provider IN ('app_store', 'google_play')), + mosaic_product_id text, + provider_product_identifier text, + acquired_at timestamptz NOT NULL, + validity_state text NOT NULL DEFAULT 'owned' CHECK (validity_state IN ( + 'owned', 'refunded', 'revoked', 'unknown' + )), + refund_effective_at timestamptz, + revocation_effective_at timestamptz, + current_projection_version bigint NOT NULL DEFAULT 0 CHECK (current_projection_version >= 0), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + UNIQUE (id, project_id), + UNIQUE (purchase_lineage_id), + 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, + FOREIGN KEY (purchase_lineage_id, project_id) + REFERENCES purchase_lineages(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (billing_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (mosaic_product_id, project_id) + REFERENCES products(id, project_id) ON DELETE RESTRICT, + CHECK (validity_state <> 'refunded' OR refund_effective_at IS NOT NULL), + CHECK (validity_state <> 'revoked' OR revocation_effective_at IS NOT NULL) +); +CREATE INDEX one_time_purchase_instances_customer_idx + ON one_time_purchase_instances(billing_customer_id, acquired_at DESC) + WHERE billing_customer_id IS NOT NULL; + +-- +goose Down +DROP TABLE one_time_purchase_instances; +DROP TABLE subscription_instances; +ALTER TABLE billing_identity_conflicts + DROP CONSTRAINT billing_identity_conflicts_lineage_fkey; +ALTER TABLE billing_association_evidence + DROP CONSTRAINT billing_association_evidence_lineage_fkey, + DROP COLUMN purchase_lineage_id; +DROP TABLE purchase_lineages; diff --git a/apps/api/migrations/00032_phase_9b_subscription_projection.sql b/apps/api/migrations/00032_phase_9b_subscription_projection.sql new file mode 100644 index 00000000..c5834680 --- /dev/null +++ b/apps/api/migrations/00032_phase_9b_subscription_projection.sql @@ -0,0 +1,271 @@ +-- Phase 9B: subscription projection persistence (plan §5, §6, §8, §9). +-- +-- Snapshots are immutable projected states; timeline entries are append-only +-- explanations; checkpoints are derived and rebuildable; rule versions are +-- global engine semantics with exactly one active; jobs follow the same lease +-- shape as every other Mosaic queue, with a partial-unique scope key that +-- coalesces duplicate triggers. + +-- +goose Up +CREATE TABLE projection_rule_versions ( + id text PRIMARY KEY, + version integer NOT NULL UNIQUE CHECK (version >= 1), + status text NOT NULL CHECK (status IN ('draft', 'active', 'retired')), + description text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL, + promoted_at timestamptz, + CHECK ((status = 'active') = (promoted_at IS NOT NULL) OR status = 'retired') +); +CREATE UNIQUE INDEX projection_rule_versions_one_active_idx + ON projection_rule_versions(status) WHERE status = 'active'; +INSERT INTO projection_rule_versions (id, version, status, description, created_at, promoted_at) +VALUES ('prv_1', 1, 'active', 'Phase 9B initial projection semantics (plan §6/§7, ordering version 1)', now(), now()); + +CREATE TABLE subscription_snapshots ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + subscription_instance_id text NOT NULL, + projection_version bigint NOT NULL CHECK (projection_version >= 1), + rule_version integer NOT NULL REFERENCES projection_rule_versions(version) ON DELETE RESTRICT, + computed_at timestamptz NOT NULL, + as_of timestamptz NOT NULL, + -- Five state axes. 'unavailable' is a read-time service state and is + -- deliberately not representable here (plan §5). + access_state text NOT NULL CHECK (access_state IN ('active', 'inactive', 'unknown')), + lifecycle_state text NOT NULL CHECK (lifecycle_state IN ( + 'trialing', 'active', 'grace_period', 'billing_retry', 'paused', + 'expired', 'revoked', 'refunded', 'superseded', 'unknown' + )), + renewal_intent text NOT NULL CHECK (renewal_intent IN ( + 'auto_renew_enabled', 'auto_renew_disabled', 'provider_managed', 'paused', 'unknown' + )), + billing_state text NOT NULL CHECK (billing_state IN ( + 'current', 'retrying', 'grace', 'failed', 'refunded', 'revoked', 'unknown' + )), + uncertainty_reason text NOT NULL DEFAULT 'none' CHECK (uncertainty_reason IN ( + 'none', 'provider_unavailable', 'missing_fact', 'identity_unresolved', + 'product_unresolved', 'conflicting_facts', 'projection_failed', + 'stale_validation', 'unsupported_provider_state' + )), + -- Effective timestamps, all provider-derived. + period_start_at timestamptz, + period_end_at timestamptz, + grace_period_end_at timestamptz, + billing_retry_start_at timestamptz, + pause_start_at timestamptz, + pause_resume_at timestamptz, + cancellation_effective_at timestamptz, + expiration_effective_at timestamptz, + revocation_effective_at timestamptz, + refund_effective_at timestamptz, + current_product_id text, + prior_product_id text, + scheduled_product_identifier text, + is_test_source boolean NOT NULL DEFAULT false, + terminal boolean NOT NULL DEFAULT false, + checksum bytea NOT NULL CHECK (octet_length(checksum) = 32), + projection_reason text NOT NULL CHECK (btrim(projection_reason) <> '' AND length(projection_reason) <= 64), + created_at timestamptz NOT NULL, + UNIQUE (id, project_id), + UNIQUE (subscription_instance_id, projection_version), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (subscription_instance_id, project_id) + REFERENCES subscription_instances(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (current_product_id, project_id) + REFERENCES products(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (prior_product_id, project_id) + REFERENCES products(id, project_id) ON DELETE RESTRICT, + -- Schema-level derivation invariants (plan §6). + CHECK (lifecycle_state NOT IN ('revoked', 'refunded', 'expired', 'superseded') OR access_state <> 'active'), + CHECK (lifecycle_state <> 'grace_period' OR grace_period_end_at IS NOT NULL), + CHECK (access_state <> 'unknown' OR uncertainty_reason <> 'none') +); +CREATE INDEX subscription_snapshots_instance_idx + ON subscription_snapshots(subscription_instance_id, projection_version DESC); + +CREATE TRIGGER subscription_snapshots_append_only +BEFORE UPDATE OR DELETE ON subscription_snapshots +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- The current-snapshot pointer on the instance, now that snapshots exist. +ALTER TABLE subscription_instances + ADD COLUMN current_snapshot_id text, + ADD CONSTRAINT subscription_instances_current_snapshot_fkey + FOREIGN KEY (current_snapshot_id, project_id) + REFERENCES subscription_snapshots(id, project_id) ON DELETE RESTRICT; + +CREATE TABLE subscription_snapshot_facts ( + snapshot_id text NOT NULL REFERENCES subscription_snapshots(id) ON DELETE RESTRICT, + transaction_fact_id text NOT NULL REFERENCES billing_transaction_facts(id) ON DELETE RESTRICT, + position integer NOT NULL CHECK (position >= 0), + PRIMARY KEY (snapshot_id, transaction_fact_id), + UNIQUE (snapshot_id, position) +); + +CREATE TRIGGER subscription_snapshot_facts_append_only +BEFORE UPDATE OR DELETE ON subscription_snapshot_facts +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +CREATE TABLE subscription_timeline_entries ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + subscription_instance_id text, + one_time_purchase_instance_id text, + entry_type text NOT NULL CHECK (entry_type IN ( + 'purchase_started', 'purchase_validated', 'trial_started', 'renewal_validated', + 'auto_renew_enabled', 'auto_renew_disabled', 'cancellation_requested', + 'grace_period_started', 'grace_period_ended', 'billing_retry_started', + 'billing_recovered', 'pause_scheduled', 'pause_started', 'pause_ended', + 'product_upgraded', 'product_downgraded', 'expiration', 'refund', 'revocation', + 'refund_reversed', 'purchase_superseded', + 'customer_association_changed', 'product_resolution_repaired', + 'projection_replayed', 'projection_rule_upgraded' + )), + effective_at timestamptz NOT NULL, + observed_at timestamptz NOT NULL, + old_snapshot_id text, + new_snapshot_id text, + product_id text, + prior_product_id text, + source_fact_ids text[] NOT NULL DEFAULT ARRAY[]::text[], + explanation_code text NOT NULL CHECK (btrim(explanation_code) <> '' AND length(explanation_code) <= 64), + -- Safe machine detail only, guarded by the same function as the 9A ledger. + detail jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (billing_ledger_detail_is_safe(detail)), + rule_version integer NOT NULL REFERENCES projection_rule_versions(version) ON DELETE RESTRICT, + created_at timestamptz NOT NULL, + UNIQUE (id, project_id), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (subscription_instance_id, project_id) + REFERENCES subscription_instances(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (one_time_purchase_instance_id, project_id) + REFERENCES one_time_purchase_instances(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (old_snapshot_id, project_id) + REFERENCES subscription_snapshots(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (new_snapshot_id, project_id) + REFERENCES subscription_snapshots(id, project_id) ON DELETE RESTRICT, + CHECK ((subscription_instance_id IS NULL) <> (one_time_purchase_instance_id IS NULL)) +); +CREATE INDEX subscription_timeline_entries_instance_idx + ON subscription_timeline_entries(subscription_instance_id, effective_at DESC) + WHERE subscription_instance_id IS NOT NULL; +CREATE INDEX subscription_timeline_entries_one_time_idx + ON subscription_timeline_entries(one_time_purchase_instance_id, effective_at DESC) + WHERE one_time_purchase_instance_id IS NOT NULL; + +CREATE TRIGGER subscription_timeline_entries_append_only +BEFORE UPDATE OR DELETE ON subscription_timeline_entries +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- Checkpoints are derived state: rebuildable, updatable, invalidated by +-- out-of-order facts. Deliberately no append-only trigger. +CREATE TABLE projection_checkpoints ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + subscription_instance_id text, + one_time_purchase_instance_id text, + -- Opaque encoding of the canonical ordering position of the last fact + -- projected (ordering version 1). + high_watermark text NOT NULL, + facts_projected bigint NOT NULL DEFAULT 0 CHECK (facts_projected >= 0), + rule_version integer NOT NULL REFERENCES projection_rule_versions(version) ON DELETE RESTRICT, + current_snapshot_id text, + checksum bytea CHECK (checksum IS NULL OR octet_length(checksum) = 32), + invalidated boolean NOT NULL DEFAULT false, + updated_at timestamptz NOT NULL, + UNIQUE (id, project_id), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (subscription_instance_id, project_id) + REFERENCES subscription_instances(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (one_time_purchase_instance_id, project_id) + REFERENCES one_time_purchase_instances(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (current_snapshot_id, project_id) + REFERENCES subscription_snapshots(id, project_id) ON DELETE RESTRICT, + CHECK ((subscription_instance_id IS NULL) <> (one_time_purchase_instance_id IS NULL)) +); +CREATE UNIQUE INDEX projection_checkpoints_subscription_idx + ON projection_checkpoints(subscription_instance_id) + WHERE subscription_instance_id IS NOT NULL; +CREATE UNIQUE INDEX projection_checkpoints_one_time_idx + ON projection_checkpoints(one_time_purchase_instance_id) + WHERE one_time_purchase_instance_id IS NOT NULL; + +-- Projection jobs: same lease shape as billing_validation_jobs, with +-- scope-key coalescing — at most one queued-or-leased job per scope. +CREATE TABLE projection_jobs ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text, + -- 'customer:{billing_customer_id}' or 'lineage:{purchase_lineage_id}'. + scope_key text NOT NULL CHECK (btrim(scope_key) <> '' AND length(scope_key) <= 200), + kind text NOT NULL CHECK (kind IN ( + 'fact_committed', 'association_established', 'quarantine_repair', + 'grant_version_published', 'rule_promotion', 'reconciliation_discovery', + 'replay', 'manual_sync' + )), + detail jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (billing_ledger_detail_is_safe(detail)), + status text NOT NULL 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 BETWEEN 1 AND 12), + available_at timestamptz NOT NULL, + lease_owner text, + lease_expires_at timestamptz, + last_error_code text CHECK (last_error_code IS NULL OR (btrim(last_error_code) <> '' AND length(last_error_code) <= 128)), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + CHECK ((status = 'leased') = (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)) +); +CREATE UNIQUE INDEX projection_jobs_scope_coalesce_idx + ON projection_jobs(scope_key) + WHERE status IN ('queued', 'leased'); +CREATE INDEX projection_jobs_lease_idx + ON projection_jobs(available_at, created_at, id) + WHERE status IN ('queued', 'leased'); + +-- Every execution records an attempt, including no-change and failed runs. +CREATE TABLE projection_attempts ( + id text PRIMARY KEY, + project_id text NOT NULL, + projection_job_id text, + scope_key text NOT NULL, + rule_version integer NOT NULL, + -- digest(scope, high-watermark, rule version, grant version set): the + -- idempotency key of the projection command (plan §9). + idempotency_key bytea CHECK (idempotency_key IS NULL OR octet_length(idempotency_key) = 32), + outcome text NOT NULL CHECK (outcome IN ( + 'projected', 'no_change', 'unresolved', 'frozen', 'failed' + )), + error_code text CHECK (error_code IS NULL OR (btrim(error_code) <> '' AND length(error_code) <= 128)), + started_at timestamptz NOT NULL, + completed_at timestamptz NOT NULL, + UNIQUE (id, project_id) +); +CREATE INDEX projection_attempts_scope_idx + ON projection_attempts(scope_key, completed_at DESC); + +CREATE TRIGGER projection_attempts_append_only +BEFORE UPDATE OR DELETE ON projection_attempts +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- +goose Down +DROP TRIGGER projection_attempts_append_only ON projection_attempts; +DROP TABLE projection_attempts; +DROP TABLE projection_jobs; +DROP TABLE projection_checkpoints; +DROP TRIGGER subscription_timeline_entries_append_only ON subscription_timeline_entries; +DROP TABLE subscription_timeline_entries; +DROP TRIGGER subscription_snapshot_facts_append_only ON subscription_snapshot_facts; +DROP TABLE subscription_snapshot_facts; +ALTER TABLE subscription_instances + DROP CONSTRAINT subscription_instances_current_snapshot_fkey, + DROP COLUMN current_snapshot_id; +DROP TRIGGER subscription_snapshots_append_only ON subscription_snapshots; +DROP TABLE subscription_snapshots; +DROP TABLE projection_rule_versions; diff --git a/apps/api/migrations/00033_phase_9b_grant_versions.sql b/apps/api/migrations/00033_phase_9b_grant_versions.sql new file mode 100644 index 00000000..5e9cb767 --- /dev/null +++ b/apps/api/migrations/00033_phase_9b_grant_versions.sql @@ -0,0 +1,148 @@ +-- Phase 9B: Product-to-Entitlement Grant Versions and Entitlement lifecycle +-- (plan §5, §7; OD-8 prospective + replacement + additive-superset, backfill +-- option (ii)). +-- +-- `product_entitlement_grants` is unversioned and hard-deletable — a +-- one-DELETE mass-revocation path. This migration makes grant meaning +-- versioned and immutable, backfills version 1 from each live pair's +-- created_at, reconstructs closed intervals for pairs that were granted and +-- later removed (from the audit events those operations always write), and +-- blocks hard DELETE on the legacy table once versions exist. +-- +-- No-overlap of grant intervals for one (product, entitlement) is enforced by +-- the application under the grant advisory lock rather than by a btree_gist +-- exclusion constraint: requiring an extension changes the deployment +-- contract for every operator, and the write path is already serialized. A +-- partial unique index still guarantees at most one open-ended version per +-- pair, which is the case an application bug would most plausibly produce. + +-- +goose Up +CREATE TABLE product_entitlement_grant_versions ( + id text PRIMARY KEY, + project_id text NOT NULL, + product_id text NOT NULL, + entitlement_id text NOT NULL, + version integer NOT NULL CHECK (version >= 1), + grant_policy_version integer NOT NULL DEFAULT 1 CHECK (grant_policy_version >= 1), + effective_start timestamptz NOT NULL, + effective_end timestamptz, + supported_purchase_types text[] NOT NULL DEFAULT ARRAY['auto_renewable_subscription','non_consumable']::text[], + -- Access policy per subscription state (plan §7, policy version 1). + grants_in_active boolean NOT NULL DEFAULT true, + grants_in_trial boolean NOT NULL DEFAULT true, + grants_in_grace boolean NOT NULL DEFAULT true, + -- Enabling billing-retry access contradicts both providers' documentation + -- and requires explicit owner approval; the default is closed. + grants_in_billing_retry boolean NOT NULL DEFAULT false, + -- Pause is fixed at no-access with no override (plan §7). + grants_in_paused boolean NOT NULL DEFAULT false, + grants_in_one_time_ownership boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL, + created_by_actor_id text, + reason text NOT NULL DEFAULT '', + UNIQUE (id, project_id), + UNIQUE (product_id, entitlement_id, version), + FOREIGN KEY (product_id, project_id) REFERENCES products(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (entitlement_id, project_id) REFERENCES entitlements(id, project_id) ON DELETE RESTRICT, + CHECK (effective_end IS NULL OR effective_end > effective_start), + CHECK (grants_in_paused = false) +); +-- At most one open-ended (current) version per pair. +CREATE UNIQUE INDEX product_entitlement_grant_versions_open_idx + ON product_entitlement_grant_versions(product_id, entitlement_id) + WHERE effective_end IS NULL; +CREATE INDEX product_entitlement_grant_versions_selection_idx + ON product_entitlement_grant_versions(product_id, entitlement_id, effective_start DESC); + +CREATE TRIGGER product_entitlement_grant_versions_append_only +BEFORE UPDATE OR DELETE ON product_entitlement_grant_versions +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- Backfill per OD-8(ii). Version 1 of every live pair is effective from the +-- grant row's own created_at: exact, because the grant row records when the +-- pair started granting. +INSERT INTO product_entitlement_grant_versions ( + id, project_id, product_id, entitlement_id, version, effective_start, created_at, reason) +SELECT + 'pegv_' || md5(g.product_id || ':' || g.entitlement_id || ':1'), + g.project_id, g.product_id, g.entitlement_id, 1, g.created_at, g.created_at, + 'backfill: live grant at 9B migration' +FROM product_entitlement_grants g; + +-- Pairs that were granted and later removed leave no grant row, but every +-- grant and removal is a discrete audited operation, so the closed interval is +-- reconstructable. Only removals whose grant is not currently live are +-- reconstructed (a re-granted pair is covered by the row above). +INSERT INTO product_entitlement_grant_versions ( + id, project_id, product_id, entitlement_id, version, effective_start, effective_end, + created_at, reason) +SELECT + 'pegv_' || md5(granted.resource_id || ':' || granted.entitlement_id || ':historic'), + granted.project_id, granted.resource_id, granted.entitlement_id, 1, + granted.created_at, removed.created_at, granted.created_at, + 'backfill: reconstructed from audit history' +FROM ( + SELECT DISTINCT ON (a.resource_id, a.metadata->>'entitlementId') + a.project_id, a.resource_id, a.metadata->>'entitlementId' AS entitlement_id, a.created_at + FROM audit_events a + WHERE a.action = 'product.entitlement_granted' AND a.project_id IS NOT NULL + AND a.metadata->>'entitlementId' IS NOT NULL + ORDER BY a.resource_id, a.metadata->>'entitlementId', a.created_at +) granted +JOIN LATERAL ( + SELECT a.created_at + FROM audit_events a + WHERE a.action = 'product.entitlement_removed' + AND a.resource_id = granted.resource_id + AND a.metadata->>'entitlementId' = granted.entitlement_id + AND a.created_at > granted.created_at + ORDER BY a.created_at DESC LIMIT 1 +) removed ON true +WHERE NOT EXISTS ( + SELECT 1 FROM product_entitlement_grants g + WHERE g.product_id = granted.resource_id AND g.entitlement_id = granted.entitlement_id) + AND EXISTS ( + SELECT 1 FROM products p WHERE p.id = granted.resource_id AND p.project_id = granted.project_id) + AND EXISTS ( + SELECT 1 FROM entitlements e WHERE e.id = granted.entitlement_id AND e.project_id = granted.project_id) +ON CONFLICT DO NOTHING; + +-- Once versions exist, a hard DELETE on the legacy grant table would strand +-- the version history and silently change historical access meaning. Removing +-- a grant now means closing the current version, which the application does. +-- +goose StatementBegin +CREATE FUNCTION reject_versioned_grant_delete() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM product_entitlement_grant_versions v + WHERE v.product_id = OLD.product_id AND v.entitlement_id = OLD.entitlement_id + ) THEN + RAISE EXCEPTION 'product_entitlement_grants is versioned; close the current grant version instead of deleting' + USING ERRCODE = '55000'; + END IF; + RETURN OLD; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER product_entitlement_grants_versioned_delete +BEFORE DELETE ON product_entitlement_grants +FOR EACH ROW EXECUTE FUNCTION reject_versioned_grant_delete(); + +-- Entitlement lifecycle columns. Archiving an Entitlement preserves its +-- historical meaning; nothing is deleted. +ALTER TABLE entitlements + ADD COLUMN lifecycle_state text NOT NULL DEFAULT 'active' + CHECK (lifecycle_state IN ('active', 'archived')), + ADD COLUMN archived_at timestamptz, + ADD CONSTRAINT entitlements_lifecycle_shape_check + CHECK ((lifecycle_state = 'archived') = (archived_at IS NOT NULL)); + +-- +goose Down +ALTER TABLE entitlements + DROP CONSTRAINT entitlements_lifecycle_shape_check, + DROP COLUMN archived_at, + DROP COLUMN lifecycle_state; +DROP TRIGGER product_entitlement_grants_versioned_delete ON product_entitlement_grants; +DROP FUNCTION reject_versioned_grant_delete(); +DROP TRIGGER product_entitlement_grant_versions_append_only ON product_entitlement_grant_versions; +DROP TABLE product_entitlement_grant_versions; diff --git a/apps/api/migrations/00034_phase_9b_entitlement_snapshots.sql b/apps/api/migrations/00034_phase_9b_entitlement_snapshots.sql new file mode 100644 index 00000000..7e80e875 --- /dev/null +++ b/apps/api/migrations/00034_phase_9b_entitlement_snapshots.sql @@ -0,0 +1,175 @@ +-- Phase 9B: Entitlement Sources and Customer Entitlement Snapshots +-- (plan §5, OD-3(b) Environment-scoped snapshots and pointers). +-- +-- A Customer is Project-scoped, but everything that holds state is +-- Environment-scoped, so a customer has one monotonic snapshot sequence and +-- one current pointer PER (customer, environment). Without that, a sandbox +-- projection would advance the snapshot version a production SDK reads. +-- +-- Entitlement Source identity is (purchase lineage, product, grant version) — +-- never a fact id — so multi-fact-per-purchase (mapping drift, validator +-- bumps) cannot double-grant. +-- +-- The snapshot/pointer relationship is circular (a snapshot names the customer +-- and the customer's pointer names a snapshot), so the pointer's foreign key +-- is added in a second step after both tables exist. + +-- +goose Up +CREATE TABLE customer_entitlement_snapshots ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + billing_customer_id text NOT NULL, + -- Monotonic per (customer, environment). A no-change projection does not + -- advance it (plan §3 snapshot versioning). + snapshot_version bigint NOT NULL CHECK (snapshot_version >= 1), + rule_version integer NOT NULL REFERENCES projection_rule_versions(version) ON DELETE RESTRICT, + computed_at timestamptz NOT NULL, + as_of timestamptz NOT NULL, + previous_snapshot_id text, + checksum bytea NOT NULL CHECK (octet_length(checksum) = 32), + change_reason text NOT NULL CHECK (btrim(change_reason) <> '' AND length(change_reason) <= 64), + created_at timestamptz NOT NULL, + UNIQUE (id, project_id), + UNIQUE (billing_customer_id, environment_id, snapshot_version), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (billing_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (previous_snapshot_id, project_id) + REFERENCES customer_entitlement_snapshots(id, project_id) ON DELETE RESTRICT, + CHECK (previous_snapshot_id IS NULL OR previous_snapshot_id <> id) +); +CREATE INDEX customer_entitlement_snapshots_customer_idx + ON customer_entitlement_snapshots(billing_customer_id, environment_id, snapshot_version DESC); + +CREATE TRIGGER customer_entitlement_snapshots_append_only +BEFORE UPDATE OR DELETE ON customer_entitlement_snapshots +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- Entitlement Sources belong to one snapshot generation: they are the +-- explanation of why that snapshot said what it said. +CREATE TABLE entitlement_sources ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + customer_entitlement_snapshot_id text NOT NULL, + billing_customer_id text NOT NULL, + entitlement_id text NOT NULL, + -- Source identity (plan §3): lineage + product + grant version. + purchase_lineage_id text NOT NULL, + product_id text NOT NULL, + grant_version_id text NOT NULL, + subscription_instance_id text, + one_time_purchase_instance_id text, + source_snapshot_id text, + source_type text NOT NULL CHECK (source_type IN ( + 'active_subscription', 'trial', 'verified_grace_period', + 'accepted_billing_retry', 'one_time_non_consumable', 'family_shared' + )), + source_state text NOT NULL CHECK (source_state IN ('active', 'inactive', 'unknown')), + source_start timestamptz, + source_end timestamptz, + -- A permanent source (a valid non-consumable) has no finite end. The + -- distinction between "no end known" and "no end exists" is what keeps a + -- lifetime purchase from reporting a misleading expiry. + end_known boolean NOT NULL DEFAULT true, + uncertainty_reason text NOT NULL DEFAULT 'none', + is_test_source boolean NOT NULL DEFAULT false, + explanation_code text NOT NULL CHECK (btrim(explanation_code) <> '' AND length(explanation_code) <= 64), + created_at timestamptz NOT NULL, + UNIQUE (id, project_id), + -- One source per identity per snapshot generation: this is the structural + -- guarantee against double-granting from two facts of one purchase. + UNIQUE (customer_entitlement_snapshot_id, purchase_lineage_id, entitlement_id, grant_version_id), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (customer_entitlement_snapshot_id, project_id) + REFERENCES customer_entitlement_snapshots(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (billing_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (entitlement_id, project_id) + REFERENCES entitlements(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (purchase_lineage_id, project_id) + REFERENCES purchase_lineages(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (product_id, project_id) + REFERENCES products(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (grant_version_id, project_id) + REFERENCES product_entitlement_grant_versions(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (subscription_instance_id, project_id) + REFERENCES subscription_instances(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (one_time_purchase_instance_id, project_id) + REFERENCES one_time_purchase_instances(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (source_snapshot_id, project_id) + REFERENCES subscription_snapshots(id, project_id) ON DELETE RESTRICT, + CHECK (end_known OR source_end IS NULL) +); +CREATE INDEX entitlement_sources_snapshot_idx + ON entitlement_sources(customer_entitlement_snapshot_id, entitlement_id); + +CREATE TRIGGER entitlement_sources_append_only +BEFORE UPDATE OR DELETE ON entitlement_sources +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +CREATE TABLE customer_entitlement_snapshot_entries ( + id text PRIMARY KEY, + project_id text NOT NULL, + customer_entitlement_snapshot_id text NOT NULL, + entitlement_id text NOT NULL, + entitlement_key text NOT NULL CHECK (btrim(entitlement_key) <> ''), + -- 'unavailable' is a read-time service state and is never persisted. + state text NOT NULL CHECK (state IN ('active', 'inactive', 'unknown')), + effective_start timestamptz, + effective_end timestamptz, + end_known boolean NOT NULL DEFAULT true, + source_count integer NOT NULL DEFAULT 0 CHECK (source_count >= 0), + uncertainty_reason text NOT NULL DEFAULT 'none' CHECK (uncertainty_reason IN ( + 'none', 'provider_unavailable', 'missing_fact', 'identity_unresolved', + 'product_unresolved', 'conflicting_facts', 'projection_failed', + 'stale_validation', 'unsupported_provider_state' + )), + is_test_source boolean NOT NULL DEFAULT false, + explanation_code text NOT NULL CHECK (btrim(explanation_code) <> '' AND length(explanation_code) <= 64), + UNIQUE (id, project_id), + -- One entry per Entitlement per snapshot. + UNIQUE (customer_entitlement_snapshot_id, entitlement_id), + FOREIGN KEY (customer_entitlement_snapshot_id, project_id) + REFERENCES customer_entitlement_snapshots(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (entitlement_id, project_id) + REFERENCES entitlements(id, project_id) ON DELETE RESTRICT, + CHECK (state <> 'unknown' OR uncertainty_reason <> 'none'), + CHECK (end_known OR effective_end IS NULL) +); + +CREATE TRIGGER customer_entitlement_snapshot_entries_append_only +BEFORE UPDATE OR DELETE ON customer_entitlement_snapshot_entries +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- Step two of the circular relationship: exactly one current pointer per +-- (customer, environment), updated atomically inside the projection +-- transaction. The pointer is mutable by design — it is the only mutable row +-- in the snapshot graph. +CREATE TABLE customer_entitlement_pointers ( + project_id text NOT NULL, + environment_id text NOT NULL, + billing_customer_id text NOT NULL, + current_snapshot_id text NOT NULL, + snapshot_version bigint NOT NULL CHECK (snapshot_version >= 1), + updated_at timestamptz NOT NULL, + PRIMARY KEY (billing_customer_id, environment_id), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) 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) + REFERENCES customer_entitlement_snapshots(id, project_id) ON DELETE RESTRICT +); + +-- +goose Down +DROP TABLE customer_entitlement_pointers; +DROP TRIGGER customer_entitlement_snapshot_entries_append_only ON customer_entitlement_snapshot_entries; +DROP TABLE customer_entitlement_snapshot_entries; +DROP TRIGGER entitlement_sources_append_only ON entitlement_sources; +DROP TABLE entitlement_sources; +DROP TRIGGER customer_entitlement_snapshots_append_only ON customer_entitlement_snapshots; +DROP TABLE customer_entitlement_snapshots; diff --git a/apps/api/migrations/00035_phase_9b_customer_access_tokens.sql b/apps/api/migrations/00035_phase_9b_customer_access_tokens.sql new file mode 100644 index 00000000..dde7953e --- /dev/null +++ b/apps/api/migrations/00035_phase_9b_customer_access_tokens.sql @@ -0,0 +1,68 @@ +-- Phase 9B: Customer Access Tokens (plan §11, OD-14(a)). +-- +-- Opaque random tokens stored as SHA-256 digests, following the ADR-0017 +-- posture already used for browser sessions and API keys. Project, +-- Environment, and customer scope are composite foreign-key columns rather +-- than claims to validate, so a token structurally cannot reach another +-- tenant's data: the scope is read from the row, never from the bearer. +-- Revocation is one UPDATE, which is why no signing ADR is required. + +-- +goose Up +CREATE TABLE customer_access_tokens ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + billing_customer_id text NOT NULL, + token_digest bytea NOT NULL UNIQUE CHECK (octet_length(token_digest) = 32), + audience text NOT NULL CHECK (audience IN ('sdk_entitlement_sync')), + scopes text[] NOT NULL DEFAULT ARRAY['entitlements:read']::text[], + issued_by_api_key_id text, + issued_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + last_used_at timestamptz, + UNIQUE (id, project_id), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (billing_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + -- Short lived by construction: the service defaults to one hour and the + -- schema refuses anything beyond twenty-four. + CONSTRAINT customer_access_tokens_lifetime_check CHECK ( + expires_at > issued_at AND expires_at <= issued_at + interval '24 hours' + ), + CHECK (revoked_at IS NULL OR revoked_at >= issued_at), + CHECK (cardinality(scopes) > 0) +); +CREATE INDEX customer_access_tokens_customer_idx + ON customer_access_tokens(billing_customer_id, environment_id, issued_at DESC); +-- Expiry sweep support. +CREATE INDEX customer_access_tokens_expiry_idx + ON customer_access_tokens(expires_at) WHERE revoked_at IS NULL; + +-- Revocation is monotonic: a revoked token can never be un-revoked by a later +-- write, matching the api-key precedent from migration 00002. +-- +goose StatementBegin +CREATE FUNCTION preserve_customer_token_revocation() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.revoked_at IS NOT NULL AND NEW.revoked_at IS NULL THEN + NEW.revoked_at := OLD.revoked_at; + END IF; + IF NEW.token_digest <> OLD.token_digest + OR NEW.billing_customer_id <> OLD.billing_customer_id + OR NEW.environment_id <> OLD.environment_id + OR NEW.project_id <> OLD.project_id THEN + RAISE EXCEPTION 'customer access token scope is immutable' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER customer_access_tokens_preserve_revocation +BEFORE UPDATE ON customer_access_tokens +FOR EACH ROW EXECUTE FUNCTION preserve_customer_token_revocation(); + +-- +goose Down +DROP TRIGGER customer_access_tokens_preserve_revocation ON customer_access_tokens; +DROP FUNCTION preserve_customer_token_revocation(); +DROP TABLE customer_access_tokens; diff --git a/apps/api/migrations/00036_phase_9b_webhooks.sql b/apps/api/migrations/00036_phase_9b_webhooks.sql new file mode 100644 index 00000000..ff2a716d --- /dev/null +++ b/apps/api/migrations/00036_phase_9b_webhooks.sql @@ -0,0 +1,119 @@ +-- Phase 9B: application webhooks (plan §3, OD-1(b) minimal slice). +-- +-- Only the schema lands in this batch, because the projection transaction must +-- create webhook event rows inside the same commit as the state they announce +-- — an access-change webhook may only exist for state that was committed. The +-- delivery worker, destination management API, and signing implementation are +-- the next batch; ADR-0024 records the signing and SSRF policy they follow. +-- +-- Events are destination-independent and immutable, which is what gives +-- consumers a stable event id across retries: a retry is a new delivery +-- attempt, never a new logical event. + +-- +goose Up +CREATE TABLE webhook_destinations ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + url text NOT NULL CHECK (url LIKE 'https://%' AND length(url) <= 2048), + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'disabled')), + event_types text[] NOT NULL DEFAULT ARRAY['customer.entitlements.changed']::text[], + description text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + created_by_actor_id text, + UNIQUE (id, project_id), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + CHECK (cardinality(event_types) > 0) +); +CREATE INDEX webhook_destinations_environment_idx + ON webhook_destinations(environment_id, status, id); + +-- Signing secrets are sealed with the existing provider-credential envelope +-- under a new v2 AAD SubjectKind (webhook_signing_secret). Several may be +-- active at once so a rotation has an overlap window. +CREATE TABLE webhook_signing_secrets ( + id text PRIMARY KEY, + project_id text NOT NULL, + webhook_destination_id text NOT NULL, + status text NOT NULL CHECK (status IN ('active', 'retired')), + envelope_version integer NOT NULL, + algorithm text NOT NULL, + key_id text NOT NULL, + nonce bytea NOT NULL, + ciphertext bytea NOT NULL, + fingerprint bytea NOT NULL, + created_at timestamptz NOT NULL, + retired_at timestamptz, + UNIQUE (id, project_id), + FOREIGN KEY (webhook_destination_id, project_id) + REFERENCES webhook_destinations(id, project_id) ON DELETE RESTRICT, + CHECK ((status = 'retired') = (retired_at IS NOT NULL)) +); +CREATE INDEX webhook_signing_secrets_destination_idx + ON webhook_signing_secrets(webhook_destination_id, status, created_at DESC); + +CREATE TABLE webhook_events ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + event_type text NOT NULL CHECK (event_type IN ('customer.entitlements.changed')), + billing_customer_id text NOT NULL, + -- The committed snapshot this event announces. The FK is what makes + -- "a webhook event for state that was not committed" unrepresentable. + customer_entitlement_snapshot_id text NOT NULL, + snapshot_version bigint NOT NULL CHECK (snapshot_version >= 1), + payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object' AND octet_length(payload::text) <= 65536), + occurred_at timestamptz NOT NULL, + created_at timestamptz NOT NULL, + UNIQUE (id, project_id), + -- One logical event per committed snapshot: an idempotent re-run of the + -- projection cannot produce a second announcement of the same state. + UNIQUE (customer_entitlement_snapshot_id, event_type), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (billing_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (customer_entitlement_snapshot_id, project_id) + REFERENCES customer_entitlement_snapshots(id, project_id) ON DELETE RESTRICT +); +CREATE INDEX webhook_events_environment_idx + ON webhook_events(environment_id, created_at DESC, id); + +CREATE TRIGGER webhook_events_append_only +BEFORE UPDATE OR DELETE ON webhook_events +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +CREATE TABLE webhook_delivery_attempts ( + id text PRIMARY KEY, + project_id text NOT NULL, + webhook_event_id text NOT NULL, + webhook_destination_id text NOT NULL, + attempt_number integer NOT NULL CHECK (attempt_number >= 1), + outcome text NOT NULL CHECK (outcome IN ('delivered', 'retryable_failure', 'permanent_failure', 'exhausted')), + response_status integer, + error_code text CHECK (error_code IS NULL OR (btrim(error_code) <> '' AND length(error_code) <= 128)), + latency_ms integer CHECK (latency_ms IS NULL OR latency_ms >= 0), + attempted_at timestamptz NOT NULL, + UNIQUE (id, project_id), + UNIQUE (webhook_event_id, webhook_destination_id, attempt_number), + 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 +); +CREATE INDEX webhook_delivery_attempts_event_idx + ON webhook_delivery_attempts(webhook_event_id, attempted_at DESC); + +CREATE TRIGGER webhook_delivery_attempts_append_only +BEFORE UPDATE OR DELETE ON webhook_delivery_attempts +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- +goose Down +DROP TRIGGER webhook_delivery_attempts_append_only ON webhook_delivery_attempts; +DROP TABLE webhook_delivery_attempts; +DROP TRIGGER webhook_events_append_only ON webhook_events; +DROP TABLE webhook_events; +DROP TABLE webhook_signing_secrets; +DROP TABLE webhook_destinations; diff --git a/apps/api/migrations/00037_phase_9b_restore_sync_jobs.sql b/apps/api/migrations/00037_phase_9b_restore_sync_jobs.sql new file mode 100644 index 00000000..0601c2cb --- /dev/null +++ b/apps/api/migrations/00037_phase_9b_restore_sync_jobs.sql @@ -0,0 +1,122 @@ +-- Phase 9B: restore and sync jobs (plan §11, §13 restore semantics). +-- +-- A restore is not one action, it is a chain: the SDK submits provider +-- transaction references as observations, those observations become Raw +-- Billing Inputs, validation turns them into facts, and only then does a +-- projection produce a snapshot that reflects them. This table records the +-- whole chain so the outcome reported to the caller is derived from where the +-- chain actually got to, never from the fact that the native restore returned. +-- +-- The single most important column is `snapshot_version`: the `restored` +-- outcome may only be written together with the accepted snapshot version that +-- demonstrates it. Without that column the restore surface could report +-- restored access that no snapshot has yet granted, which is exactly the lie +-- the Authoritative Entitlement contract's two-axis restore result exists to +-- prevent. + +-- +goose Up +CREATE TABLE restore_sync_jobs ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + -- Null until identity resolves. `identity_unresolved` is precisely the + -- outcome in which this stays null, which is why there is no NOT NULL here. + billing_customer_id text, + store_platform text NOT NULL CHECK (store_platform IN ('apple_app_store', 'google_play')), + + status text NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'leased', 'completed', 'failed')), + -- Mosaic's authoritative answer, on the closed contract vocabulary. + outcome text CHECK (outcome IS NULL OR outcome IN ( + 'restored', 'no_additional_purchases', 'validation_pending', + 'identity_unresolved', 'product_unresolved', 'provider_unavailable', 'failed' + )), + -- What the native provider restore itself did, kept on its own axis and + -- never merged into `outcome`. + provider_outcome text NOT NULL DEFAULT 'not_attempted' CHECK (provider_outcome IN ( + 'completed', 'no_purchases_found', 'cancelled', 'failed', 'unsupported', 'not_attempted' + )), + uncertainty_reason text NOT NULL DEFAULT 'none' CHECK (uncertainty_reason IN ( + 'none', 'provider_unavailable', 'missing_fact', 'identity_unresolved', + 'product_unresolved', 'conflicting_facts', 'projection_failed', + 'stale_validation', 'unsupported_provider_state' + )), + + observed_transaction_count integer NOT NULL DEFAULT 0 CHECK (observed_transaction_count >= 0), + pending_validation_count integer NOT NULL DEFAULT 0 CHECK (pending_validation_count >= 0), + -- The snapshot version current when the restore was requested. The restore + -- is only "reflected" once the customer's version has moved past it. + baseline_snapshot_version bigint CHECK (baseline_snapshot_version IS NULL OR baseline_snapshot_version >= 0), + -- The accepted snapshot that reflects the restore. This is the evidence for + -- the `restored` outcome. + snapshot_version bigint CHECK (snapshot_version IS NULL OR snapshot_version >= 1), + + correlation_id text NOT NULL DEFAULT '' CHECK (length(correlation_id) <= 128), + attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + max_attempts integer NOT NULL DEFAULT 5 CHECK (max_attempts >= 1), + available_at timestamptz NOT NULL, + leased_by text, + leased_until timestamptz, + requested_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + completed_at timestamptz, + + UNIQUE (id, project_id), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (billing_customer_id, project_id) + REFERENCES billing_customers(id, project_id) ON DELETE RESTRICT, + + -- The invariant the whole table exists for. + CONSTRAINT restore_sync_jobs_restored_requires_snapshot CHECK ( + outcome <> 'restored' OR (snapshot_version IS NOT NULL AND billing_customer_id IS NOT NULL AND completed_at IS NOT NULL) + ), + CONSTRAINT restore_sync_jobs_identity_unresolved_has_no_customer CHECK ( + outcome <> 'identity_unresolved' OR billing_customer_id IS NULL + ), + -- Every non-definite outcome remains explainable, on the same uncertainty + -- vocabulary every other entitlement surface uses. + CONSTRAINT restore_sync_jobs_uncertain_outcomes_explained CHECK ( + outcome IS NULL + OR outcome IN ('restored', 'no_additional_purchases') + OR uncertainty_reason <> 'none' + ), + CONSTRAINT restore_sync_jobs_terminal_has_outcome CHECK ( + status NOT IN ('completed', 'failed') OR outcome IS NOT NULL + ) +); +CREATE INDEX restore_sync_jobs_queue_idx + ON restore_sync_jobs(available_at, id) WHERE status IN ('queued', 'leased'); +CREATE INDEX restore_sync_jobs_customer_idx + ON restore_sync_jobs(billing_customer_id, environment_id, requested_at DESC) + WHERE billing_customer_id IS NOT NULL; +CREATE INDEX restore_sync_jobs_environment_idx + ON restore_sync_jobs(environment_id, requested_at DESC, id); + +-- The submitted references, linked to the Raw Billing Inputs the observation +-- endpoint created for them. This is the join that lets the worker answer +-- "have all of this restore's submissions been validated yet?" without +-- guessing from timestamps. +CREATE TABLE restore_sync_job_inputs ( + restore_sync_job_id text NOT NULL, + project_id text NOT NULL, + raw_input_id text NOT NULL, + transaction_reference_digest bytea NOT NULL CHECK (octet_length(transaction_reference_digest) = 32), + created_at timestamptz NOT NULL, + PRIMARY KEY (restore_sync_job_id, raw_input_id), + FOREIGN KEY (restore_sync_job_id, project_id) + REFERENCES restore_sync_jobs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (raw_input_id, project_id) + REFERENCES billing_raw_inputs(id, project_id) ON DELETE RESTRICT +); +CREATE INDEX restore_sync_job_inputs_digest_idx + ON restore_sync_job_inputs(project_id, transaction_reference_digest); + +CREATE TRIGGER restore_sync_job_inputs_append_only +BEFORE UPDATE OR DELETE ON restore_sync_job_inputs +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- +goose Down +DROP TRIGGER restore_sync_job_inputs_append_only ON restore_sync_job_inputs; +DROP TABLE restore_sync_job_inputs; +DROP TABLE restore_sync_jobs; diff --git a/apps/api/migrations/00038_phase_9b_access_surface_alignment.sql b/apps/api/migrations/00038_phase_9b_access_surface_alignment.sql new file mode 100644 index 00000000..9748d6cf --- /dev/null +++ b/apps/api/migrations/00038_phase_9b_access_surface_alignment.sql @@ -0,0 +1,157 @@ +-- Phase 9B: align the access surfaces with the frozen draft contracts. +-- +-- Batch 1 landed the token and webhook schemas before the three 9B contracts +-- were frozen. Two vocabularies drifted apart in that window, and one delivery +-- concept was missing entirely: +-- +-- 1. Customer Access Token Contract v1 names the audience `sdk_sync` and the +-- scopes `entitlements.read` / `entitlements.sync` / `restore.request`. +-- Migration 00035 shipped `sdk_entitlement_sync` and `entitlements:read`. +-- Storing a different vocabulary from the one on the wire would put a +-- translation table between the token row and the contract, and a +-- translation table is a place two vocabularies can silently disagree — so +-- storage adopts the contract's words instead. +-- 2. The contract requires a revocation reason on every revoked token. There +-- was no column for one, so a revoked token could not explain itself. +-- 3. Delivery attempts were modelled, but the delivery itself — the retryable, +-- leasable unit of work per (event, destination) — was not. Attempts are +-- append-only history; the delivery is the mutable state machine that +-- produces them. + +-- +goose Up + +-- 1 + 2: token vocabulary and revocation reason. +ALTER TABLE customer_access_tokens + DROP CONSTRAINT customer_access_tokens_audience_check; +UPDATE customer_access_tokens SET audience = 'sdk_sync' WHERE audience = 'sdk_entitlement_sync'; +UPDATE customer_access_tokens + SET scopes = ARRAY(SELECT replace(scope, ':', '.') FROM unnest(scopes) AS scope); +ALTER TABLE customer_access_tokens + ADD CONSTRAINT customer_access_tokens_audience_check + CHECK (audience IN ('sdk_sync', 'server_check')), + ALTER COLUMN scopes SET DEFAULT ARRAY['entitlements.read']::text[], + ADD CONSTRAINT customer_access_tokens_scope_vocabulary_check CHECK ( + scopes <@ ARRAY['entitlements.read', 'entitlements.sync', 'restore.request']::text[] + ), + ADD COLUMN revocation_reason text CHECK (revocation_reason IS NULL OR revocation_reason IN ( + 'customer_signed_out', 'identity_changed', 'operator_revoked', 'customer_deleted', + 'key_rotated', 'suspected_compromise', 'superseded_by_new_token' + )), + ADD CONSTRAINT customer_access_tokens_revocation_pairing_check + CHECK ((revoked_at IS NULL) = (revocation_reason IS NULL)); + +-- 3: the delivery state machine. One row per (event, destination); attempts +-- hang off it as append-only history. +CREATE TABLE webhook_deliveries ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + webhook_event_id text NOT NULL, + webhook_destination_id text NOT NULL, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'succeeded', 'failed', 'exhausted', 'skipped')), + skipped_reason text CHECK (skipped_reason IS NULL OR skipped_reason IN ( + 'destination_disabled', 'event_type_not_enabled', 'destination_deleted', 'tenant_suspended' + )), + attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + max_attempts integer NOT NULL DEFAULT 8 CHECK (max_attempts BETWEEN 1 AND 32), + next_attempt_at timestamptz, + leased_by text, + leased_until timestamptz, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + completed_at timestamptz, + UNIQUE (id, project_id), + -- At-least-once delivery of one event to one destination is one delivery. + -- A replay reuses this row and appends a further attempt, which is what + -- keeps the event id stable across every retry and every manual replay. + UNIQUE (webhook_event_id, webhook_destination_id), + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(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, + CHECK ((status = 'skipped') = (skipped_reason IS NOT NULL)), + -- Terminal states schedule nothing. + CHECK (status = 'pending' OR next_attempt_at IS NULL), + CHECK ((status = 'pending') = (completed_at IS NULL)) +); +CREATE INDEX webhook_deliveries_queue_idx + ON webhook_deliveries(next_attempt_at, id) WHERE status = 'pending'; +CREATE INDEX webhook_deliveries_destination_idx + ON webhook_deliveries(webhook_destination_id, created_at DESC, id); +CREATE INDEX webhook_deliveries_environment_idx + ON webhook_deliveries(environment_id, status, created_at DESC); + +-- Attempt history gains the members the delivery contract records. +ALTER TABLE webhook_delivery_attempts + DROP CONSTRAINT webhook_delivery_attempts_outcome_check; +ALTER TABLE webhook_delivery_attempts + ADD CONSTRAINT webhook_delivery_attempts_outcome_check CHECK (outcome IN ( + 'delivered', 'retryable_failure', 'permanent_failure', 'exhausted', 'skipped' + )), + ADD COLUMN webhook_delivery_id text, + ADD COLUMN max_attempts integer NOT NULL DEFAULT 8 CHECK (max_attempts BETWEEN 1 AND 32), + ADD COLUMN responded_at timestamptz, + ADD COLUMN next_attempt_at timestamptz, + -- Bounded, control-character-free, and never parsed: it exists so an + -- integrator can see why their own endpoint refused, and for nothing else. + ADD COLUMN response_excerpt text CHECK ( + response_excerpt IS NULL + OR (length(response_excerpt) <= 240 AND response_excerpt !~ '[[:cntrl:]]') + ), + ADD COLUMN skipped_reason text CHECK (skipped_reason IS NULL OR skipped_reason IN ( + 'destination_disabled', 'event_type_not_enabled', 'destination_deleted', 'tenant_suspended' + )), + ADD CONSTRAINT webhook_delivery_attempts_delivery_fkey + FOREIGN KEY (webhook_delivery_id, project_id) + REFERENCES webhook_deliveries(id, project_id) ON DELETE RESTRICT, + -- Exhaustion is terminal, so it schedules nothing. + ADD CONSTRAINT webhook_delivery_attempts_exhausted_is_terminal + CHECK (outcome <> 'exhausted' OR next_attempt_at IS NULL), + ADD CONSTRAINT webhook_delivery_attempts_skipped_reason_present + CHECK ((outcome = 'skipped') = (skipped_reason IS NOT NULL)); + +-- Destinations record when their secret was last rotated so the one-time +-- display is auditable without keeping the secret anywhere. +ALTER TABLE webhook_destinations + ADD COLUMN secret_last_rotated_at timestamptz, + ADD COLUMN disabled_reason text CHECK (disabled_reason IS NULL OR ( + btrim(disabled_reason) <> '' AND length(disabled_reason) <= 128 + )); + +-- +goose Down +ALTER TABLE webhook_destinations + DROP COLUMN disabled_reason, + DROP COLUMN secret_last_rotated_at; + +ALTER TABLE webhook_delivery_attempts + DROP CONSTRAINT webhook_delivery_attempts_skipped_reason_present, + DROP CONSTRAINT webhook_delivery_attempts_exhausted_is_terminal, + DROP CONSTRAINT webhook_delivery_attempts_delivery_fkey, + DROP COLUMN skipped_reason, + DROP COLUMN response_excerpt, + DROP COLUMN next_attempt_at, + DROP COLUMN responded_at, + DROP COLUMN max_attempts, + DROP COLUMN webhook_delivery_id, + DROP CONSTRAINT webhook_delivery_attempts_outcome_check; +ALTER TABLE webhook_delivery_attempts + ADD CONSTRAINT webhook_delivery_attempts_outcome_check CHECK (outcome IN ( + 'delivered', 'retryable_failure', 'permanent_failure', 'exhausted' + )); + +DROP TABLE webhook_deliveries; + +ALTER TABLE customer_access_tokens + DROP CONSTRAINT customer_access_tokens_revocation_pairing_check, + DROP COLUMN revocation_reason, + DROP CONSTRAINT customer_access_tokens_scope_vocabulary_check, + ALTER COLUMN scopes SET DEFAULT ARRAY['entitlements:read']::text[], + DROP CONSTRAINT customer_access_tokens_audience_check; +UPDATE customer_access_tokens SET audience = 'sdk_entitlement_sync' WHERE audience = 'sdk_sync'; +UPDATE customer_access_tokens + SET scopes = ARRAY(SELECT replace(scope, '.', ':') FROM unnest(scopes) AS scope); +ALTER TABLE customer_access_tokens + ADD CONSTRAINT customer_access_tokens_audience_check CHECK (audience IN ('sdk_entitlement_sync')); diff --git a/apps/api/migrations/00039_projection_job_coalescing_window.sql b/apps/api/migrations/00039_projection_job_coalescing_window.sql new file mode 100644 index 00000000..c7615ab3 --- /dev/null +++ b/apps/api/migrations/00039_projection_job_coalescing_window.sql @@ -0,0 +1,28 @@ +-- Phase 9B correction: coalesce projection triggers onto queued work only. +-- +-- Migration 00032 made the scope-key coalescing index partial over +-- `status IN ('queued','leased')`, so a trigger arriving while a projection was +-- already leased was silently absorbed. That is correct for a duplicate trigger +-- and wrong for a new one: a fact committed after the running job read its +-- input is not covered by that job's output, so absorbing its trigger left the +-- customer's access stale until some unrelated event happened to enqueue again. +-- For an expiration or a refund, "some unrelated event" can be never. +-- +-- Coalescing on `queued` alone keeps the burst protection that matters — a +-- hundred facts for one customer still collapse into one waiting job — while +-- guaranteeing that work arriving during a run gets a run of its own. The two +-- jobs cannot interleave: the projection's transaction-scoped advisory lock +-- serializes them, and the compare-and-swap on current_projection_version makes +-- the loser retry against fresh input rather than overwrite. + +-- +goose Up +DROP INDEX projection_jobs_scope_coalesce_idx; +CREATE UNIQUE INDEX projection_jobs_scope_coalesce_idx + ON projection_jobs(scope_key) + WHERE status = 'queued'; + +-- +goose Down +DROP INDEX projection_jobs_scope_coalesce_idx; +CREATE UNIQUE INDEX projection_jobs_scope_coalesce_idx + ON projection_jobs(scope_key) + WHERE status IN ('queued', 'leased'); diff --git a/apps/api/migrations/00040_phase_9b_webhook_delivery.sql b/apps/api/migrations/00040_phase_9b_webhook_delivery.sql new file mode 100644 index 00000000..effd6298 --- /dev/null +++ b/apps/api/migrations/00040_phase_9b_webhook_delivery.sql @@ -0,0 +1,153 @@ +-- Phase 9B: what webhook delivery still needs (WP19-21, ADR-0024). +-- +-- 00036 landed destinations, signing secrets, immutable events, and the +-- append-only attempt history. 00038 landed the delivery state machine itself. +-- Between them almost everything the delivery worker needs already exists, so +-- this migration is deliberately small: it adds the three things that are +-- genuinely absent and nothing that merely looks tidier. +-- +-- 1. A rotation overlap window. ADR-0024 §2 requires that a destination may +-- hold more than one active signing secret so a receiver that has adopted +-- the new secret and one that has not both verify. 00036 models retirement +-- as instantaneous — status flips to 'retired', retired_at is stamped, and +-- the secret stops signing that instant — which is exactly the behaviour +-- the ADR rejected, because it demands a rotation that is simultaneous on +-- both sides or a period of rejected deliveries, and an operator can +-- achieve neither. +-- +-- 2. Auto-disable policy state. A destination whose deliveries keep exhausting +-- their attempt budget is misconfigured or gone, not having an incident. +-- Without a counter there is nothing to base a bounded run on, and the +-- worker attempts a dead URL for every event forever. +-- +-- 3. A fan-out marker. webhook_events carries an append-only trigger, so it +-- cannot hold a "fanned out" flag — the flag would be an UPDATE, and the +-- whole point of the trigger is that an announced state is never rewritten. +-- +-- Two CHECK constraints are tightened at the same time; both are noted below +-- with the specific delivery bug they make unrepresentable. + +-- +goose Up + +-- -------------------------------------------------------------------------- +-- 1. Rotation overlap +-- -------------------------------------------------------------------------- + +-- honored_until lives on the secret rather than on the destination. +-- +-- The destination-level alternative ("previous_secret_expires_at") assumes +-- there is exactly one previous secret. During a second rotation started +-- before the first overlap lapsed there are two, and a single destination +-- column would silently retire one of them early — the precise failure the +-- overlap exists to prevent. Keeping the window on the row it governs means +-- each secret carries its own answer and the signing query is a plain +-- predicate rather than a join against a shared deadline. +-- +-- Semantics: a secret signs while status = 'active', OR while it is retired +-- and honored_until is still in the future. Once honored_until passes the +-- secret stops signing with no further action, which is what makes lapsing +-- safe even if no operator or worker ever touches the row again. +ALTER TABLE webhook_signing_secrets + ADD COLUMN honored_until timestamptz, + -- An overlap window is a property of retirement. An active secret already + -- signs, so a window on one would be either redundant or a contradiction. + ADD CONSTRAINT webhook_signing_secrets_honored_until_requires_retirement + CHECK (honored_until IS NULL OR retired_at IS NOT NULL); + +-- The signing read: every secret for a destination that is still permitted to +-- sign. Partial on status so the retired-and-lapsed rows, which accumulate +-- forever and are never read again, stay out of the index. +CREATE INDEX webhook_signing_secrets_signing_idx + ON webhook_signing_secrets(webhook_destination_id, honored_until) + WHERE status = 'active' OR honored_until IS NOT NULL; + +-- -------------------------------------------------------------------------- +-- 2. Auto-disable policy +-- -------------------------------------------------------------------------- + +-- consecutive_failure_count counts exhausted *deliveries* in a row, not failed +-- attempts: a single delivery already burns its whole attempt budget against a +-- provider outage, so counting attempts would disable a destination for one bad +-- afternoon. Any success resets it to zero, so a genuine outage that recovers +-- never trips the policy. +-- +-- auto_disable_reason is a Mosaic-owned stable code and is kept separate from +-- the free-text disabled_reason added in 00038. disabled_reason is whatever an +-- operator wrote when they disabled the destination by hand; this column is set +-- only by the automatic path, so "did Mosaic disable this, or did a person?" is +-- answerable without parsing prose. +ALTER TABLE webhook_destinations + ADD COLUMN consecutive_failure_count integer NOT NULL DEFAULT 0 + CHECK (consecutive_failure_count >= 0), + ADD COLUMN auto_disabled_at timestamptz, + ADD COLUMN auto_disable_reason text CHECK (auto_disable_reason IS NULL OR auto_disable_reason IN ( + 'consecutive_exhausted_deliveries', 'destination_refused' + )), + ADD CONSTRAINT webhook_destinations_auto_disable_pairing + CHECK ((auto_disabled_at IS NULL) = (auto_disable_reason IS NULL)), + -- An automatically disabled destination must actually be disabled. + -- Recording the reason while leaving status 'active' would produce a + -- destination that reads as auto-disabled everywhere in the API and is + -- still attempted by the worker. + ADD CONSTRAINT webhook_destinations_auto_disable_implies_disabled + CHECK (auto_disabled_at IS NULL OR status = 'disabled'); + +-- -------------------------------------------------------------------------- +-- 3. Fan-out marker +-- -------------------------------------------------------------------------- + +-- One row per event that has been expanded into deliveries. +-- +-- Recording the count as well as the instant makes the zero-destination case +-- distinguishable from the never-processed case. Those are identical when read +-- from webhook_deliveries alone — both are "no delivery rows" — and they are +-- the two answers an operator debugging "my endpoint never received this" most +-- needs told apart. It is also what stops a Project with no destinations +-- re-examining every event it has ever emitted on every worker poll. +CREATE TABLE webhook_event_fanouts ( + webhook_event_id text PRIMARY KEY, + project_id text NOT NULL, + delivery_count integer NOT NULL CHECK (delivery_count >= 0), + skipped_count integer NOT NULL DEFAULT 0 CHECK (skipped_count >= 0), + fanned_out_at timestamptz NOT NULL, + FOREIGN KEY (webhook_event_id, project_id) + REFERENCES webhook_events(id, project_id) ON DELETE RESTRICT +); + +-- -------------------------------------------------------------------------- +-- Two tightened CHECKs on the 00038 delivery row +-- -------------------------------------------------------------------------- + +-- Exhaustion means the attempts actually ran out. The delivery contract's +-- semantic validator rejects an exhausted record whose attempt count has not +-- reached its maximum, and a delivery marked exhausted after two attempts of +-- eight is a worker that gave up early — a silently dropped notification that +-- looks, in every operator view, exactly like one that was tried properly. +ALTER TABLE webhook_deliveries + ADD CONSTRAINT webhook_deliveries_exhausted_ran_out + CHECK (status <> 'exhausted' OR attempt_count >= max_attempts); + +-- A pending delivery must be scheduled. 00038 permits pending with a NULL +-- next_attempt_at; such a row sits in the queue index forever and is never +-- returned by the claim query, because `next_attempt_at <= now` is never true +-- of NULL. That is an invisible stuck delivery, and the only signal is a +-- customer's backend that never heard about a change. +ALTER TABLE webhook_deliveries + ADD CONSTRAINT webhook_deliveries_pending_is_scheduled + CHECK (status <> 'pending' OR next_attempt_at IS NOT NULL); + +-- +goose Down +ALTER TABLE webhook_deliveries + DROP CONSTRAINT webhook_deliveries_pending_is_scheduled, + DROP CONSTRAINT webhook_deliveries_exhausted_ran_out; +DROP TABLE webhook_event_fanouts; +ALTER TABLE webhook_destinations + DROP CONSTRAINT webhook_destinations_auto_disable_implies_disabled, + DROP CONSTRAINT webhook_destinations_auto_disable_pairing, + DROP COLUMN auto_disable_reason, + DROP COLUMN auto_disabled_at, + DROP COLUMN consecutive_failure_count; +DROP INDEX webhook_signing_secrets_signing_idx; +ALTER TABLE webhook_signing_secrets + DROP CONSTRAINT webhook_signing_secrets_honored_until_requires_retirement, + DROP COLUMN honored_until; diff --git a/apps/api/migrations/00041_reserved_no_op.sql b/apps/api/migrations/00041_reserved_no_op.sql new file mode 100644 index 00000000..4647b136 --- /dev/null +++ b/apps/api/migrations/00041_reserved_no_op.sql @@ -0,0 +1,29 @@ +-- Reserved version. This migration deliberately changes nothing. +-- +-- Version 41 was skipped during Phase 9B: two work packages were in flight at +-- once, one claimed 00041 and was then folded into 00040 before it landed, and +-- the sequence went 00040 → 00042. A gap in the sequence is not itself a +-- problem, but an *unclaimed* gap is: the next author to pick "the next free +-- number" by looking for a hole would write a 00041 that every deployment +-- already past 42 sees as a migration below its current version. Goose treats +-- that as an out-of-order migration and refuses to apply it, and Mosaic's +-- `migrate preflight` reports the schema as dirty — an interrupted-run verdict +-- that is wrong and that an operator cannot clear without manual intervention. +-- +-- Claiming the number with a no-op removes the hole, so the only way to add a +-- migration is to take the next number above the highest one. +-- +-- Operator note: this file lands while Phase 9B is unreleased, so no deployed +-- database has applied 00042 and above. A *development* database already at +-- version 42 or higher will report 00041 as pending-below-current; recreate it +-- (`docker compose down -v`) rather than forcing the row. +-- +-- Do not reuse this file for schema work. Reserved means reserved: a future +-- change that edited it would rewrite the meaning of a version some databases +-- have already recorded as applied. + +-- +goose Up +SELECT 1; + +-- +goose Down +SELECT 1; diff --git a/apps/api/migrations/00042_phase_9b_grant_backfill_intervals.sql b/apps/api/migrations/00042_phase_9b_grant_backfill_intervals.sql new file mode 100644 index 00000000..fd7d4d7b --- /dev/null +++ b/apps/api/migrations/00042_phase_9b_grant_backfill_intervals.sql @@ -0,0 +1,173 @@ +-- Phase 9B review correction I-14.2: reconstruct EACH historical grant→remove +-- cycle as its own grant version. +-- +-- Migration 00033 backfilled removed Product-to-Entitlement pairs from +-- `audit_events` using DISTINCT ON to take the earliest `product.entitlement_granted` +-- and a LATERAL to take the latest `product.entitlement_removed`. That collapses +-- every cycle a pair went through into ONE interval [first grant, last removal]. +-- A pair granted in January, removed in February, granted again in June and +-- removed in July backfills as a single interval covering January to July — so a +-- purchase made in April, when the Entitlement was not granted at all, selects +-- that version and is entitled. Backdated access is exactly what OD-8's +-- prospective-plus-additive-superset rule exists to prevent, and the backfill +-- was granting it. +-- +-- The reconstruction here walks the audit stream pairwise. Consecutive +-- same-kind events are collapsed first (a grant while a grant is already open +-- opens nothing new; a removal while nothing is open closes nothing), which +-- leaves a strictly alternating grant/remove sequence per pair. Each grant is +-- then paired with the removal that immediately follows it, producing one closed +-- interval per cycle. The intervals are non-overlapping by construction, which +-- is what the table's no-overlap invariant requires, and are numbered +-- chronologically from 1 to satisfy UNIQUE (product_id, entitlement_id, version). +-- +-- Scope is deliberately identical to 00033's: pairs with NO live +-- `product_entitlement_grants` row. A pair that is live already holds version 1 +-- from 00033's exact live backfill, and reconstructing its earlier cycles would +-- renumber a version that has already been published and possibly cited. That +-- gap is stated rather than closed here. +-- +-- Degenerate intervals (grant and removal at the same instant) are dropped: the +-- table's CHECK requires effective_end > effective_start, and an interval of +-- zero length grants nothing anyway. + +-- +goose Up + +-- A collapsed row that a projection has already cited cannot be removed without +-- rewriting the entitlement source that cites it, and entitlement_sources is +-- append-only evidence. Fail loudly instead of silently leaving the wrong +-- interval in place: the operator's recovery is to replay the affected scopes so +-- the citing snapshots are superseded, then re-run this migration. +-- +goose StatementBegin +DO $$ +DECLARE + citing bigint; +BEGIN + SELECT count(*) INTO citing + FROM entitlement_sources s + JOIN product_entitlement_grant_versions v ON v.id = s.grant_version_id + WHERE v.reason = 'backfill: reconstructed from audit history'; + IF citing > 0 THEN + RAISE EXCEPTION + 'cannot correct the 00033 grant backfill: % entitlement source(s) cite a collapsed grant version; replay those scopes first', citing + USING ERRCODE = '55000'; + END IF; +END; +$$; +-- +goose StatementEnd + +-- Grant versions are append-only; the trigger is lifted only for the length of +-- the correction, exactly as 00026's down path lifts the quarantine audit +-- trigger. What is being removed is a reconstruction Mosaic itself computed and +-- got wrong, not a provider statement or an operator action. +ALTER TABLE product_entitlement_grant_versions + DISABLE TRIGGER product_entitlement_grant_versions_append_only; + +DELETE FROM product_entitlement_grant_versions + WHERE reason = 'backfill: reconstructed from audit history'; + +INSERT INTO product_entitlement_grant_versions ( + id, project_id, product_id, entitlement_id, version, effective_start, effective_end, + created_at, reason) +WITH events AS ( + SELECT a.project_id, + a.resource_id AS product_id, + a.metadata->>'entitlementId' AS entitlement_id, + CASE WHEN a.action = 'product.entitlement_granted' THEN 'grant' ELSE 'remove' END AS kind, + a.created_at, + a.id AS event_id + FROM audit_events a + WHERE a.action IN ('product.entitlement_granted', 'product.entitlement_removed') + AND a.project_id IS NOT NULL + AND a.resource_id IS NOT NULL + AND a.metadata->>'entitlementId' IS NOT NULL +), deduplicated AS ( + -- Collapse runs of the same kind: only the first event of a run changes the + -- open/closed state of the pair. + SELECT e.*, + lag(e.kind) OVER ( + PARTITION BY e.product_id, e.entitlement_id + ORDER BY e.created_at, e.event_id) AS previous_kind + FROM events e +), transitions AS ( + SELECT * FROM deduplicated WHERE previous_kind IS DISTINCT FROM kind +), intervals AS ( + SELECT t.project_id, t.product_id, t.entitlement_id, t.kind, + t.created_at AS effective_start, + lead(t.created_at) OVER ( + PARTITION BY t.product_id, t.entitlement_id + ORDER BY t.created_at, t.event_id) AS effective_end + FROM transitions t +), closed AS ( + SELECT i.project_id, i.product_id, i.entitlement_id, i.effective_start, i.effective_end, + row_number() OVER ( + PARTITION BY i.product_id, i.entitlement_id + ORDER BY i.effective_start) AS version + FROM intervals i + WHERE i.kind = 'grant' + AND i.effective_end IS NOT NULL + AND i.effective_end > i.effective_start +) +SELECT + 'pegv_' || md5(c.product_id || ':' || c.entitlement_id || ':historic:' || c.version), + c.project_id, c.product_id, c.entitlement_id, c.version, + c.effective_start, c.effective_end, c.effective_start, + 'backfill: reconstructed from audit history (pairwise)' +FROM closed c +WHERE NOT EXISTS ( + SELECT 1 FROM product_entitlement_grants g + WHERE g.product_id = c.product_id AND g.entitlement_id = c.entitlement_id) + AND EXISTS ( + SELECT 1 FROM products p WHERE p.id = c.product_id AND p.project_id = c.project_id) + AND EXISTS ( + SELECT 1 FROM entitlements e WHERE e.id = c.entitlement_id AND e.project_id = c.project_id) +ON CONFLICT DO NOTHING; + +ALTER TABLE product_entitlement_grant_versions + ENABLE TRIGGER product_entitlement_grant_versions_append_only; + +-- +goose Down +-- Restore 00033's collapsed reconstruction exactly as it was, so down→up is a +-- true round trip rather than an amputation. +ALTER TABLE product_entitlement_grant_versions + DISABLE TRIGGER product_entitlement_grant_versions_append_only; + +DELETE FROM product_entitlement_grant_versions + WHERE reason = 'backfill: reconstructed from audit history (pairwise)'; + +INSERT INTO product_entitlement_grant_versions ( + id, project_id, product_id, entitlement_id, version, effective_start, effective_end, + created_at, reason) +SELECT + 'pegv_' || md5(granted.resource_id || ':' || granted.entitlement_id || ':historic'), + granted.project_id, granted.resource_id, granted.entitlement_id, 1, + granted.created_at, removed.created_at, granted.created_at, + 'backfill: reconstructed from audit history' +FROM ( + SELECT DISTINCT ON (a.resource_id, a.metadata->>'entitlementId') + a.project_id, a.resource_id, a.metadata->>'entitlementId' AS entitlement_id, a.created_at + FROM audit_events a + WHERE a.action = 'product.entitlement_granted' AND a.project_id IS NOT NULL + AND a.metadata->>'entitlementId' IS NOT NULL + ORDER BY a.resource_id, a.metadata->>'entitlementId', a.created_at +) granted +JOIN LATERAL ( + SELECT a.created_at + FROM audit_events a + WHERE a.action = 'product.entitlement_removed' + AND a.resource_id = granted.resource_id + AND a.metadata->>'entitlementId' = granted.entitlement_id + AND a.created_at > granted.created_at + ORDER BY a.created_at DESC LIMIT 1 +) removed ON true +WHERE NOT EXISTS ( + SELECT 1 FROM product_entitlement_grants g + WHERE g.product_id = granted.resource_id AND g.entitlement_id = granted.entitlement_id) + AND EXISTS ( + SELECT 1 FROM products p WHERE p.id = granted.resource_id AND p.project_id = granted.project_id) + AND EXISTS ( + SELECT 1 FROM entitlements e WHERE e.id = granted.entitlement_id AND e.project_id = granted.project_id) +ON CONFLICT DO NOTHING; + +ALTER TABLE product_entitlement_grant_versions + ENABLE TRIGGER product_entitlement_grant_versions_append_only; diff --git a/apps/api/migrations/00043_phase_9b_lineage_supersession_composite_fk.sql b/apps/api/migrations/00043_phase_9b_lineage_supersession_composite_fk.sql new file mode 100644 index 00000000..ff03ff41 --- /dev/null +++ b/apps/api/migrations/00043_phase_9b_lineage_supersession_composite_fk.sql @@ -0,0 +1,37 @@ +-- Phase 9B review correction I-14.3: make a cross-Project supersession pointer +-- unrepresentable. +-- +-- Migration 00031 declared `superseded_by_lineage_id` as a single-column +-- self-reference to purchase_lineages(id). Every other relationship in 9B is a +-- composite (id, project_id) foreign key precisely so tenancy is a schema +-- property rather than an application convention — this one was the exception, +-- and it permitted one Project's lineage to point at another Project's lineage. +-- The projection treats that edge as terminal (a superseded lineage stops +-- granting access), so a wrong or malicious pointer written by any code path +-- that forgets a Project check is a cross-tenant access revocation. +-- +-- The composite FK is the same shape as the lineage's own +-- billing_customer_id/project_id and application_id/project_id references, and +-- purchase_lineages already carries the UNIQUE (id, project_id) the reference +-- requires. +-- +-- The self-referencing CHECK forbidding a lineage from superseding itself is +-- unaffected and stays where 00031 put it. + +-- +goose Up +ALTER TABLE purchase_lineages + DROP CONSTRAINT purchase_lineages_superseded_by_lineage_id_fkey; +ALTER TABLE purchase_lineages + ADD CONSTRAINT purchase_lineages_superseded_by_lineage_fkey + FOREIGN KEY (superseded_by_lineage_id, project_id) + REFERENCES purchase_lineages(id, project_id) ON DELETE RESTRICT; + +-- +goose Down +-- Rolling back widens the constraint, so it cannot fail on existing data: every +-- row satisfying the composite reference also satisfies the single-column one. +ALTER TABLE purchase_lineages + DROP CONSTRAINT purchase_lineages_superseded_by_lineage_fkey; +ALTER TABLE purchase_lineages + ADD CONSTRAINT purchase_lineages_superseded_by_lineage_id_fkey + FOREIGN KEY (superseded_by_lineage_id) + REFERENCES purchase_lineages(id) ON DELETE RESTRICT; diff --git a/apps/api/migrations/00044_phase_9b_identity_conflict_reassignment.sql b/apps/api/migrations/00044_phase_9b_identity_conflict_reassignment.sql new file mode 100644 index 00000000..7173a3d8 --- /dev/null +++ b/apps/api/migrations/00044_phase_9b_identity_conflict_reassignment.sql @@ -0,0 +1,78 @@ +-- Phase 9B correction (review finding I-10): identity conflicts are not always +-- about a lineage. +-- +-- Migration 00030 modelled every identity conflict as a dispute over one +-- purchase lineage, because that was the only conflict the resolver could +-- produce. Two more conflicts exist and had nowhere to live: +-- +-- 1. A lineage already attached to a customer whose evidence now names a +-- different one. The service used to move the pointer silently on +-- higher-authority evidence, leaving no operator record and a previously +-- granted customer whose committed snapshot still granted the purchase. +-- That is lineage-scoped and fits the existing shape; it gains only a +-- diagnostic code, carried in the existing `detail` column. +-- 2. An application-user alias that already resolves to a different +-- customer (plan §5a rule 4). This disputes no lineage at all, so +-- `purchase_lineage_id` becomes nullable and the row instead names the +-- alias family and digest in dispute. +-- +-- The alias digest is stored, never rendered: it is the same domain-separated +-- SHA-256 already held in billing_customer_aliases, and it is what makes "one +-- open conflict per disputed alias" enforceable by the database rather than by +-- application logic that a race can lose. + +-- +goose Up +ALTER TABLE billing_identity_conflicts + ADD COLUMN conflict_scope text NOT NULL DEFAULT 'lineage' + CHECK (conflict_scope IN ('lineage', 'alias')), + ADD COLUMN alias_type text CHECK (alias_type IS NULL OR alias_type IN ( + 'application_user_id', 'installation_id', + 'apple_app_account_token', 'google_obfuscated_account_id' + )), + ADD COLUMN alias_digest bytea, + ALTER COLUMN purchase_lineage_id DROP NOT NULL; + +-- Exactly one dispute subject per row. Without this a row could name both a +-- lineage and an alias, and the resolution action would be ambiguous. +ALTER TABLE billing_identity_conflicts + ADD CONSTRAINT billing_identity_conflicts_scope_shape CHECK ( + (conflict_scope = 'lineage' + AND purchase_lineage_id IS NOT NULL + AND alias_type IS NULL AND alias_digest IS NULL) + OR (conflict_scope = 'alias' + AND purchase_lineage_id IS NULL + AND alias_type IS NOT NULL + AND alias_digest IS NOT NULL AND octet_length(alias_digest) = 32) + ); + +-- One open conflict per disputed subject. The lineage index keeps its former +-- meaning and is re-scoped so alias rows, whose lineage is NULL, cannot +-- collide with it. +DROP INDEX billing_identity_conflicts_open_idx; +CREATE UNIQUE INDEX billing_identity_conflicts_open_lineage_idx + ON billing_identity_conflicts(purchase_lineage_id) + WHERE status = 'open' AND conflict_scope = 'lineage'; +CREATE UNIQUE INDEX billing_identity_conflicts_open_alias_idx + ON billing_identity_conflicts(project_id, alias_type, alias_digest) + WHERE status = 'open' AND conflict_scope = 'alias'; + +-- Operator listing is by Project and status; without this the conflicts page +-- scans the table once the first Project accumulates history. +CREATE INDEX billing_identity_conflicts_project_status_idx + ON billing_identity_conflicts(project_id, status, opened_at DESC); + +-- +goose Down +DROP INDEX billing_identity_conflicts_project_status_idx; +DROP INDEX billing_identity_conflicts_open_alias_idx; +DROP INDEX billing_identity_conflicts_open_lineage_idx; +CREATE UNIQUE INDEX billing_identity_conflicts_open_idx + ON billing_identity_conflicts(purchase_lineage_id) + WHERE status = 'open'; +ALTER TABLE billing_identity_conflicts + DROP CONSTRAINT billing_identity_conflicts_scope_shape; +DELETE FROM billing_identity_conflicts WHERE purchase_lineage_id IS NULL; +ALTER TABLE billing_identity_conflicts + ALTER COLUMN purchase_lineage_id SET NOT NULL, + DROP COLUMN alias_digest, + DROP COLUMN alias_type, + DROP COLUMN conflict_scope; diff --git a/apps/api/migrations/00045_billing_quarantine_void_reasons.sql b/apps/api/migrations/00045_billing_quarantine_void_reasons.sql new file mode 100644 index 00000000..f0c27387 --- /dev/null +++ b/apps/api/migrations/00045_billing_quarantine_void_reasons.sql @@ -0,0 +1,65 @@ +-- Phase 9B review correction I-4: a quarantine reason for "this Google voided +-- purchase was recorded as a refund, but Mosaic could not attribute it to a +-- Product". +-- +-- Before this migration the void path had no way to record such a refund at +-- all. A voided-purchase notification carries no SKU; the SKU was recovered +-- from orders.get, and only when the order held exactly one line item. Every +-- other shape — a multi-line-item order, or an orders.get that failed +-- permanently — produced no Transaction Fact, so the refunded purchase kept +-- granting its Entitlement indefinitely. +-- +-- The worker now records a refund fact with resolution_state = 'unresolved', +-- which projects the purchase as `unknown` rather than `owned`, and quarantines +-- the input under this reason. It is deliberately not 'product_unknown' or +-- 'malformed_reference': those say "an input could not be validated", while this +-- one says "revenue was refunded, access has been corrected to unknown, and a +-- Product attribution is still owed". The three have different operator actions +-- and different severities, so they get different codes. +-- +-- The CHECK is recreated rather than widened in place because PostgreSQL has no +-- ALTER ... MODIFY CHECK; dropping and adding inside one migration is atomic +-- (same pattern as 00026 and 00029). + +-- +goose Up +ALTER TABLE billing_quarantine_records + DROP CONSTRAINT billing_quarantine_records_reason_code_check; +ALTER TABLE billing_quarantine_records + ADD CONSTRAINT billing_quarantine_records_reason_code_check CHECK (reason_code IN ( + 'signature_invalid', 'application_mismatch', 'environment_mismatch', + 'store_environment_mismatch', 'credential_unavailable', 'credential_revoked', + 'missing_validation_credential', 'missing_provider_timestamp', + 'product_unknown', 'product_ambiguous', 'cross_environment_mismatch', + 'unsupported_product_type', 'unsupported_transaction_type', + 'void_product_unresolved', + 'malformed_reference', 'input_content_conflict', 'replay_conflict', + 'provider_permanently_failed', 'validation_exhausted' + )); + +-- +goose Down +-- Quarantine rows carrying the new reason must go before the narrower CHECK can +-- return. They are work items rather than ledger evidence: the Raw Billing +-- Input, its validation attempts, its ledger entries, and — importantly — the +-- refund Transaction Fact itself all survive, so nothing that records what the +-- pipeline did or what the provider said is lost (same rationale as 00026 and +-- 00029). The append-only trigger on the action audit is disabled only for the +-- length of the delete. +ALTER TABLE billing_quarantine_actions DISABLE TRIGGER billing_quarantine_actions_append_only; +DELETE FROM billing_quarantine_actions + WHERE quarantine_record_id IN ( + SELECT id FROM billing_quarantine_records WHERE reason_code = 'void_product_unresolved'); +ALTER TABLE billing_quarantine_actions ENABLE TRIGGER billing_quarantine_actions_append_only; +DELETE FROM billing_quarantine_records WHERE reason_code = 'void_product_unresolved'; + +ALTER TABLE billing_quarantine_records + DROP CONSTRAINT billing_quarantine_records_reason_code_check; +ALTER TABLE billing_quarantine_records + ADD CONSTRAINT billing_quarantine_records_reason_code_check CHECK (reason_code IN ( + 'signature_invalid', 'application_mismatch', 'environment_mismatch', + 'store_environment_mismatch', 'credential_unavailable', 'credential_revoked', + 'missing_validation_credential', 'missing_provider_timestamp', + 'product_unknown', 'product_ambiguous', 'cross_environment_mismatch', + 'unsupported_product_type', 'unsupported_transaction_type', + 'malformed_reference', 'input_content_conflict', 'replay_conflict', + 'provider_permanently_failed', 'validation_exhausted' + )); diff --git a/apps/api/migrations/00046_billing_purchase_chain_digest_links.sql b/apps/api/migrations/00046_billing_purchase_chain_digest_links.sql new file mode 100644 index 00000000..dff380be --- /dev/null +++ b/apps/api/migrations/00046_billing_purchase_chain_digest_links.sql @@ -0,0 +1,138 @@ +-- Phase 9B: materialize the purchase-chain digest closure. +-- +-- A Purchase Lineage is keyed on the *root* of a provider chain, but a fact +-- carries its own chain digest — for Google, the digest of the purchase token +-- that was live when the fact was recorded. A plan change hands the token over, +-- so a live subscription accumulates a chain of digests linked backwards by +-- `supersedes_chain_digest`, and only the first of them equals the lineage's +-- `lineage_key_digest`. +-- +-- Every reader that needs "which facts belong to this lineage?" therefore has to +-- walk that chain. The projection loader does it with a recursive CTE, which is +-- correct and affordable because it runs once per projection command. The read +-- surfaces cannot afford it: `ProjectionStatusFor` is evaluated on the SDK sync +-- path, the highest-QPS authenticated surface Mosaic has, and it was joining +-- `lineage_key_digest = purchase_chain_digest` directly. That join silently +-- omits every mid-chain Google successor, so a customer with a plan change +-- appeared to have zero pending facts no matter how far behind the projection +-- actually was — a staleness signal that under-reports exactly the customers +-- most likely to be stale. +-- +-- This table is the closure, maintained by trigger rather than by application +-- code so that every writer maintains it — including migrations, repairs, and +-- the demonstration seeder — and so a future writer cannot forget to. +-- +-- It is derived state: it is rebuildable from the facts alone (the backfill +-- below is the rebuild), so losing it is a re-run, never a data loss. + +-- +goose Up +CREATE TABLE purchase_chain_digest_links ( + project_id text NOT NULL, + environment_id text NOT NULL, + -- A digest that appears on some fact in the chain. + chain_digest bytea NOT NULL CHECK (octet_length(chain_digest) = 32), + -- The root of that chain: the digest a Purchase Lineage is keyed on. + root_digest bytea NOT NULL CHECK (octet_length(root_digest) = 32), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project_id, environment_id, chain_digest) +); +CREATE INDEX purchase_chain_digest_links_root_idx + ON purchase_chain_digest_links(project_id, environment_id, root_digest); + +-- +goose StatementBegin +CREATE FUNCTION maintain_purchase_chain_digest_link() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + resolved_root bytea; +BEGIN + IF NEW.purchase_chain_digest IS NULL THEN + RETURN NULL; + END IF; + + -- A fact that supersedes nothing is its own root. A fact that supersedes + -- something inherits that predecessor's root when it is already known, and + -- otherwise treats the predecessor as the root — which is correct until the + -- predecessor's own predecessor arrives, and is repaired below when it does. + IF NEW.supersedes_chain_digest IS NULL THEN + resolved_root := NEW.purchase_chain_digest; + ELSE + SELECT l.root_digest INTO resolved_root + FROM purchase_chain_digest_links l + WHERE l.project_id = NEW.project_id + AND l.environment_id = NEW.environment_id + AND l.chain_digest = NEW.supersedes_chain_digest; + IF resolved_root IS NULL THEN + resolved_root := NEW.supersedes_chain_digest; + INSERT INTO purchase_chain_digest_links( + project_id, environment_id, chain_digest, root_digest, updated_at) + VALUES (NEW.project_id, NEW.environment_id, resolved_root, resolved_root, now()) + ON CONFLICT (project_id, environment_id, chain_digest) DO NOTHING; + END IF; + END IF; + + INSERT INTO purchase_chain_digest_links( + project_id, environment_id, chain_digest, root_digest, updated_at) + VALUES (NEW.project_id, NEW.environment_id, NEW.purchase_chain_digest, resolved_root, now()) + ON CONFLICT (project_id, environment_id, chain_digest) + DO UPDATE SET root_digest = EXCLUDED.root_digest, updated_at = EXCLUDED.updated_at + WHERE purchase_chain_digest_links.root_digest <> EXCLUDED.root_digest; + + -- Providers do not guarantee notification ordering, so a successor's fact + -- can be recorded before its predecessor's. When the predecessor finally + -- arrives it re-roots the chain, and every descendant that had provisionally + -- adopted this fact's digest as its root has to follow. + IF resolved_root <> NEW.purchase_chain_digest THEN + UPDATE purchase_chain_digest_links + SET root_digest = resolved_root, updated_at = now() + WHERE project_id = NEW.project_id + AND environment_id = NEW.environment_id + AND root_digest = NEW.purchase_chain_digest; + END IF; + + RETURN NULL; +END; +$$; +-- +goose StatementEnd + +CREATE TRIGGER billing_transaction_facts_maintain_chain_links +AFTER INSERT ON billing_transaction_facts +FOR EACH ROW EXECUTE FUNCTION maintain_purchase_chain_digest_link(); + +-- Backfill: the closure over every fact that already exists. UNION rather than +-- UNION ALL terminates on a cycle; provider data cannot contain one, so reaching +-- a repeat means the data is already wrong and stopping is safer than looping. +-- +goose StatementBegin +WITH RECURSIVE roots(project_id, environment_id, root_digest, chain_digest) AS ( + SELECT f.project_id, f.environment_id, f.purchase_chain_digest, f.purchase_chain_digest + FROM billing_transaction_facts f + WHERE f.purchase_chain_digest IS NOT NULL + AND f.supersedes_chain_digest IS NULL + UNION + SELECT r.project_id, r.environment_id, r.root_digest, f.purchase_chain_digest + FROM billing_transaction_facts f + JOIN roots r + ON f.supersedes_chain_digest = r.chain_digest + AND f.project_id = r.project_id + AND f.environment_id = r.environment_id + WHERE f.purchase_chain_digest IS NOT NULL +) +INSERT INTO purchase_chain_digest_links(project_id, environment_id, chain_digest, root_digest, updated_at) +SELECT DISTINCT ON (project_id, environment_id, chain_digest) + project_id, environment_id, chain_digest, root_digest, now() +FROM roots +ORDER BY project_id, environment_id, chain_digest, root_digest +ON CONFLICT (project_id, environment_id, chain_digest) DO NOTHING; +-- +goose StatementEnd + +-- Facts whose predecessor was never recorded (a Google chain whose earlier +-- token predates Mosaic ingestion) are their own root. Without this they would +-- have no link row at all and would disappear from every count. +INSERT INTO purchase_chain_digest_links(project_id, environment_id, chain_digest, root_digest, updated_at) +SELECT DISTINCT f.project_id, f.environment_id, f.purchase_chain_digest, f.purchase_chain_digest, now() +FROM billing_transaction_facts f +WHERE f.purchase_chain_digest IS NOT NULL +ON CONFLICT (project_id, environment_id, chain_digest) DO NOTHING; + +-- +goose Down +DROP TRIGGER billing_transaction_facts_maintain_chain_links ON billing_transaction_facts; +DROP FUNCTION maintain_purchase_chain_digest_link(); +DROP TABLE purchase_chain_digest_links; diff --git a/apps/api/migrations/00047_grant_version_closure.sql b/apps/api/migrations/00047_grant_version_closure.sql new file mode 100644 index 00000000..875691c6 --- /dev/null +++ b/apps/api/migrations/00047_grant_version_closure.sql @@ -0,0 +1,96 @@ +-- Phase 9B WP9: make the OD-8 "prospective + replacement" grant policy +-- expressible. +-- +-- 00033 gave `product_entitlement_grant_versions` a blanket append-only trigger +-- and a partial unique index permitting exactly one open-ended (current) +-- version per (product, entitlement). Together those two make replacement +-- impossible: publishing a new version requires closing the current one, the +-- unique index refuses a second open-ended row, and the trigger refuses the +-- UPDATE that would close the first. The management surface the dashboard needs +-- could not be built against that schema, which is why nothing has ever written +-- these rows at runtime. +-- +-- The fix is not to relax immutability but to name it precisely. A grant +-- version's *meaning* — its policy columns, its start, its supported purchase +-- types, its author and reason — stays immutable. The only permitted change is +-- the one operation that is not a rewrite of meaning: closing an open interval +-- by setting `effective_end` once, from NULL, to a later instant. Everything +-- else, including reopening a closed interval, re-closing one at a different +-- time, and DELETE, is still refused by the database rather than by the +-- application. +-- +-- Stating it as a trigger rather than a column-level grant matters: a +-- column-level restriction would still let one UPDATE close a version at an +-- instant that rewrites which version a historical purchase selects, and grant +-- selection by period effective time is the whole reason versions exist. + +-- +goose Up + +DROP TRIGGER product_entitlement_grant_versions_append_only + ON product_entitlement_grant_versions; + +-- +goose StatementBegin +CREATE FUNCTION reject_grant_version_rewrite() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'product_entitlement_grant_versions is append-only; publish a superseding version' + USING ERRCODE = '55000'; + END IF; + + -- A closed interval has already been superseded. Re-closing it at a + -- different instant silently moves the boundary between two versions, and + -- every purchase between the old and new boundary changes which grant it + -- selects — retroactive access change with no version, no audit, and no + -- additive-superset check. + IF OLD.effective_end IS NOT NULL THEN + RAISE EXCEPTION 'grant version % is already closed and cannot be reopened or re-closed', OLD.id + USING ERRCODE = '55000'; + END IF; + IF NEW.effective_end IS NULL THEN + RAISE EXCEPTION 'the only permitted update to a grant version is closing it' + USING ERRCODE = '55000'; + END IF; + + -- Every other column must be byte-identical. Closing a version is a + -- statement about when it stopped applying, never about what it meant. + IF (NEW.id, NEW.project_id, NEW.product_id, NEW.entitlement_id, NEW.version, + NEW.grant_policy_version, NEW.effective_start, NEW.supported_purchase_types, + NEW.grants_in_active, NEW.grants_in_trial, NEW.grants_in_grace, + NEW.grants_in_billing_retry, NEW.grants_in_paused, + NEW.grants_in_one_time_ownership, NEW.created_at, NEW.created_by_actor_id, + NEW.reason) + IS DISTINCT FROM + (OLD.id, OLD.project_id, OLD.product_id, OLD.entitlement_id, OLD.version, + OLD.grant_policy_version, OLD.effective_start, OLD.supported_purchase_types, + OLD.grants_in_active, OLD.grants_in_trial, OLD.grants_in_grace, + OLD.grants_in_billing_retry, OLD.grants_in_paused, + OLD.grants_in_one_time_ownership, OLD.created_at, OLD.created_by_actor_id, + OLD.reason) + THEN + RAISE EXCEPTION 'grant version % is immutable apart from its closing instant', OLD.id + USING ERRCODE = '55000'; + END IF; + + RETURN NEW; +END; +$$; +-- +goose StatementEnd + +CREATE TRIGGER product_entitlement_grant_versions_append_only +BEFORE UPDATE OR DELETE ON product_entitlement_grant_versions +FOR EACH ROW EXECUTE FUNCTION reject_grant_version_rewrite(); + +-- The publish path writes an audit event under the pair's advisory lock; this +-- index is what makes "show me this pair's history" a lookup rather than a scan +-- once a catalog has accumulated versions. +CREATE INDEX IF NOT EXISTS product_entitlement_grant_versions_history_idx + ON product_entitlement_grant_versions(project_id, product_id, entitlement_id, version DESC); + +-- +goose Down +DROP INDEX IF EXISTS product_entitlement_grant_versions_history_idx; +DROP TRIGGER product_entitlement_grant_versions_append_only + ON product_entitlement_grant_versions; +DROP FUNCTION reject_grant_version_rewrite(); +CREATE TRIGGER product_entitlement_grant_versions_append_only +BEFORE UPDATE OR DELETE ON product_entitlement_grant_versions +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); diff --git a/apps/api/migrations/00048_phase_9b_operator_lookup_indexes.sql b/apps/api/migrations/00048_phase_9b_operator_lookup_indexes.sql new file mode 100644 index 00000000..2c410eab --- /dev/null +++ b/apps/api/migrations/00048_phase_9b_operator_lookup_indexes.sql @@ -0,0 +1,38 @@ +-- Phase 9B: indexes for the operator (dashboard) surface. +-- +-- No table, column, or constraint changes. Every index here exists because a +-- query the operator surface runs on every page load would otherwise be a +-- sequential scan on a table that grows with a Project's whole billing history. +-- +-- 1. Installation lookup. The operator customer search resolves an +-- installation identifier through association evidence, because an +-- installation id is evidence and never an anchor (plan §5a rule 2a), so +-- there is no alias resolution to read. Migration 00030 indexed evidence by +-- transaction reference and by customer, but not by the correlator digest, +-- which is what a lookup matches on. Partial on the evidence type so the +-- index covers only the rows the lookup can match. +-- +-- 2. Restore job listing. 00037 indexed the queue's lease path (available_at, +-- status); the operator list orders by requested_at within one Environment, +-- which shares no prefix with it. +-- +-- 3. Entitlement pointer by Environment. The primary key is +-- (billing_customer_id, environment_id), so no index leads with +-- environment_id — and both the customer list's LEFT JOIN and the projection +-- health "stale pointers" count read exactly that way. + +-- +goose Up +CREATE INDEX billing_association_evidence_installation_digest_idx + ON billing_association_evidence(project_id, evidence_digest, observed_at DESC) + WHERE evidence_type = 'installation_observation' AND evidence_digest IS NOT NULL; + +CREATE INDEX restore_sync_jobs_environment_requested_idx + ON restore_sync_jobs(environment_id, requested_at DESC, id); + +CREATE INDEX customer_entitlement_pointers_environment_idx + ON customer_entitlement_pointers(environment_id, updated_at DESC); + +-- +goose Down +DROP INDEX customer_entitlement_pointers_environment_idx; +DROP INDEX restore_sync_jobs_environment_requested_idx; +DROP INDEX billing_association_evidence_installation_digest_idx; diff --git a/apps/api/migrations/00049_phase_9b_purchase_anchor_evidence.sql b/apps/api/migrations/00049_phase_9b_purchase_anchor_evidence.sql new file mode 100644 index 00000000..6ff2da9f --- /dev/null +++ b/apps/api/migrations/00049_phase_9b_purchase_anchor_evidence.sql @@ -0,0 +1,59 @@ +-- Phase 9B: purchase-anchored association evidence (plan §5a rule 1, OD-4(a)). +-- +-- A validated purchase fact that no evidence resolves must still have somewhere +-- to attach: plan §5a rule 1 makes "a validated purchase fact needs somewhere to +-- attach" one of exactly two ways a Billing Customer comes into existence, and +-- §5a rule 2 anchors that customer to the purchase lineage rather than to the +-- device. This is the evidence type that records the decision. +-- +-- It is deliberately its own vocabulary entry rather than being folded into +-- prior_lineage_association. The two say different things: a prior association +-- is evidence *found*, while this records that none was found and a customer was +-- created to hold the purchase. An operator looking at a +-- "purchase-anchored, not yet identified" customer in the dashboard needs to be +-- able to tell which of those happened, and a support investigation months later +-- needs it more. +-- +-- It carries no correlator digest, because there is no correlator: the whole +-- meaning of the row is the absence of one. The resolver never selects a +-- customer from it — the seam records it *after* creating the customer — so it +-- can never become a route by which a guessable value reaches someone else's +-- entitlements. + +-- +goose Up +ALTER TABLE billing_association_evidence + DROP CONSTRAINT billing_association_evidence_evidence_type_check; +ALTER TABLE billing_association_evidence + ADD CONSTRAINT billing_association_evidence_evidence_type_check + CHECK (evidence_type IN ( + 'app_account_token', 'obfuscated_external_account_id', + 'trusted_server_observation', 'prior_lineage_association', + 'restore_link', 'operator_repair', 'installation_observation', + 'purchase_anchor' + )); + +-- +goose Down +-- Rows of the new type have to go before the narrower constraint can be +-- restored, and the table is append-only, so the trigger is suspended for +-- exactly this statement. Losing them is the honest consequence of rolling back +-- past the migration that made them expressible: the customers they explain +-- still exist and still hold their lineages, but the recorded reason they were +-- created does not survive the downgrade. +-- +-- The comment above described the suspension; the statement that performed it +-- was missing, so this down path failed against the append-only trigger and the +-- migration was not reversible at all. The wrapper mirrors 00042's. +ALTER TABLE billing_association_evidence + DISABLE TRIGGER billing_association_evidence_append_only; +DELETE FROM billing_association_evidence WHERE evidence_type = 'purchase_anchor'; +ALTER TABLE billing_association_evidence + ENABLE TRIGGER billing_association_evidence_append_only; +ALTER TABLE billing_association_evidence + DROP CONSTRAINT billing_association_evidence_evidence_type_check; +ALTER TABLE billing_association_evidence + ADD CONSTRAINT billing_association_evidence_evidence_type_check + CHECK (evidence_type IN ( + 'app_account_token', 'obfuscated_external_account_id', + 'trusted_server_observation', 'prior_lineage_association', + 'restore_link', 'operator_repair', 'installation_observation' + )); diff --git a/apps/api/migrations/00050_phase_9b_token_bound_submission_and_adoption.sql b/apps/api/migrations/00050_phase_9b_token_bound_submission_and_adoption.sql new file mode 100644 index 00000000..f1db30d1 --- /dev/null +++ b/apps/api/migrations/00050_phase_9b_token_bound_submission_and_adoption.sql @@ -0,0 +1,82 @@ +-- Phase 9B Stage 5 fix round 1: separate the token-bound public-SDK-key +-- submission from the trusted-server submission, and record anchored-customer +-- adoption (plan §5a rule 3). +-- +-- Two vocabulary additions and one status addition, all for the same defect. +-- +-- `token_bound_submission` exists because a submission that arrived on the +-- *public* SDK key carrying a Customer Access Token was being recorded as +-- `trusted_server_observation`, which is the highest non-operator authority in +-- the resolver. The public SDK key ships inside every install, so that recorded +-- a claim anybody could make at the authority of the application's own backend: +-- presenting a token for a transaction could take an established purchase away +-- from the customer that owned it, or freeze the lineage in an identity +-- conflict so that neither party was granted anything. Both were remote +-- denial-of-access primitives against a paying customer. Rank 90 now belongs +-- only to a submission authenticated by the secret server key; the new type +-- ranks below `prior_lineage_association`, so it can attach an unattached +-- lineage and can never move or freeze an attached one. +-- +-- `anchored_customer_adoption` records plan §5a rule 3's adoption: an +-- identified customer taking over the lineage of a purchase-anchored customer +-- that has no aliases and no identifying evidence of its own. It is distinct +-- from `restore_link` because it is not a restore — nothing asked a store for +-- purchases — and distinct from `operator_repair` because no operator was +-- involved. Its diagnostic code carries the ownership proof that permitted it. +-- +-- The `absorbed` customer status marks the anchor afterwards. The row is not +-- deleted: entitlement snapshots, evidence, and audit events already cite it, +-- and an investigation has to be able to follow a purchase from the anchor to +-- the person who turned out to own it. + +-- +goose Up +ALTER TABLE billing_association_evidence + DROP CONSTRAINT billing_association_evidence_evidence_type_check; +ALTER TABLE billing_association_evidence + ADD CONSTRAINT billing_association_evidence_evidence_type_check + CHECK (evidence_type IN ( + 'app_account_token', 'obfuscated_external_account_id', + 'trusted_server_observation', 'prior_lineage_association', + 'restore_link', 'operator_repair', 'installation_observation', + 'purchase_anchor', 'token_bound_submission', 'anchored_customer_adoption' + )); + +ALTER TABLE billing_customers + DROP CONSTRAINT billing_customers_status_check; +ALTER TABLE billing_customers + ADD CONSTRAINT billing_customers_status_check + CHECK (status IN ('active', 'frozen', 'anonymized', 'absorbed')); + +-- +goose Down +-- An absorbed customer becomes active again. That is the honest reversal: the +-- lineage the adoption moved is not moved back — evidence is append-only and +-- the adopting customer's committed snapshot already grants the purchase — so +-- the anchor returns to the only other status that does not assert something +-- false about it. +UPDATE billing_customers SET status = 'active' WHERE status = 'absorbed'; +ALTER TABLE billing_customers + DROP CONSTRAINT billing_customers_status_check; +ALTER TABLE billing_customers + ADD CONSTRAINT billing_customers_status_check + CHECK (status IN ('active', 'frozen', 'anonymized')); + +-- Rows of the two new types have to go before the narrower constraint can be +-- restored, and the table is append-only, so the trigger is suspended for +-- exactly those statements, as 00042 and 00049 do. +ALTER TABLE billing_association_evidence + DISABLE TRIGGER billing_association_evidence_append_only; +DELETE FROM billing_association_evidence + WHERE evidence_type IN ('token_bound_submission', 'anchored_customer_adoption'); +ALTER TABLE billing_association_evidence + ENABLE TRIGGER billing_association_evidence_append_only; + +ALTER TABLE billing_association_evidence + DROP CONSTRAINT billing_association_evidence_evidence_type_check; +ALTER TABLE billing_association_evidence + ADD CONSTRAINT billing_association_evidence_evidence_type_check + CHECK (evidence_type IN ( + 'app_account_token', 'obfuscated_external_account_id', + 'trusted_server_observation', 'prior_lineage_association', + 'restore_link', 'operator_repair', 'installation_observation', + 'purchase_anchor' + )); diff --git a/apps/dashboard/src/features/billing-customers/components/billing-customers-page.tsx b/apps/dashboard/src/features/billing-customers/components/billing-customers-page.tsx new file mode 100644 index 00000000..1971d07e --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/billing-customers-page.tsx @@ -0,0 +1,267 @@ +import { useMutation, useQuery } from "@tanstack/react-query" +import { useState } from "react" + +import { EmptyState } from "@/components/feedback/empty-state" +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 { LedgerPaging, StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { pagedListHeading } from "@/features/billing-ledger/types/billing-list-headings" +import { BILLING_OPTIONAL_NOTE } from "@/features/billing-ledger/types/billing-vocabulary" +import { billingHealthQueryOptions } from "@/features/billing-operations/queries/billing-health-queries" +import { CustomerSearchForm } from "@/features/billing-customers/components/customer-search-form" +import { lookupBillingCustomerMutationOptions } from "@/features/billing-customers/mutations/customer-mutations" +import { billingCustomersQueryOptions } from "@/features/billing-customers/queries/customer-queries" +import { + describeLookupMiss, + type CustomerIdentifierType, +} from "@/features/billing-customers/types/customer-search" +import { + AUTHORITATIVE_ACCESS_NOTE, + customerIdentityExplanation, + customerIdentityLabel, + customerStatusLabel, + customerStatusTone, + formatEntitlementInstant, +} from "@/features/billing-customers/types/entitlement-vocabulary" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkflowPanel, WorkspacePage } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { billingCustomerHref, storeConnectionsHref } from "@/lib/routing/workspace-hrefs" +import type { BillingCustomerSummary } from "@/generated/api" + +interface BillingCustomersPageProps { + conflictedOnly?: boolean + cursor?: string + environmentId: string + onFiltersChange: (filters: { conflictedOnly?: boolean; cursor?: string }) => void + /** + * Opens one customer after a successful typed lookup. The route owns it so + * the transition is a router navigation rather than a full document load: a + * `window.location.assign` here discarded the loaded router, the query cache, + * and the session state the rest of the workspace depends on, and turned a + * lookup into a page reload. + */ + onCustomerFound: (customerId: string) => void + organizationId: string + projectId: string +} + +/** + * Billing Customers in one Mosaic Environment. + * + * The list exists to be browsed by an operator who already has an identifier, + * not to be searched by attributes of a person: Mosaic Billing stores aliases + * as digests, so there is nothing to search by and the lookup above is typed. + */ +export function BillingCustomersPage({ + conflictedOnly, + cursor, + environmentId, + onCustomerFound, + onFiltersChange, + organizationId, + projectId, +}: BillingCustomersPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const health = useQuery({ + ...billingHealthQueryOptions(projectId, environmentId), + enabled: scopeReady, + }) + const customers = useQuery({ + ...billingCustomersQueryOptions(projectId, environmentId, { + ...(conflictedOnly ? { conflictedOnly: true } : {}), + ...(cursor ? { cursor } : {}), + }), + enabled: scopeReady, + }) + const lookup = useMutation(lookupBillingCustomerMutationOptions(projectId, environmentId)) + const [missMessage, setMissMessage] = useState(undefined) + + const environmentName = + environments.data?.items.find((item) => item.id === environmentId)?.name ?? environmentId + const billingEnabled = health.data?.billingEnabled !== false + const items = customers.data?.items ?? [] + + const error = project.error ?? environments.error ?? customers.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: project.isPending || (scopeReady && (environments.isPending || customers.isPending)), + loadingDescription: `Loading Billing Customers for the ${environmentName} Mosaic Environment.`, + onRetry: () => { + void customers.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read Billing Customers.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const scope = { environmentId, organizationId, projectId } + const connectionsHref = storeConnectionsHref(scope) ?? "#" + + async function search(input: { + identifierType: CustomerIdentifierType + identifierValue: string + }) { + setMissMessage(undefined) + const result = await lookup.mutateAsync(input) + if (result?.found && result.customer?.billingCustomerId) { + onCustomerFound(result.customer.billingCustomerId) + return + } + // A miss is an answer. It renders as a result, never as a failure banner. + setMissMessage(describeLookupMiss(input.identifierType)) + } + + return ( + +

{AUTHORITATIVE_ACCESS_NOTE}

+ + + + {missMessage ? ( +
+ +
+ ) : null} + {lookup.error ? ( +

+ {lookup.error.message} +

+ ) : null} +
+ + + {!billingEnabled ? ( + + Set up Mosaic Billing + + } + description={`Mosaic Billing is turned off for this Project, so no purchase is recorded and no access is computed. Every Entitlement read answers "Mosaic cannot answer" rather than inactive. ${BILLING_OPTIONAL_NOTE}`} + title="Mosaic Billing is not enabled for this Project" + /> + ) : items.length === 0 ? ( + <> + + + onFiltersChange({ ...(conflictedOnly ? { conflictedOnly } : {}), cursor: next }) + } + /> + + ) : ( + + + +
    + {items.map((customer) => ( + + ))} +
+ + + onFiltersChange({ ...(conflictedOnly ? { conflictedOnly } : {}), cursor: next }) + } + /> +
+ )} +
+
+ ) +} + +function CustomerRow({ customer, href }: { customer: BillingCustomerSummary; href: string }) { + return ( +
  • +
    + + {customer.billingCustomerId} + +
    + + + {/* The attention pill an operator scans for. A conflicted customer is + frozen, so anything else on the row is the last committed state + rather than the current one. */} + {customer.hasOpenIdentityConflict ? ( + + ) : null} +
    +
    +

    + {customerIdentityExplanation(customer)} +

    +

    + {customer.snapshotVersion === undefined + ? "Never projected in this Environment — not the same as having no entitlements." + : `Snapshot version ${customer.snapshotVersion}`}{" "} + · last projected {formatEntitlementInstant(customer.lastProjectedAt)} +

    +
  • + ) +} diff --git a/apps/dashboard/src/features/billing-customers/components/conflict-resolution-form.test.tsx b/apps/dashboard/src/features/billing-customers/components/conflict-resolution-form.test.tsx new file mode 100644 index 00000000..53cec521 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/conflict-resolution-form.test.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { ConflictResolutionForm } from "@/features/billing-customers/components/conflict-resolution-form" +import type { ResolveIdentityConflictRequest } from "@/generated/api" + +type ResolveFn = (request: ResolveIdentityConflictRequest) => Promise + +function renderForm(onResolve: ResolveFn = async () => undefined) { + const spy = vi.fn(onResolve) + render( + , + ) + return spy +} + +const submitName = /Record this resolution/ + +/** + * The wiring the pure gate cannot cover: that the form actually consumes it, + * and that there is no path through the markup that submits a resolution on a + * single click. + */ +describe("conflict resolution form", () => { + it("offers no single-action merge and no preselected resolution", () => { + renderForm() + + // Nothing is chosen for the operator. A default selection is a + // recommendation, and Mosaic has deliberately not chosen a winner. + for (const label of [ + "Keep the existing customer", + "Reassign to the candidate", + "Split — neither claim wins", + ]) { + expect(screen.getByRole("radio", { name: label })).not.toBeChecked() + } + expect(screen.queryByRole("button", { name: /merge/i })).not.toBeInTheDocument() + expect(screen.getByRole("button", { name: submitName })).toBeDisabled() + }) + + it("keeps submission disabled until the reason and the acknowledgement are both given", async () => { + const resolve = renderForm() + + fireEvent.click(screen.getByRole("radio", { name: "Reassign to the candidate" })) + // The consequence for BOTH parties appears as soon as a choice is made. + expect(screen.getByText(/lose the access it grants/)).toBeInTheDocument() + expect(screen.getByRole("button", { name: submitName })).toBeDisabled() + + fireEvent.change(screen.getByLabelText(/Reason for this resolution/), { + target: { value: "Ticket 4821 confirmed the challenger owns the store account." }, + }) + expect(screen.getByRole("button", { name: submitName })).toBeDisabled() + + fireEvent.click(screen.getByRole("checkbox")) + await waitFor(() => expect(screen.getByRole("button", { name: submitName })).not.toBeDisabled()) + + fireEvent.click(screen.getByRole("button", { name: submitName })) + await waitFor(() => expect(resolve).toHaveBeenCalledTimes(1)) + expect(resolve.mock.calls[0]?.[0]).toMatchObject({ + action: "reassign_to_candidate", + assignedBillingCustomerId: "cus_challenger", + }) + }) + + it("withdraws the acknowledgement when the chosen resolution changes", async () => { + renderForm() + + fireEvent.click(screen.getByRole("radio", { name: "Reassign to the candidate" })) + fireEvent.change(screen.getByLabelText(/Reason for this resolution/), { + target: { value: "Ticket 4821." }, + }) + fireEvent.click(screen.getByRole("checkbox")) + await waitFor(() => expect(screen.getByRole("button", { name: submitName })).not.toBeDisabled()) + + // The acknowledgement was given for a different consequence than the one + // now selected, so it cannot carry over. + fireEvent.click(screen.getByRole("radio", { name: "Split — neither claim wins" })) + expect(screen.getByRole("checkbox")).not.toBeChecked() + expect(screen.getByRole("button", { name: submitName })).toBeDisabled() + }) + + it("routes an operator without permission to the members page instead of the form", () => { + render( + undefined} + secondCustomerId="cus_challenger" + />, + ) + + expect(screen.queryByRole("button", { name: submitName })).not.toBeInTheDocument() + expect( + screen.getByRole("link", { name: "Ask an Owner or Admin to resolve this conflict" }), + ).toHaveAttribute("href", "/organizations/org_01/members") + }) +}) diff --git a/apps/dashboard/src/features/billing-customers/components/conflict-resolution-form.tsx b/apps/dashboard/src/features/billing-customers/components/conflict-resolution-form.tsx new file mode 100644 index 00000000..9d72c066 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/conflict-resolution-form.tsx @@ -0,0 +1,188 @@ +import { useState } from "react" + +import { Button } from "@/components/ui/button" +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { + conflictActionConsequence, + conflictActionLabel, + conflictActions, + evaluateResolutionGate, + expectedAssignee, + type ConflictAction, +} from "@/features/billing-customers/types/conflict-resolution" +import type { ResolveIdentityConflictRequest } from "@/generated/api" + +interface ConflictResolutionFormProps { + canManage: boolean + firstCustomerId: string | undefined + membersHref: string + onResolve: (request: ResolveIdentityConflictRequest) => Promise + secondCustomerId: string | undefined +} + +/** + * Resolving an identity conflict, deliberately in four steps. + * + * There is no one-click merge here and there is no default selection, because + * this is the operation that moves real purchases between real people and it + * cannot be undone by re-running it. The sequence forces the operator past the + * consequence of the *specific* choice they made — including what happens to + * the party they are not looking at — before anything is submittable. + * + * Modelled on the 9A quarantine recovery actions, with one addition: a reason + * is required by the API, and it is what an investigation reads when someone + * asks why their purchase moved. + */ +export function ConflictResolutionForm({ + canManage, + firstCustomerId, + membersHref, + onResolve, + secondCustomerId, +}: ConflictResolutionFormProps) { + const [action, setAction] = useState(undefined) + const [reason, setReason] = useState("") + const [acknowledged, setAcknowledged] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const gate = evaluateResolutionGate({ + acknowledged, + action, + canManage, + firstCustomerId, + isSubmitting: submitting, + reason, + secondCustomerId, + }) + + if (!canManage) { + return ( +
    +

    + Resolving an identity conflict requires organization owner or admin permission. +

    + + Ask an Owner or Admin to resolve this conflict + +
    + ) + } + + async function submit() { + if (!action) return + setSubmitting(true) + setError(null) + try { + const assignee = expectedAssignee({ action, firstCustomerId, secondCustomerId }) + await onResolve({ + action, + reason: reason.trim(), + ...(assignee ? { assignedBillingCustomerId: assignee } : {}), + }) + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Mosaic could not record this resolution.") + } finally { + setSubmitting(false) + } + } + + return ( +
    { + event.preventDefault() + void submit() + }} + > +
    + 1. Choose how this resolves + {conflictActions.map((candidate) => ( + + ))} +
    + + {action ? ( +
    +

    2. What this does

    +

    {conflictActionConsequence(action)}

    + {expectedAssignee({ action, firstCustomerId, secondCustomerId }) ? ( +

    + The disputed purchase will be assigned to{" "} + {expectedAssignee({ action, firstCustomerId, secondCustomerId })}. +

    + ) : ( +

    + The disputed purchase will be assigned to neither customer. +

    + )} +
    + ) : null} + + + 3. Reason for this resolution + { + const value = event.currentTarget.value + setReason(value) + }} + value={reason} + /> + + Required. Recorded on the conflict and on the audit event, and read by whoever + investigates this later. + + + + + + {error ? ( +

    + {error} +

    + ) : null} + +
    + + {gate.explanation ? ( +

    {gate.explanation}

    + ) : ( + + )} +
    +
    + ) +} diff --git a/apps/dashboard/src/features/billing-customers/components/customer-detail-page.tsx b/apps/dashboard/src/features/billing-customers/components/customer-detail-page.tsx new file mode 100644 index 00000000..6fef2669 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/customer-detail-page.tsx @@ -0,0 +1,595 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useState } from "react" + +import { Button } from "@/components/ui/button" +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" +import { + DefinitionRow, + EnvironmentBadges, + LedgerPaging, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome" +import { + cappedListHeading, + pagedListHeading, +} from "@/features/billing-ledger/types/billing-list-headings" +import { providerLabel } from "@/features/billing-ledger/types/billing-vocabulary" +import { EntitlementExplanationPanel } from "@/features/billing-customers/components/entitlement-explanation-panel" +import { requestCustomerSyncMutationOptions } from "@/features/billing-customers/mutations/customer-mutations" +import { + billingCustomerQueryOptions, + customerEntitlementSnapshotQueryOptions, + customerSubscriptionsQueryOptions, + projectionRefetchInterval, +} from "@/features/billing-customers/queries/customer-queries" +import { + aliasTypeLabel, + aliasTypeNote, + AUTHORITATIVE_ACCESS_NOTE, + AUTHORITATIVE_TIMESTAMP_NOTE, + customerDiagnosticsLabel, + customerIdentityExplanation, + customerIdentityLabel, + customerStatusLabel, + customerStatusTone, + formatEntitlementInstant, + lineageDiagnosticLabel, + oneTimeValidityLabel, + oneTimeValidityTone, + PROJECTION_FROZEN_NOTE, + projectionStatusExplanation, + projectionStatusLabel, + projectionStatusTone, + sourceAuthorityLabel, + verificationStatusLabel, +} from "@/features/billing-customers/types/entitlement-vocabulary" +import { SubscriptionStateAxes } from "@/features/billing-customers/components/subscription-state-axes" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { + ScopeBadge, + WorkflowPanel, + WorkspacePage, +} from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { useOrganizationAccess } from "@/hooks/use-organization-access" +import { + billingCustomersHref, + billingIdentityConflictHref, + billingRestoresHref, + billingSubscriptionHref, + catalogProductHref, + type WorkspaceScope, +} from "@/lib/routing/workspace-hrefs" +import type { BillingSubscriptionSnapshot } from "@/generated/api" + +interface CustomerDetailPageProps { + customerId: string + environmentId: string + organizationId: string + projectId: string +} + +/** + * One Billing Customer's authoritative state. + * + * The header carries both scopes because they genuinely differ: identity is + * Project-wide, everything computed about access is Environment-scoped. An + * operator who assumes one scope for both will conclude a customer has no + * access when they are simply looking at the wrong Environment. + */ +export function CustomerDetailPage({ + customerId, + environmentId, + organizationId, + projectId, +}: CustomerDetailPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const access = useOrganizationAccess(organizationId) + const queryClient = useQueryClient() + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const sync = useMutation( + requestCustomerSyncMutationOptions(projectId, environmentId, customerId, queryClient), + ) + // A queued recomputation is the one moment the page is expected to change + // without the operator doing anything, so both authoritative reads poll until + // the projection reports `current` and then stop. + const recomputeQueued = sync.isSuccess + const detail = useQuery({ + ...billingCustomerQueryOptions(projectId, environmentId, customerId, recomputeQueued), + enabled: scopeReady, + }) + const snapshot = useQuery({ + ...customerEntitlementSnapshotQueryOptions( + projectId, + environmentId, + customerId, + recomputeQueued, + ), + enabled: scopeReady, + }) + + const environmentName = + environments.data?.items.find((item) => item.id === environmentId)?.name ?? environmentId + const customer = detail.data?.customer + const projectionStatus = snapshot.data?.projectionStatus ?? detail.data?.projectionStatus + const currentSnapshot = snapshot.data?.snapshot ?? detail.data?.currentSnapshot + + const error = project.error ?? environments.error ?? detail.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: project.isPending || (scopeReady && (environments.isPending || detail.isPending)), + loadingDescription: "Loading this Billing Customer's authoritative state.", + onRetry: () => { + void detail.refetch() + void snapshot.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read a Billing Customer.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const scope = { environmentId, organizationId, projectId } + + return ( + + + Back to Customers + + +

    {AUTHORITATIVE_ACCESS_NOTE}

    + + + +
    + + + {customer?.hasOpenIdentityConflict ? ( + + ) : null} + {customer?.diagnosticsStatus && customer.diagnosticsStatus !== "none" ? ( + + ) : null} +
    + +

    {customerIdentityExplanation(customer ?? {})}

    + +
    + + Identity scope: + Project-wide + + + Access scope: + {environmentName} + +
    + + {/* As of and last projected are two clocks, exactly as occurred-at and + recorded-at are on the ledger. Merging them would hide a projection + that ran recently but reasoned about a stale instant. */} +
    + + + + +
    +

    + {AUTHORITATIVE_TIMESTAMP_NOTE} +

    +
    + + + +

    + {projectionStatusExplanation(projectionStatus?.state)} +

    + {projectionStatus?.pendingFactCount ? ( +

    + {projectionStatus.pendingFactCount} fact(s) recorded but not yet projected. Until they + are, entries derived from them read undetermined rather than inactive. +

    + ) : null} + {projectionRefetchInterval(projectionStatus?.state, recomputeQueued) ? ( +

    + Watching for the projection to commit. This page re-reads every few seconds and stops + on its own once the projection reports current. +

    + ) : null} + + {access.canManage ? ( +
    + {/* Deliberately not called "restore". No operator action can make + a store replay a person's purchases; this recomputes access + from facts Mosaic already holds. */} + +

    + Queues a recomputation of this customer’s committed access from the facts + Mosaic already holds. It is not a device restore and cannot pull purchases from a + store — for that, see{" "} + + restores + + . Requests coalesce, so clicking twice produces one projection. +

    + {sync.isSuccess ? ( +

    + Queued. The snapshot version moves once the projection commits; a projection that + changes nothing does not advance it. +

    + ) : null} + {sync.error ? ( +

    + {sync.error.message} +

    + ) : null} +
    + ) : null} +
    + + {(detail.data?.identityConflicts ?? []).length > 0 ? ( + +
      + {(detail.data?.identityConflicts ?? []).map((conflict) => ( +
    • + +

      + Scope {conflict.scope ?? "—"} · opened{" "} + {formatEntitlementInstant(conflict.openedAt)} +

      +
    • + ))} +
    +
    + ) : null} + + + + + {(detail.data?.aliases ?? []).length === 0 ? ( +

    + No alias is recorded. For a purchase-anchored customer that is expected: the purchase + is anchored to the store’s own chain, not to a person. +

    + ) : ( +
      + {(detail.data?.aliases ?? []).map((alias) => ( +
    • +
      + {aliasTypeLabel(alias.aliasType)} + + +
      +

      + {sourceAuthorityLabel(alias.sourceAuthority)} ·{" "} + {formatEntitlementInstant(alias.effectiveStart)} + {alias.effectiveEnd + ? ` → ${formatEntitlementInstant(alias.effectiveEnd)}` + : " → active"} +

      + {aliasTypeNote(alias.aliasType) ? ( +

      + {aliasTypeNote(alias.aliasType)} +

      + ) : null} +
    • + ))} +
    + )} +
    + + + {(detail.data?.purchaseLineages ?? []).length === 0 ? ( +

    + No purchase lineage is recorded for this customer in this Mosaic Environment. +

    + ) : ( +
      + {(detail.data?.purchaseLineages ?? []).map((lineage) => ( +
    • +
      + {lineage.purchaseLineageId} +
      + + {lineage.projectionFrozen ? ( + + ) : null} + {lineage.diagnosticStatus && lineage.diagnosticStatus !== "none" ? ( + + ) : null} +
      +
      +
      + +
      + {lineage.projectionFrozen ? ( +

      + {PROJECTION_FROZEN_NOTE} +

      + ) : null} +
    • + ))} +
    + )} +
    + + + + + {(detail.data?.oneTimePurchases ?? []).length === 0 ? ( +

    No one-time purchase is recorded for this customer.

    + ) : ( +
      + {(detail.data?.oneTimePurchases ?? []).map((purchase) => ( +
    • +
      + + {purchase.oneTimePurchaseInstanceId} + + +
      +

      + {providerLabel(purchase.provider)} · acquired{" "} + {formatEntitlementInstant(purchase.acquiredAt)} +

      + {purchase.mosaicProductId ? ( + + Product + + ) : null} +
    • + ))} +
    + )} +
    +
    +
    + ) +} + +/** + * The customer detail read embeds a bounded first slice of subscriptions so the + * page is one request. This is the bound, and it must match the API's own — + * disclosing a cap that is not the real cap is worse than disclosing none. + */ +const EMBEDDED_SUBSCRIPTION_CAP = 50 + +/** + * The detail response's own count of this customer's subscriptions, when it + * states one. + * + * Read tolerantly and off the generated shape on purpose: the field is being + * added to the contract, and the disclosure below is keyed on its presence so + * this surface tells the truth both before and after it lands. Without it the + * cap is still disclosed the moment the slice is exactly as long as the cap, + * which is the case where saying nothing is most likely to mislead. + */ +function embeddedSubscriptionTotal(detail: unknown): number | undefined { + if (!detail || typeof detail !== "object") return undefined + const record = detail as Record + const value = record.subscriptionTotalCount ?? record.totalCount + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +/** + * Subscriptions for one customer, with the cap stated rather than implied. + * + * A customer with sixty subscription instances is unusual but entirely real — + * years of resubscribing, a family plan, a migrated product line. Rendering + * fifty of them under the heading "Subscriptions" tells an operator they have + * seen all of them, and the support answer that follows ("you have no + * subscription for that product") is then wrong for a reason nothing on screen + * could reveal. So the heading states the cap, and the paged list is one click + * away instead of being unreachable. + */ +function CustomerSubscriptionsPanel({ + customerId, + embedded, + environmentId, + projectId, + scope, + scopeReady, + totalCount, +}: { + customerId: string + embedded: readonly BillingSubscriptionSnapshot[] + environmentId: string + projectId: string + scope: WorkspaceScope + scopeReady: boolean + totalCount: number | undefined +}) { + const [expanded, setExpanded] = useState(false) + const [cursor, setCursor] = useState(undefined) + + const paged = useQuery({ + ...customerSubscriptionsQueryOptions(projectId, environmentId, customerId, cursor), + enabled: scopeReady && expanded, + }) + + const capped = + totalCount !== undefined + ? totalCount > embedded.length + : embedded.length >= EMBEDDED_SUBSCRIPTION_CAP + const items = expanded ? (paged.data?.items ?? []) : embedded + + return ( + + {!expanded && capped ? ( +

    + This panel carries the first {EMBEDDED_SUBSCRIPTION_CAP} the detail read returns + {totalCount === undefined ? " and there may be more" : ""}. Open the full list to page + through every Subscription Instance Mosaic holds for this customer.{" "} + +

    + ) : null} + + {expanded && paged.error ? ( +

    + {paged.error.message} +

    + ) : null} + + {expanded && paged.isPending ? ( +

    + Loading every Subscription Instance for this customer. +

    + ) : items.length === 0 ? ( +

    + {cursor + ? "No Subscription Instance is on this page. Return to the first page to read from the newest." + : "No Subscription Instance is projected for this customer in this Mosaic Environment."} +

    + ) : ( + + )} + + {expanded ? ( + + ) : null} +
    + ) +} diff --git a/apps/dashboard/src/features/billing-customers/components/customer-search-form.tsx b/apps/dashboard/src/features/billing-customers/components/customer-search-form.tsx new file mode 100644 index 00000000..84d25a45 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/customer-search-form.tsx @@ -0,0 +1,112 @@ +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 { + customerIdentifierTypes, + describeSearchIssue, + identifierTypeHelp, + identifierTypeLabel, + MAX_IDENTIFIER_LENGTH, + SEARCH_HELPER_TEXT, + SEARCH_PRIVACY_NOTE, + validateCustomerSearch, + type CustomerIdentifierType, +} from "@/features/billing-customers/types/customer-search" + +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" + +interface CustomerSearchFormProps { + isPending: boolean + onSearch: (input: { + identifierType: CustomerIdentifierType + identifierValue: string + }) => Promise +} + +/** + * Typed-identifier lookup. + * + * The identifier type is chosen before the value is typed, and the helper text + * names every accepted identifier. Both are deliberate: an unlabelled box is a + * box operators put email addresses into, and Mosaic Billing holds no email + * addresses to match them against. Saying what is accepted is how this control + * says what does not exist. + */ +export function CustomerSearchForm({ isPending, onSearch }: CustomerSearchFormProps) { + const [identifierType, setIdentifierType] = + useState("billing_customer_id") + const [identifierValue, setIdentifierValue] = useState("") + const [issue, setIssue] = useState(undefined) + + async function submit() { + const found = validateCustomerSearch({ identifierType, identifierValue }) + if (found) { + setIssue(describeSearchIssue(found)) + return + } + setIssue(undefined) + await onSearch({ identifierType, identifierValue: identifierValue.trim() }) + } + + return ( +
    { + event.preventDefault() + void submit() + }} + > +

    {SEARCH_HELPER_TEXT}

    + +
    + + Identifier type + + + + + + {identifierTypeLabel(identifierType)} + + { + const value = event.currentTarget.value + setIdentifierValue(value) + setIssue(undefined) + }} + value={identifierValue} + /> + {identifierTypeHelp(identifierType)} + {issue ? : null} + + + +
    + +

    {SEARCH_PRIVACY_NOTE}

    +
    + ) +} diff --git a/apps/dashboard/src/features/billing-customers/components/entitlement-explanation-panel.tsx b/apps/dashboard/src/features/billing-customers/components/entitlement-explanation-panel.tsx new file mode 100644 index 00000000..57720e9c --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/entitlement-explanation-panel.tsx @@ -0,0 +1,260 @@ +import { useState } from "react" + +import { DefinitionRow, StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { + accessStateExplanation, + accessStateLabel, + accessStateTone, + explanationSentence, + formatEntitlementInstant, + sourceEndStatement, + sourceStateLabel, + sourceStateTone, + sourceTypeLabel, + uncertaintyReasonLabel, + uncertaintyTone, +} from "@/features/billing-customers/types/entitlement-vocabulary" +import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { + billingSubscriptionHref, + catalogProductHref, + grantVersionsHref, + type WorkspaceScope, +} from "@/lib/routing/workspace-hrefs" +import type { + BillingEntitlementSnapshot, + BillingEntitlementSnapshotEntry, + BillingEntitlementSource, +} from "@/generated/api" + +interface EntitlementExplanationPanelProps { + scope: WorkspaceScope + snapshot: BillingEntitlementSnapshot | undefined +} + +/** + * Why this customer has — or does not have — each Entitlement. + * + * The panel exists because a state without its derivation is unusable in a + * support conversation. Every entry expands to *all* of its contributing + * sources, not just the one that happened to win: an operator asked "why is + * this person still Pro after cancelling?" needs to see the lifetime purchase + * sitting underneath the cancelled subscription, and a UI that shows only the + * decisive source makes that invisible. + * + * Nothing here is a control. This is a derivation, and the only way to change + * it is to change the facts or the grant version it was derived from. + */ +export function EntitlementExplanationPanel({ scope, snapshot }: EntitlementExplanationPanelProps) { + const entries = snapshot?.entries ?? [] + const sources = snapshot?.sources ?? [] + + if (!snapshot || entries.length === 0) { + return ( + +

    + {snapshot + ? "The committed snapshot carries no Entitlement entries. Mosaic has computed an answer and the answer is that no purchase currently grants this customer anything." + : "No Customer Entitlement Snapshot has been committed for this customer in this Mosaic Environment. That is undetermined, not inactive: Mosaic has not yet computed an answer rather than having computed that there is no access."} +

    +
    + ) + } + + return ( + +
      + {entries.map((entry) => ( + source.sourceId && entry.sourceIds?.includes(source.sourceId), + )} + /> + ))} +
    +
    + ) +} + +function EntitlementRow({ + entry, + scope, + sources, +}: { + entry: BillingEntitlementSnapshotEntry + scope: WorkspaceScope + sources: readonly BillingEntitlementSource[] +}) { + const [open, setOpen] = useState(false) + const contentId = `entitlement-sources-${entry.entitlementId}` + + return ( +
  • +
    +
    +

    {entry.entitlementKey ?? entry.entitlementId}

    +

    {entry.entitlementId}

    +
    +
    + + {entry.uncertaintyReason && entry.uncertaintyReason !== "none" ? ( + + ) : null} + {entry.isTestSource ? : null} +
    +
    + +

    + {accessStateExplanation( + entry.state, + entry.uncertaintyReason ? { reason: entry.uncertaintyReason } : undefined, + )} +

    +

    + {explanationSentence(entry.explanationCode)} +

    + +
    + + + +
    + + {/* A count that disagrees with the sources actually named is a defect in + the snapshot, not a rendering detail to smooth over. Saying so is the + honest option: quietly showing whichever number is smaller would hide + a source an operator is entitled to see, and quietly padding the list + would invent one. */} + {entry.sourceCount !== undefined && entry.sourceCount !== sources.length ? ( +

    + The snapshot reports {entry.sourceCount} contributing source(s) but names {sources.length} + . Mosaic renders only the sources the snapshot attributes to this entry; the difference is + a data problem worth reporting rather than a display limit. +

    + ) : null} + + + + {open ? ( +
      + {sources.length === 0 ? ( +
    • + The snapshot names no source for this entry. For an inactive entry that is the + expected shape: nothing is granting it. +
    • + ) : ( + sources.map((source) => ( + + )) + )} +
    + ) : null} +
  • + ) +} + +function SourceRow({ scope, source }: { scope: WorkspaceScope; source: BillingEntitlementSource }) { + const productHref = source.mosaicProductId + ? catalogProductHref(scope, source.mosaicProductId) + : undefined + const subscriptionHref = source.subscriptionInstanceId + ? billingSubscriptionHref(scope, source.subscriptionInstanceId) + : undefined + const grantHref = grantVersionsHref(scope, { + ...(source.entitlementId ? { entitlementId: source.entitlementId } : {}), + ...(source.mosaicProductId ? { productId: source.mosaicProductId } : {}), + }) + + return ( +
  • +
    + {sourceTypeLabel(source.sourceType)} + + {source.uncertaintyReason && source.uncertaintyReason !== "none" ? ( + + ) : null} + {/* Apple sandbox and Google licence-tester purchases both reach here. + A test purchase granting production access is a thing an operator + must be able to see at a glance. */} + {source.isTestSource ? : null} +
    + +

    + {explanationSentence(source.explanationCode)} +

    + +

    + {formatEntitlementInstant(source.sourceStart)} ·{" "} + {sourceEndStatement({ ...(source.sourceEnd ? { end: source.sourceEnd } : {}) })} +

    + +
    + {productHref ? ( + + Product + + ) : null} + {subscriptionHref ? ( + + Subscription + + ) : null} + {source.oneTimePurchaseInstanceId ? ( + + One-time purchase {source.oneTimePurchaseInstanceId} + + ) : null} + {grantHref ? ( + + Grant version + + ) : null} +
    +
  • + ) +} diff --git a/apps/dashboard/src/features/billing-customers/components/identity-conflict-detail-page.tsx b/apps/dashboard/src/features/billing-customers/components/identity-conflict-detail-page.tsx new file mode 100644 index 00000000..2ea5db3b --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/identity-conflict-detail-page.tsx @@ -0,0 +1,265 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" +import { + DefinitionRow, + EnvironmentBadges, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome" +import { providerLabel } from "@/features/billing-ledger/types/billing-vocabulary" +import { ConflictResolutionForm } from "@/features/billing-customers/components/conflict-resolution-form" +import { resolveIdentityConflictMutationOptions } from "@/features/billing-customers/mutations/conflict-mutations" +import { identityConflictQueryOptions } from "@/features/billing-customers/queries/conflict-queries" +import { + CONFLICT_FREEZE_NOTE, + conflictActionLabel, + conflictDiagnosticExplanation, +} from "@/features/billing-customers/types/conflict-resolution" +import { + aliasTypeLabel, + formatEntitlementInstant, + lineageDiagnosticLabel, + PROJECTION_FROZEN_NOTE, +} from "@/features/billing-customers/types/entitlement-vocabulary" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkflowPanel, WorkspacePage } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { useOrganizationAccess } from "@/hooks/use-organization-access" +import { billingCustomerHref, billingIdentityConflictsHref } from "@/lib/routing/workspace-hrefs" + +interface IdentityConflictDetailPageProps { + conflictId: string + environmentId: string + organizationId: string + projectId: string +} + +/** + * One identity conflict and its resolution. + * + * The page states the frozen safety posture before it offers any action, and + * the candidates are presented side by side without a recommended winner: + * Mosaic has deliberately not chosen, and a UI that visually favours one + * candidate would reintroduce the automatic merge the design refuses. + */ +export function IdentityConflictDetailPage({ + conflictId, + environmentId, + organizationId, + projectId, +}: IdentityConflictDetailPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const access = useOrganizationAccess(organizationId) + const queryClient = useQueryClient() + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const detail = useQuery({ + ...identityConflictQueryOptions(projectId, conflictId), + enabled: scopeReady, + }) + const resolve = useMutation( + resolveIdentityConflictMutationOptions(projectId, conflictId, queryClient), + ) + + const conflict = detail.data?.conflict + const lineage = detail.data?.lineage + const environmentName = + environments.data?.items.find((item) => item.id === (lineage?.environmentId ?? environmentId)) + ?.name ?? + lineage?.environmentId ?? + environmentId + + const error = project.error ?? detail.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: project.isPending || (scopeReady && detail.isPending), + loadingDescription: "Loading this identity conflict.", + onRetry: () => { + void detail.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read an identity conflict.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const scope = { environmentId, organizationId, projectId } + const isOpen = conflict?.status === "open" + + return ( + + + Back to identity conflicts + + + + +
    + + +
    +

    {CONFLICT_FREEZE_NOTE}

    +

    + {conflictDiagnosticExplanation(conflict?.diagnosticCode)} +

    +
    + + {conflict?.resolvedAt ? ( + <> + + + + + ) : null} + {/* The disputed alias *type* is reported. Its digest is not rendered + under any scope: a digest is still a stable per-person key. */} + {conflict?.aliasType ? ( + + ) : null} +
    +
    + + +
    + + +
    +
    + + {lineage ? ( + +
    + {lineage.purchaseLineageId}} + /> + + + +
    +
    + +
    + {lineage.projectionFrozen ? ( +

    + {PROJECTION_FROZEN_NOTE} +

    + ) : null} +
    + ) : null} + + {isOpen ? ( + + { + await resolve.mutateAsync(request) + }} + secondCustomerId={conflict?.secondCustomerId} + /> + + ) : ( + +

    + This conflict was resolved as “{conflictActionLabel(conflict?.resolutionAction)} + ”. Both customers were unfrozen and reprojected. A resolution is recorded, not + reversed: if it was wrong, the correction is a new association, not an edit to this + record. +

    +
    + )} +
    +
    + ) +} + +function CandidateCard({ + caption, + customerId, + href, + title, +}: { + caption: string + customerId: string | undefined + href: string + title: string +}) { + return ( +
    +

    {title}

    +

    {caption}

    + {customerId ? ( + + {customerId} + + ) : ( +

    Not recorded.

    + )} +
    + ) +} diff --git a/apps/dashboard/src/features/billing-customers/components/identity-conflicts-page.tsx b/apps/dashboard/src/features/billing-customers/components/identity-conflicts-page.tsx new file mode 100644 index 00000000..efec419f --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/identity-conflicts-page.tsx @@ -0,0 +1,161 @@ +import { useQuery } from "@tanstack/react-query" + +import { EmptyState } from "@/components/feedback/empty-state" +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 { identityConflictsQueryOptions } from "@/features/billing-customers/queries/conflict-queries" +import { + CONFLICT_FREEZE_NOTE, + CONFLICT_PROJECT_SCOPE_NOTE, + conflictActionLabel, + conflictDiagnosticExplanation, +} from "@/features/billing-customers/types/conflict-resolution" +import { formatEntitlementInstant } from "@/features/billing-customers/types/entitlement-vocabulary" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkflowPanel, WorkspacePage } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { billingIdentityConflictHref } from "@/lib/routing/workspace-hrefs" + +interface IdentityConflictsPageProps { + environmentId: string + onStatusChange: (status: "open" | "resolved") => void + organizationId: string + projectId: string + status: "open" | "resolved" +} + +/** + * Identity conflicts awaiting an operator. + * + * Project-wide, and the page says so. A Billing Customer's identity belongs to + * the Project while its purchases belong to an Environment, so silently + * filtering this list to the Environment in the address would let an operator + * conclude a conflict had been resolved when it merely concerned a sibling + * Environment — and a conflict left open keeps a real customer frozen. + */ +export function IdentityConflictsPage({ + environmentId, + onStatusChange, + organizationId, + projectId, + status, +}: IdentityConflictsPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const conflicts = useQuery({ + ...identityConflictsQueryOptions(projectId, status), + enabled: scopeReady, + }) + + const error = project.error ?? conflicts.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: project.isPending || (scopeReady && conflicts.isPending), + loadingDescription: "Loading identity conflicts for this Project.", + onRetry: () => { + void conflicts.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read identity conflicts.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const scope = { environmentId, organizationId, projectId } + const items = conflicts.data ?? [] + + return ( + +

    {CONFLICT_PROJECT_SCOPE_NOTE}

    +

    {CONFLICT_FREEZE_NOTE}

    + + + +
    + {(["open", "resolved"] as const).map((candidate) => ( + + ))} +
    +
    + + {items.length === 0 ? ( + + ) : ( + +
      + {items.map((conflict) => ( +
    • + + +

      + {conflictDiagnosticExplanation(conflict.diagnosticCode)} +

      + +

      + Opened {formatEntitlementInstant(conflict.openedAt)} + {conflict.resolvedAt + ? ` · resolved ${formatEntitlementInstant(conflict.resolvedAt)} as “${conflictActionLabel(conflict.resolutionAction)}”` + : ""} +

      +
    • + ))} +
    +
    + )} +
    +
    + ) +} diff --git a/apps/dashboard/src/features/billing-customers/components/restore-jobs-page.tsx b/apps/dashboard/src/features/billing-customers/components/restore-jobs-page.tsx new file mode 100644 index 00000000..6cfd74ec --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/restore-jobs-page.tsx @@ -0,0 +1,242 @@ +import { useQuery } from "@tanstack/react-query" + +import { EmptyState } from "@/components/feedback/empty-state" +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 { + DefinitionRow, + LedgerPaging, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome" +import { + BILLING_OPTIONAL_NOTE, + runStatusLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { billingHealthQueryOptions } from "@/features/billing-operations/queries/billing-health-queries" +import { restoreJobsQueryOptions } from "@/features/billing-customers/queries/restore-queries" +import { formatEntitlementInstant } from "@/features/billing-customers/types/entitlement-vocabulary" +import { + describeSnapshotMovement, + providerOutcomeLabel, + RESTORE_LAYERS, + RESTORE_READ_ONLY_NOTE, + restoreOutcomeExplanation, + restoreOutcomeLabel, + restoreOutcomeTone, +} from "@/features/billing-customers/types/restore-vocabulary" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkflowPanel, WorkspacePage } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { billingCustomerHref, storeConnectionsHref } from "@/lib/routing/workspace-hrefs" +import type { BillingRestoreJob } from "@/generated/api" + +interface RestoreJobsPageProps { + cursor?: string + environmentId: string + onCursorChange: (cursor: string | undefined) => void + organizationId: string + projectId: string +} + +/** + * Restore status, read-only. + * + * A restore is started by an SDK on a device, because only the device can ask + * the store to replay its own purchases. There is no operator "start restore" + * button and adding one would be a lie about what Mosaic can do — the nearest + * real action is recomputing a customer's projection, which is on customer + * detail and is a different operation. + * + * The three layers are stated in fixed copy above the list, because almost + * every confusing restore is a case where one layer succeeded and another had + * not finished. + */ +export function RestoreJobsPage({ + cursor, + environmentId, + onCursorChange, + organizationId, + projectId, +}: RestoreJobsPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const health = useQuery({ + ...billingHealthQueryOptions(projectId, environmentId), + enabled: scopeReady, + }) + const restores = useQuery({ + ...restoreJobsQueryOptions(projectId, environmentId, { ...(cursor ? { cursor } : {}) }), + enabled: scopeReady, + }) + + const billingEnabled = health.data?.billingEnabled !== false + const items = restores.data?.items ?? [] + + const error = project.error ?? restores.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: project.isPending || (scopeReady && restores.isPending), + loadingDescription: "Loading restore jobs for this Mosaic Environment.", + onRetry: () => { + void restores.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read restore jobs.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const scope = { environmentId, organizationId, projectId } + + return ( + +

    {RESTORE_READ_ONLY_NOTE}

    + + +
      + {RESTORE_LAYERS.map((layer) => ( +
    1. +

      {layer.title}

      +

      {layer.body}

      +
    2. + ))} +
    +
    + + + {!billingEnabled ? ( + + Set up Mosaic Billing + + } + description={`Mosaic Billing is turned off for this Project, so restore submissions are rejected and none is recorded. ${BILLING_OPTIONAL_NOTE}`} + title="Mosaic Billing is not enabled for this Project" + /> + ) : items.length === 0 ? ( + <> + + + + ) : ( + +
      + {items.map((job) => ( + + ))} +
    + +
    + )} +
    +
    + ) +} + +function RestoreRow({ + customerHref, + job, +}: { + customerHref: string | undefined + job: BillingRestoreJob +}) { + return ( +
  • +
    + {job.restoreId} +
    + {/* Two axes, never merged. The store's own result and Mosaic's are + different questions, and a native restore that "succeeded" says + nothing about whether anyone's access changed. */} + + + +
    +
    + +

    {restoreOutcomeExplanation(job.outcome)}

    +

    + {describeSnapshotMovement(job)} +

    + +
    + + {job.billingCustomerId} + + ) : ( + "Not resolved yet — which is exactly the identity-unresolved outcome" + ) + } + /> + + + + + +
    +
  • + ) +} diff --git a/apps/dashboard/src/features/billing-customers/components/subscription-detail-page.tsx b/apps/dashboard/src/features/billing-customers/components/subscription-detail-page.tsx new file mode 100644 index 00000000..522f9dd2 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/subscription-detail-page.tsx @@ -0,0 +1,321 @@ +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 { + DefinitionRow, + LedgerPaging, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome" +import { pagedListHeading } from "@/features/billing-ledger/types/billing-list-headings" +import { providerLabel } from "@/features/billing-ledger/types/billing-vocabulary" +import { SubscriptionStateAxes } from "@/features/billing-customers/components/subscription-state-axes" +import { + subscriptionQueryOptions, + subscriptionTimelineQueryOptions, +} from "@/features/billing-customers/queries/customer-queries" +import { + AUTHORITATIVE_ACCESS_NOTE, + AUTHORITATIVE_TIMESTAMP_NOTE, + explanationSentence, + formatEntitlementInstant, + timelineEntryTypeLabel, +} from "@/features/billing-customers/types/entitlement-vocabulary" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkflowPanel, WorkspacePage } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { + billingCustomerHref, + billingTransactionsHref, + catalogProductHref, +} from "@/lib/routing/workspace-hrefs" +import type { BillingSubscriptionSnapshot } from "@/generated/api" + +interface SubscriptionDetailPageProps { + cursor?: string + environmentId: string + instanceId: string + onCursorChange: (cursor: string | undefined) => void + organizationId: string + projectId: string +} + +/** + * One Subscription Instance and the append-only history that explains it. + * + * The timeline is newest-first and nothing is ever removed from it: a + * superseded entry is still part of why the current state is what it is, and + * dropping it would leave an operator with a conclusion and no derivation — + * which on a billing surface is indistinguishable from Mosaic having made it + * up. + * + * That promise is about the record, not about one response. A subscription that + * has renewed monthly for two years has more entries than any single page + * carries, so the page is paged and says so. Claiming completeness while + * silently trimming would break the same promise from the other direction. + */ +export function SubscriptionDetailPage({ + cursor, + environmentId, + instanceId, + onCursorChange, + organizationId, + projectId, +}: SubscriptionDetailPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const subscription = useQuery({ + ...subscriptionQueryOptions(projectId, environmentId, instanceId), + enabled: scopeReady, + }) + const timeline = useQuery({ + ...subscriptionTimelineQueryOptions(projectId, environmentId, instanceId, cursor), + enabled: scopeReady, + }) + const timelineEntries = timeline.data?.items ?? [] + + const error = project.error ?? subscription.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: project.isPending || (scopeReady && subscription.isPending), + loadingDescription: "Loading this Subscription Instance's projected state.", + onRetry: () => { + void subscription.refetch() + void timeline.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read a Subscription Instance.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const scope = { environmentId, organizationId, projectId } + const data = subscription.data + + return ( + + {data?.billingCustomerId ? ( + + Back to the Billing Customer + + ) : null} + +

    {AUTHORITATIVE_ACCESS_NOTE}

    + + + + {data ? : null} + + + +
    + + + + + + {/* Deliberately labelled as the moment renewal was turned off, not + as the moment access ended. They are not the same instant and + conflating them is the cancellation bug. */} + + + + +
    +
    + + +
    + + + + + +
    +

    + {AUTHORITATIVE_TIMESTAMP_NOTE} +

    + {data?.explanationCode ? ( +

    {explanationSentence(data.explanationCode)}

    + ) : null} + {data?.supersededBySubscriptionInstanceId ? ( +

    + Superseded by {data.supersededBySubscriptionInstanceId}. Nothing was deleted — the + replacement is recorded explicitly and this instance stays readable. +

    + ) : null} + {data?.mosaicProductId ? ( + + Product + + ) : null} +
    + + + {timelineEntries.length === 0 ? ( + <> +

    + {cursor + ? "No timeline entry is on this page. Return to the first page to read this Subscription Instance's history from the newest entry." + : "No timeline entry is recorded for this Subscription Instance yet."} +

    + + + ) : ( +
      + {timelineEntries.map((entry) => ( +
    1. +
      + + {timelineEntryTypeLabel(entry.entryType)} + + +
      + + {/* Effective and observed are two clocks: when the store says + it took effect, and when Mosaic learned about it. Neither + provider guarantees notification ordering, so they diverge + routinely and an operator reasoning about a dispute needs + both. */} +
      +

      + Effective at + {formatEntitlementInstant(entry.effectiveAt)} +

      +

      + Observed at + {formatEntitlementInstant(entry.observedAt)} +

      +
      + + {entry.explanationCode ? ( +

      + {explanationSentence(entry.explanationCode)} +

      + ) : null} + + {entry.detail && Object.keys(entry.detail).length > 0 ? ( +
      + {Object.entries(entry.detail).map(([key, value]) => ( + + ))} +
      + ) : null} + + + Find the recorded facts behind this + +
    2. + ))} +
    + )} + {timelineEntries.length > 0 ? ( + + ) : null} +
    +
    +
    + ) +} + +/** + * The snapshot names the platform in SDK vocabulary while the ledger names the + * store in provider vocabulary. Mapping here keeps one provider label in use + * across both features rather than introducing a second name for one store. + */ +function storeProviderOf(subscription: BillingSubscriptionSnapshot | undefined) { + if (subscription?.storePlatform === "apple_app_store") return "app_store" + if (subscription?.storePlatform === "google_play") return "google_play" + return subscription?.storePlatform +} diff --git a/apps/dashboard/src/features/billing-customers/components/subscription-state-axes.test.tsx b/apps/dashboard/src/features/billing-customers/components/subscription-state-axes.test.tsx new file mode 100644 index 00000000..c8962ca0 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/subscription-state-axes.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { SubscriptionStateAxes } from "@/features/billing-customers/components/subscription-state-axes" +import type { BillingSubscriptionSnapshot } from "@/generated/api" + +/** + * The cancelled-but-still-current subscription. + * + * This is the case a merged status pill cannot state, and the realistic failure + * is expensive in one direction: a support agent who reads "Cancelled" as + * "access gone" issues a refund or talks a paying customer through a + * re-purchase for a subscription that is working correctly. So the rendering + * has to keep both facts on screen — renewal stopped, access continues — and + * must never emit the inactive label for a subscription whose access is active. + */ +describe("subscription state axes", () => { + const cancelledButCurrent: BillingSubscriptionSnapshot = { + accessState: "active", + billingState: "current", + cancellationEffectiveAt: "2026-07-10T09:00:00Z", + lifecycleState: "active", + periodEnd: "2026-09-01T12:00:00Z", + renewalIntent: "auto_renew_disabled", + subscriptionInstanceId: "sub_01", + uncertaintyReason: "none", + } + + it("renders auto-renew disabled and access until the period end, never inactive", () => { + render() + + expect(screen.getByText("Auto-renew disabled")).toBeInTheDocument() + expect(screen.getByText("Active until 2026-09-01 12:00:00 UTC")).toBeInTheDocument() + expect(screen.queryByText("No access")).not.toBeInTheDocument() + }) + + it("keeps the five axes separate rather than merging them into one status", () => { + render() + + for (const axis of ["Access", "Lifecycle", "Renewal intent", "Billing state", "Uncertainty"]) { + expect(screen.getByText(axis)).toBeInTheDocument() + } + // Access and lifecycle disagreeing with renewal intent is the whole point: + // all three are true simultaneously and none is summarised away. + expect(screen.getByText("Access active")).toBeInTheDocument() + expect(screen.getByText("Billing current")).toBeInTheDocument() + }) + + it("does not present an undetermined subscription as having no access", () => { + render( + , + ) + + // The pill and the summary sentence both say it, which is the point: there + // is no reading of this component that produces "inactive". + expect(screen.getAllByText("Access undetermined").length).toBeGreaterThan(0) + expect(screen.queryByText("No access")).not.toBeInTheDocument() + expect(screen.getByText("Store unavailable")).toBeInTheDocument() + }) +}) diff --git a/apps/dashboard/src/features/billing-customers/components/subscription-state-axes.tsx b/apps/dashboard/src/features/billing-customers/components/subscription-state-axes.tsx new file mode 100644 index 00000000..b5c504c6 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/components/subscription-state-axes.tsx @@ -0,0 +1,103 @@ +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { + accessStateLabel, + accessStateTone, + billingStateLabel, + billingStateTone, + lifecycleStateLabel, + lifecycleStateTone, + renewalIntentLabel, + renewalIntentTone, + subscriptionAccessStatement, + uncertaintyReasonLabel, + uncertaintyTone, +} from "@/features/billing-customers/types/entitlement-vocabulary" +import type { BillingSubscriptionSnapshot } from "@/generated/api" + +/** + * The five state axes, always rendered as five separate text-first pills. + * + * This is the single most consequential rendering decision on the customer + * surfaces, and it is why no component here accepts a "status" prop. + * + * A cancelled subscription that still has access is the case that proves it. A + * merged pill must choose between "Cancelled" — which support agents read as + * access gone, and act on, by offering a refund or a re-purchase — and + * "Active", which hides that renewal was turned off and produces a surprised + * customer at period end. Both are wrong, and the first is wrong in the + * expensive direction. + * + * Five pills state five true things at once: access is active, the lifecycle is + * active, renewal intent is disabled, billing is current, and there is no + * uncertainty. Nothing has to be summarised away. + */ +export function SubscriptionStateAxes({ + subscription, +}: { + subscription: BillingSubscriptionSnapshot +}) { + const statement = subscriptionAccessStatement({ + accessState: subscription.accessState, + periodEnd: subscription.periodEnd, + renewalIntent: subscription.renewalIntent, + }) + + return ( +
    +
    + + + + + + + + + + + + + + + +
    + + {/* The sentence pair. Cancellation flips renewal intent only, so the + access line states the period end rather than the cancellation. */} +

    + {statement.access} + · {statement.renewal} +

    + + {subscription.isTestSource ? ( +

    + +

    + ) : null} +
    + ) +} + +function Axis({ children, label }: { children: React.ReactNode; label: string }) { + return ( +
    +
    {label}
    +
    {children}
    +
    + ) +} diff --git a/apps/dashboard/src/features/billing-customers/mutations/conflict-mutations.ts b/apps/dashboard/src/features/billing-customers/mutations/conflict-mutations.ts new file mode 100644 index 00000000..4e4919e2 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/mutations/conflict-mutations.ts @@ -0,0 +1,39 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query" + +import { + resolveBillingIdentityConflict, + type ResolveIdentityConflictRequest, +} from "@/generated/api" +import { conflictKeys } from "@/features/billing-customers/queries/conflict-queries" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * Record an operator's resolution of an identity conflict. + * + * This moves real purchases between real people and unfreezes projection for + * both parties, so it is the one write on the customer surfaces and it carries + * a required reason. Every customer read in the Project is invalidated + * afterwards, not just the two named: a resolution unfreezes lineages whose + * reprojection can change what any customer citing the same Product is shown. + */ +export function resolveIdentityConflictMutationOptions( + projectId: string, + conflictId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (request: ResolveIdentityConflictRequest) => { + const result = await resolveBillingIdentityConflict({ + body: request, + client: generatedDashboardClient, + path: { conflictId, projectId }, + throwOnError: true, + }) + return result.data.data + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: conflictKeys.scope(projectId) }) + await queryClient.invalidateQueries({ queryKey: ["billing-customers"] }) + }, + }) +} diff --git a/apps/dashboard/src/features/billing-customers/mutations/customer-mutations.ts b/apps/dashboard/src/features/billing-customers/mutations/customer-mutations.ts new file mode 100644 index 00000000..6d8fafb1 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/mutations/customer-mutations.ts @@ -0,0 +1,72 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query" + +import { + createBillingCustomerSyncRequest, + lookupBillingCustomer, + type BillingCustomerLookupRequest, +} from "@/generated/api" +import { customerKeys } from "@/features/billing-customers/queries/customer-queries" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * The typed-identifier lookup is a mutation, not a query. + * + * Not because it changes anything — it does not — but because a query would + * have to key the cache on the raw identifier the operator typed, and that is + * the one value this whole surface is arranged to keep out of stored state. It + * travels in a POST body so it also stays out of URLs, access logs, and browser + * history. + */ +export function lookupBillingCustomerMutationOptions(projectId: string, environmentId: string) { + return mutationOptions({ + mutationFn: async (request: BillingCustomerLookupRequest) => { + const result = await lookupBillingCustomer({ + body: request, + client: generatedDashboardClient, + path: { environmentId, projectId }, + throwOnError: true, + }) + // A miss answers 200 with `found: false`. It is a result, not an error, + // and the caller renders it as one. + return result.data.data + }, + }) +} + +/** + * Queue a projection recomputation for one customer. + * + * This is emphatically not a device restore: no operator action can make a + * store replay a person's purchases. It recomputes committed access from the + * facts Mosaic already holds, which is what fixes a stale or failed projection + * and does nothing at all for a customer whose purchases were never ingested. + * + * The answer is "this has been queued". Triggers coalesce onto the customer + * scope, so an impatient operator clicking twice produces one projection rather + * than two. + */ +export function requestCustomerSyncMutationOptions( + projectId: string, + environmentId: string, + customerId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async () => { + const result = await createBillingCustomerSyncRequest({ + client: generatedDashboardClient, + path: { customerId, environmentId, projectId }, + throwOnError: true, + }) + return result.data.data + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: customerKeys.detail(projectId, environmentId, customerId), + }) + await queryClient.invalidateQueries({ + queryKey: customerKeys.entitlements(projectId, environmentId, customerId), + }) + }, + }) +} diff --git a/apps/dashboard/src/features/billing-customers/queries/conflict-queries.ts b/apps/dashboard/src/features/billing-customers/queries/conflict-queries.ts new file mode 100644 index 00000000..a6bbcdab --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/queries/conflict-queries.ts @@ -0,0 +1,55 @@ +import { queryOptions } from "@tanstack/react-query" + +import { + getOperatorBillingIdentityConflict, + listOperatorBillingIdentityConflicts, +} from "@/generated/api" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * Identity conflicts are Project-scoped, so their query keys are too. + * + * A Billing Customer's identity belongs to the Project while its purchases and + * snapshots belong to an Environment. Keying these by Environment would show an + * operator a different conflict list depending on which Environment they + * happened to be in, which is exactly the misreading that makes someone + * conclude a conflict has been resolved. + */ +export const conflictKeys = { + detail: (projectId: string, conflictId: string) => + ["billing-conflicts", projectId, "conflict", conflictId] as const, + list: (projectId: string, status: string) => + ["billing-conflicts", projectId, "conflicts", status] as const, + scope: (projectId: string) => ["billing-conflicts", projectId] as const, +} + +export function identityConflictsQueryOptions(projectId: string, status: "open" | "resolved") { + return queryOptions({ + queryKey: conflictKeys.list(projectId, status), + queryFn: async ({ signal }) => { + const result = await listOperatorBillingIdentityConflicts({ + client: generatedDashboardClient, + path: { projectId }, + query: { status }, + signal, + throwOnError: true, + }) + return result.data.data?.items ?? [] + }, + }) +} + +export function identityConflictQueryOptions(projectId: string, conflictId: string) { + return queryOptions({ + queryKey: conflictKeys.detail(projectId, conflictId), + queryFn: async ({ signal }) => { + const result = await getOperatorBillingIdentityConflict({ + client: generatedDashboardClient, + path: { conflictId, projectId }, + signal, + throwOnError: true, + }) + return result.data.data + }, + }) +} diff --git a/apps/dashboard/src/features/billing-customers/queries/customer-queries.ts b/apps/dashboard/src/features/billing-customers/queries/customer-queries.ts new file mode 100644 index 00000000..2d539b75 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/queries/customer-queries.ts @@ -0,0 +1,255 @@ +import { queryOptions } from "@tanstack/react-query" + +import { + getBillingCustomerEntitlementSnapshot, + getBillingSubscription, + getOperatorBillingCustomer, + listBillingCustomers, + listBillingCustomerSubscriptions, + listBillingSubscriptionTimeline, +} from "@/generated/api" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * Authoritative customer reads. + * + * No query key here carries a raw identifier a person could be recognised by: + * the list and detail keys hold Mosaic identifiers only. The typed-identifier + * lookup is deliberately absent from this module — it is a POST whose body must + * not be cached under a key, so it lives as a mutation. + */ +export const customerKeys = { + detail: (projectId: string, environmentId: string, customerId: string) => + ["billing-customers", projectId, environmentId, "customer", customerId] as const, + entitlements: (projectId: string, environmentId: string, customerId: string) => + ["billing-customers", projectId, environmentId, "entitlements", customerId] as const, + list: (projectId: string, environmentId: string, filters: Record) => + ["billing-customers", projectId, environmentId, "customers", filters] as const, + scope: (projectId: string, environmentId: string) => + ["billing-customers", projectId, environmentId] as const, + subscription: (projectId: string, environmentId: string, instanceId: string) => + ["billing-customers", projectId, environmentId, "subscription", instanceId] as const, + subscriptions: (projectId: string, environmentId: string, customerId: string, cursor?: string) => + [ + "billing-customers", + projectId, + environmentId, + "subscriptions", + customerId, + cursor ?? null, + ] as const, + timeline: (projectId: string, environmentId: string, instanceId: string, cursor?: string) => + [ + "billing-customers", + projectId, + environmentId, + "timeline", + instanceId, + cursor ?? null, + ] as const, +} + +/** The page size every paged customer surface asks for. */ +export const CUSTOMER_PAGE_SIZE = 50 + +export interface CustomerListFilters { + conflictedOnly?: boolean + cursor?: string + identified?: boolean + limit?: number + status?: "absorbed" | "active" | "anonymized" | "frozen" +} + +export function billingCustomersQueryOptions( + projectId: string, + environmentId: string, + filters: CustomerListFilters = {}, +) { + const query = { + limit: filters.limit ?? 25, + ...(filters.cursor ? { cursor: filters.cursor } : {}), + ...(filters.status ? { status: filters.status } : {}), + ...(filters.identified === undefined ? {} : { identified: filters.identified }), + ...(filters.conflictedOnly ? { conflictedOnly: true } : {}), + } + return queryOptions({ + queryKey: customerKeys.list(projectId, environmentId, query), + queryFn: async ({ signal }) => { + const result = await listBillingCustomers({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + query, + signal, + throwOnError: true, + }) + return { + items: result.data.data?.items ?? [], + nextCursor: result.data.data?.nextCursor, + } + }, + }) +} + +/** How often a customer surface re-reads while a projection is outstanding. */ +export const PROJECTION_POLL_INTERVAL_MS = 5000 + +/** + * Poll only while something is genuinely expected to move, and stop the moment + * it has. + * + * A projection runs on a worker, so `pending` is a state the page leaves on its + * own — without polling an operator watches a stale answer and reaches for the + * refresh button, or worse, believes it. `current` is terminal and ends the + * polling; nothing else does, because a `failed` or `stale` projection will not + * resolve itself and repeating the read forever would be an idle page making + * requests until the tab closes. This is the same terminal-state shape the + * restore list uses. + * + * `recomputeQueued` covers the gap between an operator queueing a recomputation + * and the status catching up: the request is accepted before the worker marks + * anything pending, so without it the page would sit still for exactly the + * interval where the operator is watching hardest. + */ +export function projectionRefetchInterval( + state: string | undefined, + recomputeQueued: boolean, +): number | false { + if (state === "current") return false + if (state === "pending" || recomputeQueued) return PROJECTION_POLL_INTERVAL_MS + return false +} + +export function billingCustomerQueryOptions( + projectId: string, + environmentId: string, + customerId: string, + recomputeQueued = false, +) { + return queryOptions({ + queryKey: customerKeys.detail(projectId, environmentId, customerId), + queryFn: async ({ signal }) => { + const result = await getOperatorBillingCustomer({ + client: generatedDashboardClient, + path: { customerId, environmentId, projectId }, + signal, + throwOnError: true, + }) + return result.data.data + }, + refetchInterval: (query) => + projectionRefetchInterval(query.state.data?.projectionStatus?.state, recomputeQueued), + }) +} + +/** + * The current Customer Entitlement Snapshot, read separately from the customer + * detail so a projection that moves refreshes the entitlement panel without + * refetching aliases and lineages that did not. + */ +export function customerEntitlementSnapshotQueryOptions( + projectId: string, + environmentId: string, + customerId: string, + recomputeQueued = false, +) { + return queryOptions({ + queryKey: customerKeys.entitlements(projectId, environmentId, customerId), + queryFn: async ({ signal }) => { + const result = await getBillingCustomerEntitlementSnapshot({ + client: generatedDashboardClient, + path: { customerId, environmentId, projectId }, + signal, + throwOnError: true, + }) + return result.data.data + }, + refetchInterval: (query) => + projectionRefetchInterval(query.state.data?.projectionStatus?.state, recomputeQueued), + }) +} + +/** + * Every Subscription Instance for one customer, paged. + * + * The customer detail read embeds a bounded first slice so the page is one + * request. This is the surface an operator reaches for when that slice is not + * all of them, so it pages properly rather than repeating the same cap under a + * different name. + */ +export function customerSubscriptionsQueryOptions( + projectId: string, + environmentId: string, + customerId: string, + cursor?: string, +) { + return queryOptions({ + queryKey: customerKeys.subscriptions(projectId, environmentId, customerId, cursor), + queryFn: async ({ signal }) => { + const result = await listBillingCustomerSubscriptions({ + client: generatedDashboardClient, + path: { customerId, environmentId, projectId }, + query: { limit: CUSTOMER_PAGE_SIZE, ...(cursor ? { cursor } : {}) }, + signal, + throwOnError: true, + }) + return { + items: result.data.data?.items ?? [], + nextCursor: result.data.data?.nextCursor, + } + }, + }) +} + +export function subscriptionQueryOptions( + projectId: string, + environmentId: string, + instanceId: string, +) { + return queryOptions({ + queryKey: customerKeys.subscription(projectId, environmentId, instanceId), + queryFn: async ({ signal }) => { + const result = await getBillingSubscription({ + client: generatedDashboardClient, + path: { environmentId, instanceId, projectId }, + signal, + throwOnError: true, + }) + return result.data.data + }, + }) +} + +/** + * The append-only explanation history for one Subscription Instance, newest + * first. + * + * Nothing is ever *deleted* from this history — a superseded entry is still + * part of why the current state is what it is — but one response is a page, not + * the history. The distinction matters because the two claims are easy to + * conflate into "you are looking at everything", and a long-lived subscription + * with a year of renewals and retries has far more than one page. The cursor is + * therefore carried through to the caller rather than discarded. + */ +export function subscriptionTimelineQueryOptions( + projectId: string, + environmentId: string, + instanceId: string, + cursor?: string, +) { + return queryOptions({ + queryKey: customerKeys.timeline(projectId, environmentId, instanceId, cursor), + queryFn: async ({ signal }) => { + const result = await listBillingSubscriptionTimeline({ + client: generatedDashboardClient, + path: { environmentId, instanceId, projectId }, + query: { limit: CUSTOMER_PAGE_SIZE, ...(cursor ? { cursor } : {}) }, + signal, + throwOnError: true, + }) + return { + items: result.data.data?.items ?? [], + nextCursor: result.data.data?.nextCursor, + } + }, + }) +} diff --git a/apps/dashboard/src/features/billing-customers/queries/restore-queries.ts b/apps/dashboard/src/features/billing-customers/queries/restore-queries.ts new file mode 100644 index 00000000..403a6cd3 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/queries/restore-queries.ts @@ -0,0 +1,76 @@ +import { queryOptions } from "@tanstack/react-query" + +import { getBillingRestoreJob, listBillingRestoreJobs } from "@/generated/api" +import { isRestoreJobRunning } from "@/features/billing-customers/types/restore-vocabulary" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +export const restoreKeys = { + detail: (projectId: string, environmentId: string, restoreId: string) => + ["billing-restores", projectId, environmentId, "restore", restoreId] as const, + list: (projectId: string, environmentId: string, filters: Record) => + ["billing-restores", projectId, environmentId, "restores", filters] as const, + scope: (projectId: string, environmentId: string) => + ["billing-restores", projectId, environmentId] as const, +} + +/** + * Restore jobs run on a worker, so the list polls while anything is in flight + * and stops as soon as nothing is — the same terminal-status pattern the 9A + * replay list uses, so an idle billing page makes no repeating requests. + * + * Note the deliberate asymmetry with the outcome vocabulary: a job reaching + * `completed` is what stops the polling, but a `completed` job can still carry + * `validation_pending`, so the status and the outcome are never merged. + */ +export function restoreJobsQueryOptions( + projectId: string, + environmentId: string, + filters: { billingCustomerId?: string; cursor?: string } = {}, +) { + const query = { + limit: 25, + ...(filters.cursor ? { cursor: filters.cursor } : {}), + ...(filters.billingCustomerId ? { billingCustomerId: filters.billingCustomerId } : {}), + } + return queryOptions({ + queryKey: restoreKeys.list(projectId, environmentId, query), + queryFn: async ({ signal }) => { + const result = await listBillingRestoreJobs({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + query, + signal, + throwOnError: true, + }) + return { + items: result.data.data?.items ?? [], + nextCursor: result.data.data?.nextCursor, + } + }, + refetchInterval: (query) => { + const items = query.state.data?.items ?? [] + return items.some((job) => isRestoreJobRunning(job)) ? 5000 : false + }, + }) +} + +export function restoreJobQueryOptions( + projectId: string, + environmentId: string, + restoreId: string, +) { + return queryOptions({ + queryKey: restoreKeys.detail(projectId, environmentId, restoreId), + queryFn: async ({ signal }) => { + const result = await getBillingRestoreJob({ + client: generatedDashboardClient, + path: { environmentId, projectId, restoreId }, + signal, + throwOnError: true, + }) + return result.data.data + }, + refetchInterval: (query) => + query.state.data && isRestoreJobRunning(query.state.data) ? 5000 : false, + }) +} diff --git a/apps/dashboard/src/features/billing-customers/types/conflict-resolution.test.ts b/apps/dashboard/src/features/billing-customers/types/conflict-resolution.test.ts new file mode 100644 index 00000000..e94b6f10 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/types/conflict-resolution.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest" + +import { + conflictActionConsequence, + conflictActions, + evaluateResolutionGate, + expectedAssignee, +} from "@/features/billing-customers/types/conflict-resolution" + +const base = { + acknowledged: true, + action: "reassign_to_candidate", + canManage: true, + firstCustomerId: "cus_incumbent", + isSubmitting: false, + reason: "Support ticket 4821 confirmed the challenger owns the store account.", + secondCustomerId: "cus_challenger", +} + +/** + * Resolving an identity conflict moves real purchases between real people and + * cannot be undone by re-running it, so the guard is what stands between an + * operator and an irreversible mistake made in one click. + * + * The realistic failure is a form that submits on the radio selection alone — + * the operator picks "reassign", never reads what happens to the incumbent, and + * a paying customer loses access with nothing recorded about why. + */ +describe("identity conflict resolution gate", () => { + it("refuses to submit until an action, a reason, and an acknowledgement are all present", () => { + expect(evaluateResolutionGate({ ...base, action: undefined }).blockedBy).toBe("action_required") + expect(evaluateResolutionGate({ ...base, reason: " " }).blockedBy).toBe("reason_required") + expect(evaluateResolutionGate({ ...base, acknowledged: false }).blockedBy).toBe( + "acknowledgement_required", + ) + }) + + it("allows submission only when every condition holds at once", () => { + expect(evaluateResolutionGate(base).allowed).toBe(true) + }) + + it("requires management permission", () => { + expect(evaluateResolutionGate({ ...base, canManage: false }).blockedBy).toBe("no_permission") + }) + + it("refuses a resolution whose stated winner contradicts its action", () => { + // The server enforces this; catching it here keeps the refusal next to the + // control that caused it rather than surfacing as a 422. + const gate = evaluateResolutionGate({ + ...base, + action: "keep_existing", + assignedBillingCustomerId: "cus_challenger", + }) + expect(gate.allowed).toBe(false) + expect(gate.blockedBy).toBe("assignment_mismatch") + }) + + it("derives the assignee the action implies, and none for a split", () => { + expect( + expectedAssignee({ + action: "keep_existing", + firstCustomerId: "cus_incumbent", + secondCustomerId: "cus_challenger", + }), + ).toBe("cus_incumbent") + expect( + expectedAssignee({ + action: "reassign_to_candidate", + firstCustomerId: "cus_incumbent", + secondCustomerId: "cus_challenger", + }), + ).toBe("cus_challenger") + expect( + expectedAssignee({ + action: "operator_split", + firstCustomerId: "cus_incumbent", + secondCustomerId: "cus_challenger", + }), + ).toBeUndefined() + }) +}) + +describe("resolution consequences", () => { + it("states the outcome for both parties on every action", () => { + // The failure this guards is an operator reading only the outcome for the + // customer in front of them and not noticing the other one loses access. + for (const action of conflictActions) { + const sentence = conflictActionConsequence(action) + expect(sentence.length).toBeGreaterThan(80) + expect(sentence.toLowerCase()).toContain("access") + } + expect(conflictActionConsequence("reassign_to_candidate")).toContain("lose the access") + expect(conflictActionConsequence("operator_split")).toContain("Neither customer") + }) + + it("offers no action that merges the two customers", () => { + // Automatic merge stays an architecture checkpoint, not a dashboard button. + expect(conflictActions).toEqual(["keep_existing", "reassign_to_candidate", "operator_split"]) + expect(conflictActions).not.toContain("merge") + }) +}) diff --git a/apps/dashboard/src/features/billing-customers/types/conflict-resolution.ts b/apps/dashboard/src/features/billing-customers/types/conflict-resolution.ts new file mode 100644 index 00000000..399133e1 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/types/conflict-resolution.ts @@ -0,0 +1,173 @@ +import type { ResolveIdentityConflictRequest } from "@/generated/api" + +/** + * Resolving an identity conflict. + * + * An open conflict means two Billing Customers both have a claim on the same + * purchase or alias, and Mosaic has frozen the disputed subject rather than + * pick one. Resolving it moves real purchases between real people, so there is + * deliberately no one-click merge anywhere in this feature and no heuristic + * that reaches a resolution on its own — automatic merge stays an ADR + * checkpoint, not something a dashboard button does. + * + * What replaces it is a sequence the operator cannot skip: choose the + * resolution, read the consequence of that specific choice, write down why, + * then acknowledge it explicitly. + */ + +export type ConflictAction = ResolveIdentityConflictRequest["action"] + +export const conflictActions = [ + "keep_existing", + "reassign_to_candidate", + "operator_split", +] as const satisfies readonly ConflictAction[] + +const ACTION_LABELS: Record = { + keep_existing: "Keep the existing customer", + operator_split: "Split — neither claim wins", + reassign_to_candidate: "Reassign to the candidate", +} + +export function conflictActionLabel(value: string | undefined) { + if (!value) return "No resolution recorded" + return ACTION_LABELS[value as ConflictAction] ?? value.replaceAll("_", " ") +} + +/** + * The consequence sentence. + * + * Each one names what happens to *both* parties, because the failure this + * screen exists to prevent is an operator who reads only the outcome for the + * customer in front of them and does not notice that the other one loses + * access. + */ +const ACTION_CONSEQUENCES: Record = { + keep_existing: + "The disputed purchase stays with the customer that already held it. The challenger keeps whatever it held before and gains nothing from this purchase — if a real person is behind the challenger, they will still have no access to what this purchase grants. Projection unfreezes and recomputes from the existing association.", + operator_split: + "Neither customer takes the disputed purchase. Both are unfrozen and reprojected without it, so whichever person actually made the purchase loses the access it grants until the association is established again. Choose this when the evidence does not identify an owner and granting the wrong person access is worse than granting nobody.", + reassign_to_candidate: + "The disputed purchase moves to the challenger. The customer that held it is reprojected without it, so if it was their only source they lose the access it grants — immediately, and without anything on their side changing. Both customers are unfrozen and recomputed.", +} + +export function conflictActionConsequence(value: ConflictAction) { + return ACTION_CONSEQUENCES[value] +} + +const DIAGNOSTIC_EXPLANATIONS: Record = { + application_user_alias_claims_two_customers: + "One application user ID was asserted for two different Billing Customers, each with its own purchases. Login attaches an alias; it never merges customers, so Mosaic held both rather than combining them.", + multiple_customers_claim_lineage: + "Two Billing Customers hold evidence claiming the same Purchase Lineage. Only one person made the purchase, so at most one claim is right.", + reassignment_requires_operator_resolution: + "New evidence would move an already-associated purchase to a different customer. Reassignment is never automatic: it is the operation that can silently take access from whoever holds it now.", +} + +export function conflictDiagnosticExplanation(value: string | undefined) { + if (!value) return "Mosaic recorded no diagnostic for this conflict." + return ( + DIAGNOSTIC_EXPLANATIONS[value] ?? + "Mosaic recorded a diagnostic this build does not have copy for. The conflict is still frozen and still requires a deliberate resolution." + ) +} + +export const CONFLICT_FREEZE_NOTE = + "While this conflict is open the disputed subject is frozen: access is granted to neither candidate, the last committed state is preserved, and nothing is merged automatically." + +/** + * Conflicts are Project-scoped while most billing surfaces are + * Environment-scoped, so the list says so rather than appearing to be filtered + * to the Environment in the address. An operator who assumes the narrower scope + * would conclude a conflict was resolved when it merely belongs to a sibling + * Environment. + */ +export const CONFLICT_PROJECT_SCOPE_NOTE = + "Identity conflicts are Project-wide. A Billing Customer's identity belongs to the Project, so this list is not filtered to the Mosaic Environment in the address and may include conflicts about purchases in another Environment." + +export type ResolutionBlockedReason = + | "acknowledgement_required" + | "action_required" + | "already_resolving" + | "assignment_mismatch" + | "no_permission" + | "reason_required" + +export interface ResolutionGate { + allowed: boolean + blockedBy?: ResolutionBlockedReason + explanation?: string +} + +const BLOCKED_EXPLANATIONS: Record = { + acknowledgement_required: + "Confirm you have read what happens to both customers. This moves real purchases between real people and cannot be undone by re-running it.", + action_required: "Choose how this conflict should be resolved.", + already_resolving: "Mosaic is recording this resolution.", + assignment_mismatch: + "The customer named for assignment does not match the resolution chosen. Mosaic refuses a resolution whose stated winner and stated action disagree.", + no_permission: "Resolving an identity conflict requires organization owner or admin permission.", + reason_required: + "Give the reason for this resolution. It is recorded on the conflict and on the audit event, and it is what an investigation reads when someone asks why their purchase moved.", +} + +/** + * The single decision point for whether the resolution can be submitted. + * + * Every condition is required simultaneously, so there is no ordering in which + * an operator reaches a submittable state without having seen the consequence + * and written down a reason. + */ +export function evaluateResolutionGate(input: { + acknowledged: boolean + action: string | undefined + assignedBillingCustomerId?: string | undefined + canManage: boolean + firstCustomerId?: string | undefined + isSubmitting: boolean + reason: string + secondCustomerId?: string | undefined +}): ResolutionGate { + const gate = (blockedBy: ResolutionBlockedReason): ResolutionGate => ({ + allowed: false, + blockedBy, + explanation: BLOCKED_EXPLANATIONS[blockedBy], + }) + + if (!input.canManage) return gate("no_permission") + if (input.isSubmitting) return gate("already_resolving") + if (!input.action || !conflictActions.includes(input.action as ConflictAction)) { + return gate("action_required") + } + if (input.reason.trim().length === 0) return gate("reason_required") + + // `assignedBillingCustomerId` is optional, but when it is sent it must name + // the party the action already implies. The server enforces this; catching it + // here keeps the refusal next to the control that caused it. + const expected = expectedAssignee({ + action: input.action as ConflictAction, + firstCustomerId: input.firstCustomerId, + secondCustomerId: input.secondCustomerId, + }) + if (input.assignedBillingCustomerId && expected && input.assignedBillingCustomerId !== expected) { + return gate("assignment_mismatch") + } + + if (!input.acknowledged) return gate("acknowledgement_required") + + return { allowed: true } +} + +/** + * Which customer an action implies. `operator_split` implies none, which is why + * it returns undefined rather than a placeholder. + */ +export function expectedAssignee(input: { + action: ConflictAction + firstCustomerId?: string | undefined + secondCustomerId?: string | undefined +}) { + if (input.action === "keep_existing") return input.firstCustomerId + if (input.action === "reassign_to_candidate") return input.secondCustomerId + return undefined +} diff --git a/apps/dashboard/src/features/billing-customers/types/customer-search.test.ts b/apps/dashboard/src/features/billing-customers/types/customer-search.test.ts new file mode 100644 index 00000000..be42b6bc --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/types/customer-search.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest" + +import { + customerIdentifierTypes, + describeLookupMiss, + identifierTypeHelp, + MAX_IDENTIFIER_LENGTH, + SEARCH_HELPER_TEXT, + validateCustomerSearch, +} from "@/features/billing-customers/types/customer-search" + +/** + * Customer lookup is typed-identifier only. + * + * Two risks are protected. The first is scope creep into free-text search: + * Mosaic Billing stores aliases as digests and holds no names or email + * addresses, so a free-text box could only ever be answered by personal data + * the storage design exists to avoid keeping — and offering the box teaches + * operators that the data is there. The accepted set is asserted here so + * widening it is a deliberate, reviewed change rather than a one-line addition. + * + * The second is a miss being rendered as a failure. The API answers + * `200 {found:false}` precisely so "this person has no billing record" reads as + * an answer; a red error banner would read as "Mosaic is broken" and send an + * operator debugging instead of concluding. + */ +describe("customer lookup identifier gating", () => { + it("accepts exactly the three Mosaic-issued identifier types", () => { + expect(customerIdentifierTypes).toEqual([ + "billing_customer_id", + "application_user_id", + "installation_id", + ]) + }) + + it.each(["email", "name", "display_name", "receipt", ""])( + "refuses the unsupported identifier type %j", + (identifierType) => { + expect( + validateCustomerSearch({ identifierType, identifierValue: "someone@example.com" }), + ).toBe("unsupported_type") + }, + ) + + it("requires a non-empty value within the contract's bound", () => { + expect( + validateCustomerSearch({ identifierType: "application_user_id", identifierValue: " " }), + ).toBe("empty") + expect( + validateCustomerSearch({ + identifierType: "application_user_id", + identifierValue: "x".repeat(MAX_IDENTIFIER_LENGTH + 1), + }), + ).toBe("too_long") + expect( + validateCustomerSearch({ identifierType: "billing_customer_id", identifierValue: "cus_01" }), + ).toBeUndefined() + }) + + it("tells the operator what is accepted and that no free-text search exists", () => { + expect(SEARCH_HELPER_TEXT).toContain("Billing Customer ID") + expect(SEARCH_HELPER_TEXT).toContain("application user ID") + expect(SEARCH_HELPER_TEXT).toContain("installation ID") + expect(SEARCH_HELPER_TEXT).toContain("no free-text search") + }) + + it("warns that an application user ID is not an email address", () => { + // The single most likely wrong input, named explicitly where it is typed. + expect(identifierTypeHelp("application_user_id")).toContain("Not an email address") + }) + + it("describes a miss as an answer rather than a failure", () => { + const message = describeLookupMiss("application_user_id") + expect(message).toContain("an answer, not a failure") + expect(message.toLowerCase()).not.toContain("error") + }) +}) diff --git a/apps/dashboard/src/features/billing-customers/types/customer-search.ts b/apps/dashboard/src/features/billing-customers/types/customer-search.ts new file mode 100644 index 00000000..dc8eb424 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/types/customer-search.ts @@ -0,0 +1,98 @@ +import type { BillingCustomerLookupRequest } from "@/generated/api" + +/** + * Typed-identifier customer lookup. + * + * There is deliberately no free-text search box on this surface. A box that + * accepts "anything" is a box operators type email addresses and names into, + * and Mosaic Billing holds neither: aliases are stored as SHA-256 digests, so a + * free-text query could only ever be answered by matching against personal data + * Mosaic has gone to some length not to keep. Worse, offering the box would + * teach operators the data exists. + * + * So a lookup names its identifier *type* first, and the value is submitted in + * a POST body rather than a query string so the raw identifier stays out of + * access logs, referrers, and browser history. + */ + +export type CustomerIdentifierType = BillingCustomerLookupRequest["identifierType"] + +export const customerIdentifierTypes = [ + "billing_customer_id", + "application_user_id", + "installation_id", +] as const satisfies readonly CustomerIdentifierType[] + +const IDENTIFIER_LABELS: Record = { + application_user_id: "Application user ID", + billing_customer_id: "Billing Customer ID", + installation_id: "Installation ID", +} + +const IDENTIFIER_HELP: Record = { + application_user_id: + "The identifier your own backend uses for this user — the value it passed when it identified them to Mosaic. Not an email address and not a display name.", + billing_customer_id: "The Mosaic Billing Customer ID, as it appears on any billing surface.", + installation_id: + "A Mosaic installation identifier from an SDK. It resolves through recorded evidence only: an installation ID can never by itself select a customer, so a match here means Mosaic already associated that installation with a purchase.", +} + +export function identifierTypeLabel(value: CustomerIdentifierType) { + return IDENTIFIER_LABELS[value] +} + +export function identifierTypeHelp(value: CustomerIdentifierType) { + return IDENTIFIER_HELP[value] +} + +/** + * The one sentence rendered above the control, naming every accepted + * identifier. Stating what is accepted is also how the surface says what it + * will never accept. + */ +export const SEARCH_HELPER_TEXT = + "Look a customer up by Billing Customer ID, by the application user ID your backend assigned, or by an installation ID. Mosaic Billing stores no email addresses, names, or other personal details, so there is nothing else to search by and no free-text search exists." + +export const SEARCH_PRIVACY_NOTE = + "The identifier is digested server-side. It is never stored, never written to a log, and never echoed back in the response." + +export type SearchIssue = "empty" | "too_long" | "unsupported_type" + +const ISSUE_MESSAGES: Record = { + empty: "Enter the identifier to look up.", + too_long: "That identifier is longer than any identifier Mosaic issues or accepts.", + unsupported_type: "Choose which kind of identifier this is before looking it up.", +} + +/** Matches the contract's own bound on an application user identifier. */ +export const MAX_IDENTIFIER_LENGTH = 512 + +export function validateCustomerSearch(input: { + identifierType: string + identifierValue: string +}): SearchIssue | undefined { + if (!customerIdentifierTypes.includes(input.identifierType as CustomerIdentifierType)) { + return "unsupported_type" + } + const value = input.identifierValue.trim() + if (value.length === 0) return "empty" + if (value.length > MAX_IDENTIFIER_LENGTH) return "too_long" + return undefined +} + +export function describeSearchIssue(issue: SearchIssue) { + return ISSUE_MESSAGES[issue] +} + +/** + * A miss is a result, not an error. + * + * The API answers `200 {found: false}` precisely so the dashboard renders "no + * customer matches that identifier" rather than a failure banner. An operator + * checking whether a user has ever purchased gets an answer either way, and a + * red error state would read as "Mosaic is broken" when the correct reading is + * "this person has no billing record". + */ +export function describeLookupMiss(identifierType: CustomerIdentifierType) { + return `No Billing Customer in this Mosaic Environment matches that ${IDENTIFIER_LABELS[identifierType]}. That is an answer, not a failure: a customer exists only once your backend has identified the user or a validated purchase has attached to them.` +} diff --git a/apps/dashboard/src/features/billing-customers/types/entitlement-vocabulary.test.ts b/apps/dashboard/src/features/billing-customers/types/entitlement-vocabulary.test.ts new file mode 100644 index 00000000..eb899c22 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/types/entitlement-vocabulary.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest" + +import { + accessStateExplanation, + accessStateLabel, + accessStateSubject, + accessStateTone, + accessStates, + explanationSentence, + sourceEndStatement, + subscriptionAccessStatement, +} from "@/features/billing-customers/types/entitlement-vocabulary" + +/** + * These tests protect one rule, and it is the rule the whole phase turns on: + * "Mosaic cannot answer" must never be rendered as "the customer has no + * access". The realistic failure is not a typo — it is a contract member added + * after this build (or a truncated payload) falling through a lookup into the + * `inactive` copy or a destructive tone, at which point a support agent reads a + * projection outage as a churned customer and acts on it. + */ +describe("entitlement access vocabulary", () => { + const inactiveLabel = accessStateLabel("inactive") + const inactiveExplanation = accessStateExplanation("inactive") + + it("gives every access state a distinct label", () => { + const labels = accessStates.map((state) => accessStateLabel(state)) + expect(new Set(labels).size).toBe(accessStates.length) + }) + + it.each(["unknown", "unavailable"])( + "never renders %s with the inactive label, copy, or a destructive tone", + (state) => { + expect(accessStateLabel(state)).not.toBe(inactiveLabel) + expect(accessStateExplanation(state)).not.toBe(inactiveExplanation) + expect(accessStateTone(state)).toBe("attention") + expect(accessStateExplanation(state)).toContain("not the same as inactive") + }, + ) + + it("treats a determined absence of access as an answer rather than a fault", () => { + // Destructive tone on `inactive` is what trains an operator to read every + // non-active customer as a problem, which hides the two states that are. + expect(accessStateTone("inactive")).toBe("neutral") + expect(accessStateTone("active")).toBe("positive") + }) + + it.each(["", "provisionally_active", "suspended", "INACTIVE"])( + "degrades the unrecognised member %j to attention rather than to inactive", + (state) => { + expect(accessStateTone(state)).toBe("attention") + expect(accessStateLabel(state)).not.toBe(inactiveLabel) + expect(accessStateExplanation(state)).not.toBe(inactiveExplanation) + }, + ) + + it("names the reason inside the undetermined sentence", () => { + const sentence = accessStateExplanation("unknown", { reason: "identity_unresolved" }) + expect(sentence).toContain("Mosaic cannot currently determine access") + expect(sentence).toContain("identity conflict") + }) + + it("attributes unavailable and unknown to Mosaic, not to the customer", () => { + expect(accessStateSubject("unavailable")).toBe("mosaic") + expect(accessStateSubject("unknown")).toBe("mosaic") + expect(accessStateSubject("inactive")).toBe("customer") + expect(accessStateSubject("active")).toBe("customer") + }) +}) + +/** + * Cancellation flips renewal intent only. A merged status pill has to choose + * between "Cancelled" (read as access gone) and "Active" (hides that renewal + * stopped); both are wrong, and the first one is the one that gets acted on. + */ +describe("cancelled subscription still holding access", () => { + it("states auto-renew disabled and access until the period end, never inactive", () => { + const statement = subscriptionAccessStatement({ + accessState: "active", + periodEnd: "2026-09-01T12:00:00Z", + renewalIntent: "auto_renew_disabled", + }) + + expect(statement.renewal).toContain("Auto-renew disabled") + expect(statement.access).toBe("Active until 2026-09-01 12:00:00 UTC") + expect(statement.access).not.toBe(accessStateLabel("inactive")) + }) + + it("does not invent an end date when the projection states none", () => { + const statement = subscriptionAccessStatement({ + accessState: "active", + periodEnd: undefined, + renewalIntent: "auto_renew_enabled", + }) + expect(statement.access).toBe("Active with no end Mosaic can state") + }) +}) + +describe("entitlement source and explanation copy", () => { + it("renders a permanent source as having no finite end rather than as expired", () => { + // A one-time non-consumable has no `end`. Formatting an absent end as a + // date, or reading it as elapsed, is the false-expiry failure. + expect(sourceEndStatement({})).toBe("No finite end — this source does not expire") + expect(sourceEndStatement({ end: "2026-01-01T00:00:00Z" })).toContain("Ends 2026-01-01") + }) + + it("shows an unrecognised explanation code verbatim instead of paraphrasing it", () => { + // The contract's explanation vocabulary is closed: a reader may render its + // own copy for a code but must never invent one. + const sentence = explanationSentence("some_future_code") + expect(sentence).toContain("some_future_code") + expect(sentence).toContain("no copy for it") + }) + + it("keeps the cancellation explanation about renewal intent", () => { + expect(explanationSentence("subscription_cancelled_access_until_period_end")).toContain( + "Access continues until the validated period end", + ) + }) +}) diff --git a/apps/dashboard/src/features/billing-customers/types/entitlement-vocabulary.ts b/apps/dashboard/src/features/billing-customers/types/entitlement-vocabulary.ts new file mode 100644 index 00000000..61529263 --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/types/entitlement-vocabulary.ts @@ -0,0 +1,642 @@ +import type { EntitlementEntry, EntitlementSourceSummary, Uncertainty } from "@/generated/api" + +/** + * Frozen vocabulary for authoritative customer access. + * + * Phase 9A froze the words the ledger may use. Phase 9B adds the harder half: + * this is the first Mosaic surface that states whether a person has access, and + * the single most consequential mistake it can make is collapsing "Mosaic + * cannot answer" into "no access". A support agent who reads `unknown` as + * `inactive` refunds a paying customer; an operator who reads `unavailable` as + * `inactive` concludes an outage is a churn event. + * + * So four access labels exist and none of them share copy or tone: + * + * - `active` — a positive determination that access exists. + * - `inactive` — a positive determination that it does not. Neutral tone: this + * is an answer, not a fault. + * - `unknown` — Mosaic declines to answer about this customer. Attention tone, + * always accompanied by the reason and by the explicit sentence + * that this is not the same as inactive. + * - `unavailable` — Mosaic could not answer at all (billing disabled, service + * failure). It describes Mosaic, never the customer. Attention + * tone. It is never persisted on a snapshot entry, only produced + * at read time, which is why it is absent from `EntitlementEntry`. + * + * Every lookup here degrades an unrecognised member to the attention tone, never + * to the inactive one. A contract member added after this build must read as + * "Mosaic does not recognise this state" rather than quietly assert that a + * paying customer has nothing. + */ + +/** The read-time access vocabulary. Wider than `EntitlementEntry["state"]`. */ +export type AccessState = EntitlementEntry["state"] | "unavailable" + +export type AccessTone = "attention" | "negative" | "neutral" | "positive" + +export const accessStates = ["active", "inactive", "unknown", "unavailable"] as const + +const ACCESS_STATE_LABELS: Record = { + active: "Access active", + inactive: "No access", + unavailable: "Mosaic cannot answer", + unknown: "Access undetermined", +} + +const ACCESS_STATE_TONES: Record = { + active: "positive", + // A determined absence of access is an answer, not a failure. Destructive + // tone here trains operators to read every non-active customer as a problem + // and makes the two states that *are* problems invisible by comparison. + inactive: "neutral", + unavailable: "attention", + unknown: "attention", +} + +export function accessStateLabel(value: string | undefined) { + if (!value) return "Access undetermined" + return ACCESS_STATE_LABELS[value] ?? "Access state Mosaic does not recognise" +} + +/** + * Tone for an access state. + * + * Anything this build does not recognise is `attention`. Falling back to the + * `inactive` tone would render a future contract member as a confident denial + * of access, which is exactly the failure the four-label split exists to + * prevent. + */ +export function accessStateTone(value: string | undefined): AccessTone { + if (!value) return "attention" + return ACCESS_STATE_TONES[value] ?? "attention" +} + +const UNCERTAINTY_REASON_SENTENCES: Record = { + conflicting_facts: "two store-confirmed facts contradict each other for this purchase", + identity_unresolved: + "an identity conflict froze this purchase, so Mosaic will not attribute it to either candidate", + missing_fact: "a fact this state depends on has not arrived", + none: "no uncertainty was recorded", + product_unresolved: "the store confirmed a Product this Project does not map", + projection_failed: "the last projection run for this customer failed", + provider_unavailable: "the store could not be reached to confirm the current state", + stale_validation: "the newest store confirmation is older than the freshness threshold", + unsupported_provider_state: "the store reported a state this build does not model", +} + +const UNCERTAINTY_REASON_LABELS: Record = { + conflicting_facts: "Conflicting facts", + identity_unresolved: "Identity unresolved", + missing_fact: "Missing fact", + none: "None", + product_unresolved: "Product unresolved", + projection_failed: "Projection failed", + provider_unavailable: "Store unavailable", + stale_validation: "Stale validation", + unsupported_provider_state: "Unsupported store state", +} + +const EXPECTED_RESOLUTION_LABELS: Record = { + automatic_retry: "Mosaic retries automatically", + customer_action: "The customer has to act", + next_projection_run: "Resolves on the next projection run", + next_provider_notification: "Resolves when the store sends the next notification", + none_expected: "Nothing will resolve this on its own", + operator_action: "An operator has to act", +} + +function humanize(value: string) { + const spaced = value.replaceAll("_", " ") + return spaced.charAt(0).toUpperCase() + spaced.slice(1) +} + +export function uncertaintyReasonLabel(value: string | undefined) { + if (!value) return "None" + return UNCERTAINTY_REASON_LABELS[value] ?? humanize(value) +} + +/** + * The clause that completes the `unknown` sentence. Lower-case and without + * terminal punctuation because it is always embedded, never rendered alone. + */ +export function uncertaintyReasonClause(value: string | undefined) { + if (!value) return "Mosaic recorded no reason" + return ( + UNCERTAINTY_REASON_SENTENCES[value] ?? + `the store reported ${humanize(value).toLowerCase()}, which this build does not model` + ) +} + +export function expectedResolutionLabel(value: string | undefined) { + if (!value) return "Mosaic did not state how this resolves" + return EXPECTED_RESOLUTION_LABELS[value] ?? humanize(value) +} + +/** + * The full sentence rendered beside an access pill. + * + * `unknown` and `unavailable` both carry the explicit disclaimer, because the + * pill alone is exactly the kind of two-word summary that gets screenshotted + * into a support thread and read as a denial. + */ +export function accessStateExplanation( + value: string | undefined, + uncertainty?: Uncertainty | undefined, +): string { + switch (value) { + case "active": + return "Mosaic has determined this customer has access." + case "inactive": + return "Mosaic has determined this customer does not have access. This is an answer, not a failure — the evidence was sufficient to decide." + case "unavailable": + return "Mosaic could not answer. This describes Mosaic's own availability, not the customer: their access is unchanged and unjudged. This is not the same as inactive." + case "unknown": + return `Mosaic cannot currently determine access — ${uncertaintyReasonClause(uncertainty?.reason)}. This is not the same as inactive.` + default: + return "Mosaic reported an access state this build does not recognise. Treat it as undetermined rather than as a denial of access, and check whether the dashboard is older than the API." + } +} + +/** + * `unavailable` says something about Mosaic; the other three say something + * about the customer. Surfaces use this to caption the pill correctly instead + * of labelling a service failure as a customer attribute. + */ +export function accessStateSubject(value: string | undefined): "customer" | "mosaic" { + return value === "unavailable" || value === "unknown" ? "mosaic" : "customer" +} + +// --------------------------------------------------------------------------- +// The five separate subscription axes +// --------------------------------------------------------------------------- + +/** + * These are five axes, never one merged status. + * + * A cancelled subscription that still has access is the case that proves it: a + * single merged pill has to choose between "Cancelled" (which reads as access + * gone, and is what support agents act on) and "Active" (which hides that + * renewal was turned off). Both are wrong. Five pills state both facts. + */ +const LIFECYCLE_STATE_LABELS: Record = { + active: "Active", + billing_retry: "Billing retry", + expired: "Expired", + grace_period: "Grace period", + paused: "Paused", + refunded: "Refunded", + revoked: "Revoked", + superseded: "Superseded by a later purchase", + trialing: "Trialing", + unknown: "Lifecycle undetermined", +} + +const LIFECYCLE_STATE_TONES: Record = { + active: "positive", + billing_retry: "attention", + expired: "neutral", + grace_period: "attention", + paused: "neutral", + refunded: "neutral", + revoked: "negative", + superseded: "neutral", + trialing: "positive", + unknown: "attention", +} + +const RENEWAL_INTENT_LABELS: Record = { + auto_renew_disabled: "Auto-renew disabled", + auto_renew_enabled: "Auto-renew enabled", + paused: "Renewal paused", + provider_managed: "Renewal managed by the store", + unknown: "Renewal intent undetermined", +} + +const BILLING_STATE_LABELS: Record = { + current: "Billing current", + failed: "Billing failed", + grace: "Billing in grace", + refunded: "Refunded", + retrying: "Billing retrying", + revoked: "Revoked", + unknown: "Billing state undetermined", +} + +const BILLING_STATE_TONES: Record = { + current: "positive", + failed: "negative", + grace: "attention", + refunded: "neutral", + retrying: "attention", + revoked: "negative", + unknown: "attention", +} + +export function lifecycleStateLabel(value: string | undefined) { + if (!value) return "Lifecycle undetermined" + return LIFECYCLE_STATE_LABELS[value] ?? humanize(value) +} + +export function lifecycleStateTone(value: string | undefined): AccessTone { + if (!value) return "attention" + return LIFECYCLE_STATE_TONES[value] ?? "attention" +} + +export function renewalIntentLabel(value: string | undefined) { + if (!value) return "Renewal intent undetermined" + return RENEWAL_INTENT_LABELS[value] ?? humanize(value) +} + +/** + * Renewal intent is never an access signal. `auto_renew_disabled` on an + * otherwise current subscription is neutral, not negative: the customer keeps + * access until the period ends and colouring it red is what makes operators + * revoke early. + */ +export function renewalIntentTone(value: string | undefined): AccessTone { + if (!value) return "attention" + return value === "unknown" ? "attention" : "neutral" +} + +export function billingStateLabel(value: string | undefined) { + if (!value) return "Billing state undetermined" + return BILLING_STATE_LABELS[value] ?? humanize(value) +} + +export function billingStateTone(value: string | undefined): AccessTone { + if (!value) return "attention" + return BILLING_STATE_TONES[value] ?? "attention" +} + +export function uncertaintyTone(value: string | undefined): AccessTone { + return !value || value === "none" ? "neutral" : "attention" +} + +/** + * Renders the cancellation case correctly. + * + * Cancellation flips renewal intent only (plan section 7). The two sentences + * this returns are the contract with the reader: the subscription will not + * renew, *and* access continues until the validated period end. Neither + * sentence may be dropped, and neither may be replaced with "Cancelled". + */ +export function subscriptionAccessStatement(input: { + accessState: string | undefined + periodEnd: string | undefined + renewalIntent: string | undefined +}): { access: string; renewal: string } { + const renewal = + input.renewalIntent === "auto_renew_disabled" + ? "Auto-renew disabled. The store will not charge again." + : renewalIntentLabel(input.renewalIntent) + + if (input.accessState !== "active") { + return { access: accessStateLabel(input.accessState), renewal } + } + + return { + access: input.periodEnd + ? `Active until ${formatEntitlementInstant(input.periodEnd)}` + : "Active with no end Mosaic can state", + renewal, + } +} + +// --------------------------------------------------------------------------- +// Explanation codes and sources +// --------------------------------------------------------------------------- + +/** + * The contract's closed explanation vocabulary + * (`protocol/schema/authoritative-entitlement/v1`). A reader may render its own + * copy for a code but must never invent one, so an unrecognised code renders as + * the raw code plus an explicit "this build does not have copy for it" rather + * than as an invented sentence. + */ +const EXPLANATION_SENTENCES: Record = { + active_billing_retry_allowance: + "The store is retrying a failed charge and this Project's grant version allows access during billing retry.", + active_grace_period: + "The charge failed and the store opened a grace period. Both stores grant access during grace, and so does Mosaic.", + active_subscription_period: "The store confirmed a paid period that covers this instant.", + active_trial_period: + "The store confirmed an introductory or free trial period covering this instant.", + billing_disabled: + "Mosaic Billing is turned off for this Project, so Mosaic holds no authoritative answer. This is a Mosaic state, not a customer state.", + conflicting_facts: + "Two store-confirmed facts contradict each other. Mosaic keeps both and declines to choose.", + family_shared_source: "Access comes from a Family Sharing transaction on another store account.", + grant_version_ended: + "The grant version that applied to this purchase has ended and no later version grants this Entitlement.", + identity_unresolved: + "An identity conflict froze this purchase. Neither candidate customer is granted anything until an operator resolves it.", + no_qualifying_source: "No purchase Mosaic holds grants this Entitlement at this instant.", + permanent_one_time_purchase: + "A non-consumable purchase grants this permanently. There is no expiry date to state.", + product_unresolved: + "The store confirmed a purchase of a Product this Project does not map, so Mosaic cannot say what it grants.", + projection_failed: + "The last projection run for this customer failed. The previously committed state is preserved rather than replaced with a guess.", + provider_evidence_stale: + "The newest store confirmation is older than the freshness threshold, so Mosaic will not assert the current state.", + provider_unavailable: + "The store could not be reached, so Mosaic will not assert the current state.", + scheduled_pause_not_yet_effective: + "A pause is scheduled but has not taken effect. Access continues until it does.", + subscription_cancelled_access_until_period_end: + "Auto-renew was turned off. Access continues until the validated period end; cancellation changes renewal intent only.", + subscription_expired: "The paid period ended and no later period was confirmed.", + subscription_paused: "The subscription is paused. Google's pause never grants access.", + subscription_refunded: "The store confirmed a refund effective at the recorded instant.", + subscription_revoked: "The store revoked this purchase effective at the recorded instant.", + subscription_superseded: + "A later purchase replaced this one. Nothing was deleted; the replacement is recorded explicitly.", + unsupported_provider_state: "The store reported a state this build does not model.", +} + +export function explanationSentence(code: string | undefined) { + if (!code) return "Mosaic recorded no explanation for this entry." + const sentence = EXPLANATION_SENTENCES[code] + if (sentence) return sentence + return `Mosaic reported the explanation code "${code}". This dashboard build has no copy for it, so the code is shown verbatim rather than paraphrased.` +} + +const SOURCE_TYPE_LABELS: Record = { + active_subscription: "Active subscription", + billing_retry: "Subscription in billing retry", + family_shared: "Family Sharing", + grace_period: "Subscription in grace", + one_time_non_consumable: "One-time purchase", + trial: "Trial", +} + +export function sourceTypeLabel(value: string | undefined) { + if (!value) return "Unclassified source" + return SOURCE_TYPE_LABELS[value] ?? humanize(value) +} + +const SOURCE_STATE_LABELS: Record = { + granting: "Granting access", + not_granting: "Not granting access", + unknown: "Contribution undetermined", +} + +export function sourceStateLabel(value: string | undefined) { + if (!value) return "Contribution undetermined" + return SOURCE_STATE_LABELS[value] ?? humanize(value) +} + +export function sourceStateTone(value: string | undefined): AccessTone { + if (value === "granting") return "positive" + if (value === "not_granting") return "neutral" + return "attention" +} + +/** + * A permanent source has no finite end Mosaic can state. Rendering an empty + * `end` as an expiry date, or as "expired", is the false-expiry bug the + * aggregation rules exist to prevent. + */ +export function sourceEndStatement(source: Pick) { + return source.end + ? `Ends ${formatEntitlementInstant(source.end)}` + : "No finite end — this source does not expire" +} + +const PROJECTION_STATUS_LABELS: Record = { + current: "Current", + degraded: "Degraded", + failed: "Failed", + pending: "Projection pending", + stale: "Stale", +} + +const PROJECTION_STATUS_EXPLANATIONS: Record = { + current: "The committed state reflects every fact Mosaic holds for this customer.", + degraded: + "Mosaic committed a state but could not use every input it wanted. Entries derived from the missing inputs read undetermined.", + failed: + "The last projection run failed. The previously committed state is preserved rather than replaced.", + pending: + "Facts are waiting to be projected. The committed state is older than the evidence, which is why entries can read undetermined rather than inactive.", + stale: "The committed state is older than the staleness threshold for this Environment.", +} + +export function projectionStatusLabel(value: string | undefined) { + if (!value) return "Projection status unknown" + return PROJECTION_STATUS_LABELS[value] ?? humanize(value) +} + +export function projectionStatusExplanation(value: string | undefined) { + if (!value) return "Mosaic did not report a projection status for this customer." + return ( + PROJECTION_STATUS_EXPLANATIONS[value] ?? + "Mosaic reported a projection status this build does not recognise. Treat the committed state as possibly out of date." + ) +} + +export function projectionStatusTone(value: string | undefined): AccessTone { + if (value === "current") return "positive" + if (value === "failed") return "negative" + return "attention" +} + +/** + * Access arguments are made in UTC. The customer surfaces never localise, for + * the same reason the 9A ledger does not: a period end read in two timezones is + * two different support answers. + */ +export function formatEntitlementInstant(value: string | undefined) { + if (!value) return "—" + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return value + return `${parsed.toISOString().slice(0, 19).replace("T", " ")} UTC` +} + +/** + * The header pair every authoritative surface carries. `asOf` is the instant the + * projection reasoned about; `lastProjectedAt` is when the run happened. They + * are different clocks and are never merged, exactly as `occurredAt` and + * `recordedAt` are not merged on the ledger. + */ +export const AUTHORITATIVE_TIMESTAMP_NOTE = + "As of is the instant the projection reasoned about. Last projected is when the projection run committed. They are never the same clock." + +// --------------------------------------------------------------------------- +// Customer, alias, and lineage vocabulary +// --------------------------------------------------------------------------- + +/** + * Aliases are rendered as *types and protected representations only*. + * + * There is no value field and no digest field on this surface, and that is not + * an omission to be worked around: a digest is still a stable per-person + * identifier, so rendering one would recreate exactly the tracking key the + * digest-only storage design exists to avoid. What an operator gets is what + * kind of identity this is, who asserted it, and whether it is still active — + * enough to reason about an attribution, not enough to identify a person. + */ +const ALIAS_TYPE_LABELS: Record = { + apple_app_account_token: "Apple app account token", + application_user_id: "Application user ID", + google_obfuscated_account_id: "Google obfuscated account ID", + installation_id: "Installation ID", +} + +const ALIAS_TYPE_NOTES: Record = { + apple_app_account_token: + "Parsed server-side from Apple's signed payload. Evidence of who made the purchase.", + application_user_id: + "Your own user identifier, asserted by your backend. This is the alias that makes a customer identified.", + google_obfuscated_account_id: + "Parsed server-side from Google's payload. Evidence of who made the purchase.", + installation_id: + "A device-local identifier. Evidence and attribution only — it can never create or select a customer, because a guessed installation ID would otherwise read someone else's entitlements.", +} + +export function aliasTypeLabel(value: string | undefined) { + if (!value) return "Unclassified alias" + return ALIAS_TYPE_LABELS[value] ?? humanize(value) +} + +export function aliasTypeNote(value: string | undefined) { + if (!value) return undefined + return ALIAS_TYPE_NOTES[value] +} + +const SOURCE_AUTHORITY_LABELS: Record = { + operator: "Recorded by an operator", + provider_payload: "Parsed from a store payload", + restore: "Established by a restore", + sdk_installation: "Asserted by an SDK installation", + trusted_server: "Asserted by your backend", +} + +export function sourceAuthorityLabel(value: string | undefined) { + if (!value) return "Unknown authority" + return SOURCE_AUTHORITY_LABELS[value] ?? humanize(value) +} + +const VERIFICATION_STATUS_LABELS: Record = { + asserted: "Asserted", + verified: "Verified", +} + +export function verificationStatusLabel(value: string | undefined) { + if (!value) return "Unclassified" + return VERIFICATION_STATUS_LABELS[value] ?? humanize(value) +} + +/** + * The distinction an operator needs first when a customer list looks larger + * than the user base. + * + * A purchase-anchored customer is not a defect or a duplicate: it is revenue + * whose owner has not been named yet, which is the correct resting state for an + * anonymous purchase. Labelling it as "unknown" or "orphaned" invites someone + * to clean it up, and deleting it would strand a real purchase. + */ +export function customerIdentityLabel(customer: { + identified?: boolean + purchaseAnchored?: boolean +}) { + if (customer.identified) return "Identified" + if (customer.purchaseAnchored) return "Purchase-anchored · not yet identified" + return "No identity or purchase recorded" +} + +export function customerIdentityExplanation(customer: { + identified?: boolean + purchaseAnchored?: boolean +}) { + if (customer.identified) { + return "An application-user alias is active, so a person your backend named is attached to this customer." + } + if (customer.purchaseAnchored) { + return "A validated purchase is attached but no application-user alias is. This is the correct resting state for an anonymous purchase: the revenue is anchored to the store's own purchase chain, which survives reinstall and device changes, and identifying the user later attaches rather than merges." + } + return "Neither an application-user alias nor a purchase lineage is recorded in this Mosaic Environment." +} + +const CUSTOMER_STATUS_LABELS: Record = { + absorbed: "Absorbed purchase anchor", + active: "Active", + anonymized: "Anonymized", + frozen: "Frozen", +} + +export function customerStatusLabel(value: string | undefined) { + if (!value) return "Unclassified" + return CUSTOMER_STATUS_LABELS[value] ?? humanize(value) +} + +export function customerStatusTone(value: string | undefined): AccessTone { + if (value === "active") return "positive" + if (value === "frozen") return "attention" + return "neutral" +} + +const CUSTOMER_DIAGNOSTICS_LABELS: Record = { + identity_conflict: "Identity conflict", + none: "None", + projection_failed: "Projection failed", + projection_stale: "Projection stale", +} + +export function customerDiagnosticsLabel(value: string | undefined) { + if (!value || value === "none") return "None" + return CUSTOMER_DIAGNOSTICS_LABELS[value] ?? humanize(value) +} + +const LINEAGE_DIAGNOSTIC_LABELS: Record = { + identity_conflict: "Identity conflict", + identity_unresolved: "Identity unresolved", + none: "None", + product_unresolved: "Product unresolved", +} + +export function lineageDiagnosticLabel(value: string | undefined) { + if (!value || value === "none") return "None" + return LINEAGE_DIAGNOSTIC_LABELS[value] ?? humanize(value) +} + +/** + * The safety state an operator must not misread as a fault to be cleared. + * + * A frozen lineage is Mosaic refusing to guess. Access is granted to neither + * candidate and the last committed state is preserved, which is deliberately + * the conservative outcome: automatically picking a winner would hand one + * person another person's purchases. + */ +export const PROJECTION_FROZEN_NOTE = + "Projection is frozen for this Purchase Lineage while an identity conflict is open. The projector skips it and the last committed state is preserved, so nothing changes and neither candidate is granted anything. This is a safety state, not a failure." + +const ONE_TIME_VALIDITY_LABELS: Record = { + owned: "Owned", + refunded: "Refunded", + revoked: "Revoked", + unknown: "Validity undetermined", +} + +export function oneTimeValidityLabel(value: string | undefined) { + if (!value) return "Validity undetermined" + return ONE_TIME_VALIDITY_LABELS[value] ?? humanize(value) +} + +export function oneTimeValidityTone(value: string | undefined): AccessTone { + if (value === "owned") return "positive" + if (value === "revoked") return "negative" + if (value === "refunded") return "neutral" + return "attention" +} + +export function timelineEntryTypeLabel(value: string | undefined) { + return value ? humanize(value) : "Unclassified entry" +} + +/** + * Rendered wherever an authoritative access answer appears. It is the sentence + * the 9A boundary note now defers to. + */ +export const AUTHORITATIVE_ACCESS_NOTE = + "Access shown here is computed only by the Mosaic projection engine from store-confirmed facts and the grant version that applied to each purchase. Nothing on these pages can grant or revoke access directly." diff --git a/apps/dashboard/src/features/billing-customers/types/restore-vocabulary.ts b/apps/dashboard/src/features/billing-customers/types/restore-vocabulary.ts new file mode 100644 index 00000000..0315825a --- /dev/null +++ b/apps/dashboard/src/features/billing-customers/types/restore-vocabulary.ts @@ -0,0 +1,147 @@ +import type { BillingRestoreJob } from "@/generated/api" + +/** + * Restore, explained in three layers that are never merged. + * + * A restore involves three separate things succeeding, and conflating any two + * of them produces a support answer that is confidently wrong: + * + * 1. **The native restore** — the store's own operation on the device. It can + * succeed and find nothing, which is not a Mosaic failure. + * 2. **Server validation** — Mosaic asking the store to confirm each purchase + * the device reported. This takes time and can still be running. + * 3. **Authoritative projection** — Mosaic recomputing the customer's access + * from the newly validated facts. Only when a committed snapshot reflects + * the restore is anything actually restored. + * + * So `providerOutcome` and `outcome` are rendered as two separate axes and + * `restored` is never reported until the third layer has committed. + */ + +export const RESTORE_LAYERS = [ + { + body: "The store's own restore on the device, run by the SDK. Mosaic records what it reported but never treats it as proof of access: a native restore that succeeds only means the device asked and the store answered.", + title: "1. Native store restore", + }, + { + body: "Mosaic asks the store server to confirm each purchase the device reported. Nothing a device says is trusted on its own. This layer takes time, and while it runs the restore is pending — not failed.", + title: "2. Server validation", + }, + { + body: "Mosaic recomputes the customer's authoritative access from the newly validated facts. Only once a committed snapshot reflects the restore is access actually restored, which is why the outcome below can still be pending after the store reported success.", + title: "3. Authoritative projection", + }, +] as const + +export const RESTORE_READ_ONLY_NOTE = + "Restores are started by an SDK on a device. This surface reports their status; an operator cannot start one, because nobody but the device can ask the store to replay its own purchases." + +const PROVIDER_OUTCOME_LABELS: Record = { + cancelled: "The person cancelled it", + completed: "The store completed it", + failed: "The store reported a failure", + no_purchases_found: "The store found no purchases", + not_attempted: "Not attempted", + unsupported: "Not supported on this platform", +} + +export function providerOutcomeLabel(value: string | undefined) { + if (!value) return "Not reported" + return PROVIDER_OUTCOME_LABELS[value] ?? humanize(value) +} + +const OUTCOME_LABELS: Record = { + failed: "Failed", + identity_unresolved: "Identity unresolved", + no_additional_purchases: "Nothing further to restore", + product_unresolved: "Product unresolved", + provider_unavailable: "Store unavailable", + restored: "Restored", + validation_pending: "Validation in progress", +} + +/** + * Tone is where this vocabulary earns its keep. + * + * `validation_pending` is emphatically not a failure — it is the expected + * middle state of every restore — and rendering it in a destructive tone is how + * a support agent talks a paying customer through "reinstall the app" for a + * restore that was about to succeed on its own. + */ +const OUTCOME_TONES: Record = { + failed: "negative", + identity_unresolved: "attention", + no_additional_purchases: "neutral", + product_unresolved: "attention", + provider_unavailable: "attention", + restored: "positive", + validation_pending: "attention", +} + +const OUTCOME_EXPLANATIONS: Record = { + failed: + "The restore chain could not complete. Every attempt is preserved; nothing already recorded was changed.", + identity_unresolved: + "Purchases were validated but Mosaic could not decide which Billing Customer they belong to. Access is granted to nobody rather than to a guess. Check identity conflicts.", + no_additional_purchases: + "The chain completed and found nothing Mosaic did not already hold. For a customer who genuinely has no purchases this is the correct, successful outcome.", + product_unresolved: + "The store confirmed a purchase of a Product this Project does not map, so Mosaic cannot say what it grants. Repair the mapping and the next projection picks it up.", + provider_unavailable: + "The store could not be reached during validation. This is retried automatically.", + restored: + "A committed snapshot now reflects the restore. This is the only outcome that means the customer's access actually changed — the store reporting success is not sufficient on its own.", + validation_pending: + "The device reported purchases and Mosaic is confirming them with the store. This is the normal middle of a restore, not a failure: the outcome becomes definite once validation finishes and a projection commits.", +} + +export function restoreOutcomeLabel(value: string | undefined) { + if (!value) return "In progress" + return OUTCOME_LABELS[value] ?? humanize(value) +} + +export function restoreOutcomeTone(value: string | undefined) { + if (!value) return "neutral" as const + return OUTCOME_TONES[value] ?? ("attention" as const) +} + +export function restoreOutcomeExplanation(value: string | undefined) { + if (!value) { + return "Mosaic has not recorded a final outcome yet. The restore is still moving through validation and projection." + } + return ( + OUTCOME_EXPLANATIONS[value] ?? + "Mosaic reported an outcome this build does not recognise. Treat it as still in progress rather than as a failure." + ) +} + +/** Terminal for the *job*, which is not the same as the outcome being final. */ +export const TERMINAL_RESTORE_STATUSES: readonly string[] = ["completed", "failed"] + +export function isRestoreJobRunning(job: Pick) { + return !TERMINAL_RESTORE_STATUSES.includes(job.status ?? "queued") +} + +/** + * Whether the snapshot moved. + * + * The baseline and current snapshot versions are the honest evidence that a + * restore changed anything, independent of what any layer reported. + */ +export function describeSnapshotMovement(job: BillingRestoreJob) { + if (job.snapshotVersion === undefined) { + return "No committed snapshot has been recorded against this restore yet." + } + if (job.baselineSnapshotVersion === undefined) { + return `A snapshot at version ${job.snapshotVersion} is recorded for this restore.` + } + if (job.snapshotVersion > job.baselineSnapshotVersion) { + return `The customer's snapshot moved from version ${job.baselineSnapshotVersion} to ${job.snapshotVersion}, so committed access changed.` + } + return `The snapshot is still at version ${job.baselineSnapshotVersion}, so committed access has not changed. A no-change projection does not advance the version.` +} + +function humanize(value: string) { + const spaced = value.replaceAll("_", " ") + return spaced.charAt(0).toUpperCase() + spaced.slice(1) +} diff --git a/apps/dashboard/src/features/billing-ledger/components/billing-chrome.tsx b/apps/dashboard/src/features/billing-ledger/components/billing-chrome.tsx index 8cd35d29..5d6e2881 100644 --- a/apps/dashboard/src/features/billing-ledger/components/billing-chrome.tsx +++ b/apps/dashboard/src/features/billing-ledger/components/billing-chrome.tsx @@ -1,6 +1,8 @@ import { InfoIcon } from "@phosphor-icons/react/dist/ssr/Info" import type { ReactNode } from "react" +import { Button } from "@/components/ui/button" + import { ScopeBadge } from "@/features/organizations/components/workspace-page" import { BILLING_BOUNDARY_NOTE, @@ -108,6 +110,54 @@ export function DualTimestamps({ ) } +/** + * Forward paging over a keyset-paged billing list. + * + * Extracted from the 9A transaction ledger, where it was rendered beside every + * branch including the filtered-empty one. That placement is the point: several + * filters narrow the loaded page only, so "nothing matched here" must still + * offer a way to look at the next page rather than making the operator discard + * their filter to escape. + * + * The customer, conflict, and restore lists page the same way, so the control + * lives in billing chrome rather than being reimplemented per feature. + */ +export function LedgerPaging({ + cursor, + endLabel = "End of the list for these filters.", + nextCursor, + onCursorChange, +}: { + cursor: string | undefined + endLabel?: string + nextCursor: string | undefined + onCursorChange: (cursor: string | undefined) => void +}) { + if (!cursor && !nextCursor) return null + + return ( +
    + {cursor ? ( + + ) : null} + {nextCursor ? ( + + ) : ( +

    {endLabel}

    + )} +
    + ) +} + export function DefinitionRow({ label, value }: { label: string; value: ReactNode }) { return (
    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 8460eded..7164647d 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 @@ -5,7 +5,10 @@ import { buttonVariants } from "@/components/ui/button-variants" import { EmptyState } from "@/components/feedback/empty-state" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { BillingBoundaryNote } from "@/features/billing-ledger/components/billing-chrome" +import { + BillingBoundaryNote, + LedgerPaging, +} from "@/features/billing-ledger/components/billing-chrome" import { TransactionLedgerFilters } from "@/features/billing-ledger/components/transaction-ledger-filters" import { TransactionLedgerTable } from "@/features/billing-ledger/components/transaction-ledger-table" import { transactionFactsQueryOptions } from "@/features/billing-ledger/queries/transaction-queries" @@ -132,6 +135,7 @@ export function TransactionLedgerPage({ /> onFiltersChange({ ...filters, cursor })} /> @@ -153,6 +157,7 @@ export function TransactionLedgerPage({ exit. */} onFiltersChange({ ...filters, cursor })} /> @@ -172,6 +177,7 @@ export function TransactionLedgerPage({ /> onFiltersChange({ ...filters, cursor })} /> @@ -181,44 +187,3 @@ export function TransactionLedgerPage({ ) } - -/** - * Forward paging over the ledger. - * - * Rendered beside every branch, including the filtered-empty one: several - * filters are applied to the loaded page only, so "nothing matched here" must - * still offer a way to look at the next page. - */ -function LedgerPaging({ - cursor, - nextCursor, - onCursorChange, -}: { - cursor: string | undefined - nextCursor: string | undefined - onCursorChange: (cursor: string | undefined) => void -}) { - if (!cursor && !nextCursor) return null - - return ( -
    - {cursor ? ( - - ) : null} - {nextCursor ? ( - - ) : ( -

    End of the ledger for these filters.

    - )} -
    - ) -} diff --git a/apps/dashboard/src/features/billing-ledger/types/billing-list-headings.ts b/apps/dashboard/src/features/billing-ledger/types/billing-list-headings.ts new file mode 100644 index 00000000..e3a76acc --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/types/billing-list-headings.ts @@ -0,0 +1,36 @@ +/** Describes one page without implying that the visible records are the total. */ +export function pagedListHeading({ + count, + cursor, + nextCursor, + noun, +}: { + count: number + cursor: string | undefined + nextCursor: string | undefined + noun: string +}) { + if (!cursor && !nextCursor) return `All ${count} ${noun}` + if (nextCursor) return `Showing ${count} ${noun} on this page — more follow` + return `Showing ${count} ${noun} on the last page` +} + +/** Describes a server-bounded embedded list whose surface has no cursor. */ +export function cappedListHeading({ + cap, + count, + noun, + totalCount, +}: { + cap: number + count: number + noun: string + totalCount: number | undefined +}) { + if (totalCount !== undefined && totalCount > count) { + return `Showing ${count} of ${totalCount} ${noun}` + } + if (totalCount !== undefined) return `All ${count} ${noun}` + if (count >= cap) return `Showing the ${cap} most recent ${noun} — more may exist` + return `All ${count} ${noun}` +} diff --git a/apps/dashboard/src/features/billing-ledger/types/billing-vocabulary.ts b/apps/dashboard/src/features/billing-ledger/types/billing-vocabulary.ts index 3a67164a..bbbd07b4 100644 --- a/apps/dashboard/src/features/billing-ledger/types/billing-vocabulary.ts +++ b/apps/dashboard/src/features/billing-ledger/types/billing-vocabulary.ts @@ -14,11 +14,22 @@ import type { QuarantineRecord, TransactionFact, ValidationAttempt } from "@/gen */ /** - * Rendered in the header of every billing surface. It states the phase - * boundary without using any of the words 9A forbids on new surfaces. + * Rendered in the header of every ledger and ingestion surface. + * + * Revised in Phase 9B. The 9A wording ended "…does not grant, revoke, or + * represent any person's access to your app", which was true while nothing in + * Mosaic computed access. It stopped being true the moment the projection engine + * shipped, and a note that asserts something false about the neighbouring + * feature is worse than no note: an operator who believes Mosaic still holds no + * access state will go looking for one somewhere else. + * + * The boundary it draws is now the honest one — recorded evidence here, + * computed access under Customers — and it names where to go, because the + * operator reading a ledger page is often there having failed to find the + * question they actually came with. */ export const BILLING_BOUNDARY_NOTE = - "Mosaic Billing records store-confirmed transaction facts and their full validation history. It does not grant, revoke, or represent any person's access to your app." + "Mosaic Billing records store-confirmed transaction facts and their full validation history. Nothing on these ledger pages grants or revokes access: authoritative customer access is computed from these facts only by the Mosaic projection engine, and is read under Customers." /** Billing is per-Project opt-in. No empty state may read like a dead end. */ export const BILLING_OPTIONAL_NOTE = 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 01f560b4..c70d88f7 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 @@ -234,6 +234,24 @@ export function BillingHealthPage({ )} + {/* Projection health is a sibling, not a section here. This page can be + entirely green while every customer is being told the wrong thing, + so the link states the difference rather than just offering a jump. */} + +

    + This page answers whether store input is still becoming facts. Whether the authoritative + answer Mosaic gives about a customer’s access is still current is a separate + question with a separate queue: a healthy intake pipeline and a stalled projection queue + look identical from here. +

    + + Open projection health + +
    +
    • diff --git a/apps/dashboard/src/features/billing-operations/queries/billing-list-paging.test.ts b/apps/dashboard/src/features/billing-operations/queries/billing-list-paging.test.ts index 8d957cda..57611804 100644 --- a/apps/dashboard/src/features/billing-operations/queries/billing-list-paging.test.ts +++ b/apps/dashboard/src/features/billing-operations/queries/billing-list-paging.test.ts @@ -2,10 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const listBillingQuarantine = vi.fn() const listReconciliationRuns = vi.fn() +const listBillingCustomerSubscriptions = vi.fn() +const listBillingSubscriptionTimeline = vi.fn() vi.mock("@/generated/api", () => ({ + getBillingCustomerEntitlementSnapshot: vi.fn(), + getBillingSubscription: vi.fn(), + getOperatorBillingCustomer: vi.fn(), getQuarantineRecord: vi.fn(), + listBillingCustomers: vi.fn(), + listBillingCustomerSubscriptions, listBillingQuarantine, + listBillingSubscriptionTimeline, listReconciliationRuns, })) @@ -13,6 +21,8 @@ const { quarantineRecordsQueryOptions } = await import("@/features/billing-operations/queries/quarantine-queries") const { reconciliationRunsQueryOptions } = await import("@/features/billing-operations/queries/reconciliation-queries") +const { customerSubscriptionsQueryOptions, subscriptionTimelineQueryOptions } = + await import("@/features/billing-customers/queries/customer-queries") /** * Risk: a page of records is presented as the total. @@ -76,4 +86,43 @@ describe("billing list paging", () => { expect(listReconciliationRuns.mock.calls[1]?.[0].query.cursor).toBe("cursor_older") expect(second.queryKey).not.toEqual(first.queryKey) }) + + /** + * Same failure, worse copy. The subscription timeline requested a fixed page + * and discarded the cursor while the panel told the operator entries are + * never trimmed. A subscription with two years of renewals therefore showed + * one page and claimed it was the history — the derivation an operator uses + * to argue with a customer, silently incomplete and asserted as complete. + */ + it("returns the timeline cursor and forwards it on the next page", async () => { + listBillingSubscriptionTimeline.mockResolvedValue({ + data: { data: { items: [{ timelineEntryId: "tl_1" }], nextCursor: "cursor_older" } }, + }) + + const first = subscriptionTimelineQueryOptions("proj_1", "env_1", "sub_1") + const firstPage = await first.queryFn!({ signal: new AbortController().signal } as never) + expect(firstPage.items).toHaveLength(1) + expect(firstPage.nextCursor).toBe("cursor_older") + expect(listBillingSubscriptionTimeline.mock.calls[0]?.[0].query.cursor).toBeUndefined() + + const second = subscriptionTimelineQueryOptions("proj_1", "env_1", "sub_1", "cursor_older") + await second.queryFn!({ signal: new AbortController().signal } as never) + expect(listBillingSubscriptionTimeline.mock.calls[1]?.[0].query.cursor).toBe("cursor_older") + expect(second.queryKey).not.toEqual(first.queryKey) + }) + + it("pages the customer subscription list rather than returning a bare array", async () => { + listBillingCustomerSubscriptions.mockResolvedValue({ + data: { data: { items: [{ subscriptionInstanceId: "sub_1" }], nextCursor: "cursor_older" } }, + }) + + const options = customerSubscriptionsQueryOptions("proj_1", "env_1", "cus_1", "cursor_older") + const page = await options.queryFn!({ signal: new AbortController().signal } as never) + + expect(page.nextCursor).toBe("cursor_older") + expect(listBillingCustomerSubscriptions.mock.calls[0]?.[0].query.cursor).toBe("cursor_older") + expect(options.queryKey).not.toEqual( + customerSubscriptionsQueryOptions("proj_1", "env_1", "cus_1").queryKey, + ) + }) }) diff --git a/apps/dashboard/src/features/billing-projection/components/projection-health-page.tsx b/apps/dashboard/src/features/billing-projection/components/projection-health-page.tsx new file mode 100644 index 00000000..7e8d7d46 --- /dev/null +++ b/apps/dashboard/src/features/billing-projection/components/projection-health-page.tsx @@ -0,0 +1,327 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +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 { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { + BILLING_OPTIONAL_NOTE, + formatBillingTimestamp, + formatDurationSeconds, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { AUTHORITATIVE_ACCESS_NOTE } from "@/features/billing-customers/types/entitlement-vocabulary" +import { ProjectionReplayPanel } from "@/features/billing-projection/components/projection-replay-panel" +import { createProjectionReplayMutationOptions } from "@/features/billing-projection/mutations/projection-replay-mutations" +import { projectionHealthQueryOptions } from "@/features/billing-projection/queries/projection-health-queries" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkflowPanel, WorkspacePage } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { useOrganizationAccess } from "@/hooks/use-organization-access" +import { + billingHealthHref, + billingIdentityConflictsHref, + billingQuarantineHref, + billingRestoresHref, + storeConnectionsHref, +} from "@/lib/routing/workspace-hrefs" + +interface ProjectionHealthPageProps { + environmentId: string + organizationId: string + projectId: string +} + +/** + * Whether the authoritative answer Mosaic gives about a customer's access is + * still current. + * + * Every number here is a count or a timestamp. Nothing on this page can carry a + * customer value, an alias digest, a store token, or a secret — which is what + * makes it safe to leave open on a wall display. + */ +export function ProjectionHealthPage({ + environmentId, + organizationId, + projectId, +}: ProjectionHealthPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const access = useOrganizationAccess(organizationId) + const queryClient = useQueryClient() + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const health = useQuery({ + ...projectionHealthQueryOptions(projectId, environmentId), + enabled: scopeReady, + }) + const replay = useMutation( + createProjectionReplayMutationOptions(projectId, environmentId, queryClient), + ) + + const environmentName = + environments.data?.items.find((item) => item.id === environmentId)?.name ?? environmentId + const data = health.data + + const error = project.error ?? environments.error ?? health.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: project.isPending || (scopeReady && (environments.isPending || health.isPending)), + loadingDescription: `Loading projection health for the ${environmentName} Mosaic Environment.`, + onRetry: () => { + void health.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read projection health.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const scope = { environmentId, organizationId, projectId } + const connectionsHref = storeConnectionsHref(scope) ?? "#" + const oldestQueued = data?.projectionOldestQueuedAgeSeconds + // Depth alone cannot distinguish a busy queue from a stuck one; age can. + const queueStuck = typeof oldestQueued === "number" && oldestQueued > 900 + const conflicts = data?.openIdentityConflicts ?? 0 + const unknownEntries = data?.unknownEntitlementEntries ?? 0 + + return ( + +

      {AUTHORITATIVE_ACCESS_NOTE}

      + + + {data?.billingEnabled === false ? ( + +

      + No projection runs while billing is off, so Mosaic holds no authoritative answer about + anyone’s access. Every Entitlement read answers{" "} + Mosaic cannot answer — never inactive. The distinction + matters: a caller that treats the two as the same revokes access during an outage. +

      +

      {BILLING_OPTIONAL_NOTE}

      + + Open Mosaic Billing setup + +
      + ) : null} + +
      + + + 0 ? "negative" : "positive"} + value={String(data?.projectionFailedJobs ?? 0)} + /> + 0 ? "attention" : "neutral"} + value={String(data?.projectionFailuresLastHour ?? 0)} + /> +
      + +
      + 0 ? "attention" : "positive"} + value={String(data?.staleCustomers ?? 0)} + /> + 0 ? "attention" : "neutral"} + value={String(data?.neverProjectedCustomers ?? 0)} + /> + 0 ? "attention" : "positive"} + value={String(unknownEntries)} + /> + +
      + + +
      + 0 ? "negative" : "positive"} + value={String(conflicts)} + /> + 0 ? "attention" : "neutral"} + value={String(data?.frozenLineages ?? 0)} + /> + 0 ? "attention" : "neutral"} + value={String(data?.unresolvedLineages ?? 0)} + /> +
      +
      + + +
      + 0 ? "attention" : "neutral"} + value={String(data?.restoreBacklog ?? 0)} + /> + 0 ? "negative" : "positive"} + value={String(data?.restoreFailedJobs ?? 0)} + /> + 0 ? "attention" : "neutral"} + value={String(data?.webhookDeliveryBacklog ?? 0)} + /> + 0 ? "negative" : "positive"} + value={String(data?.webhookDeliveriesExhausted ?? 0)} + /> +
      +

      + {data?.activeWebhookDestinations ?? 0} active webhook destination(s) in this Mosaic + Environment. +

      +
      + + +
      + + 1 ? "attention" : "neutral"} + value={String(data?.projectionRuleVersionCount ?? 1)} + /> +
      +

      + Observed at {formatBillingTimestamp(data?.observedAt)}. +

      +
      + + replay.mutateAsync(request)} + /> + + +
        +
      • + + Billing health + {" "} + — whether store input is still becoming facts at all. A stalled intake pipeline shows + up there first. +
      • +
      • + + Quarantine + {" "} + — inputs that could not safely proceed. Unresolved Products here become undetermined + Entitlements above. +
      • +
      • + + Identity conflicts + {" "} + — inspect disputed customer claims and the Purchase Lineages frozen until an operator + resolves them. +
      • +
      • + + Restore jobs + {" "} + — inspect validation-pending and failed restore work without treating restore as an + immediate access decision. +
      • +
      +
      +
      +
      + ) +} + +function Metric({ + label, + recovery, + tone, + value, +}: { + label: string + recovery?: ReactNode + tone: "attention" | "negative" | "neutral" | "positive" + value: string +}) { + return ( +
      +

      {label}

      +

      {value}

      + {tone === "attention" || tone === "negative" ? ( +
      + +
      + ) : null} + {recovery ?

      {recovery}

      : null} +
      + ) +} 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 new file mode 100644 index 00000000..e9cecf22 --- /dev/null +++ b/apps/dashboard/src/features/billing-projection/components/projection-replay-panel.tsx @@ -0,0 +1,225 @@ +import { useState } from "react" + +import { Button } from "@/components/ui/button" +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { DefinitionRow, StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { + describeReplayRefusal, + describeReplayScopeIssue, + NO_AUTO_PROMOTION_NOTE, + replayComparisonExplanation, + replayComparisonLabel, + replayComparisonTone, + replaySummary, + validateReplayScope, +} from "@/features/billing-projection/types/projection-vocabulary" +import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import type { CreateProjectionReplayRequest, ProjectionReplayResult } from "@/generated/api" + +interface ProjectionReplayPanelProps { + activeRuleVersion: number | undefined + canManage: boolean + membersHref: string + onReplay: (request: CreateProjectionReplayRequest) => Promise +} + +/** + * Recomputing committed state from the immutable facts. + * + * Nothing here promotes anything. The panel reports what recomputing produced + * and stops; turning a `changed` comparison into the active semantics is a + * separate, deliberate act that Phase 9B does not automate. + */ +export function ProjectionReplayPanel({ + activeRuleVersion, + canManage, + membersHref, + onReplay, +}: ProjectionReplayPanelProps) { + const [request, setRequest] = useState({}) + const [result, setResult] = useState(undefined) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const scopeIssue = validateReplayScope(request) + + async function run() { + setBusy(true) + setError(null) + setResult(undefined) + try { + setResult(await onReplay(request)) + } catch (cause) { + setError( + describeReplayRefusal(cause) ?? + (cause instanceof Error ? cause.message : "Mosaic could not run this replay."), + ) + } finally { + setBusy(false) + } + } + + if (!canManage) { + return ( + +

      + Replaying a projection requires organization owner or admin permission. +

      + + Ask an Owner or Admin to run a replay + +
      + ) + } + + return ( + +
      + + Subscription Instance + { + const value = event.currentTarget.value || undefined + setRequest((current) => ({ ...current, subscriptionInstanceId: value })) + }} + value={request.subscriptionInstanceId ?? ""} + /> + + + Billing Customer + { + const value = event.currentTarget.value || undefined + setRequest((current) => ({ ...current, billingCustomerId: value })) + }} + value={request.billingCustomerId ?? ""} + /> + + + Fact window start + { + const value = toIsoInstant(event.currentTarget.value) + setRequest((current) => ({ ...current, windowStart: value })) + }} + type="datetime-local" + value={toLocalInput(request.windowStart)} + /> + + + Fact window end + { + const value = toIsoInstant(event.currentTarget.value) + setRequest((current) => ({ ...current, windowEnd: value })) + }} + type="datetime-local" + value={toLocalInput(request.windowEnd)} + /> + + The window bounds facts, not lineage creation: a scope is in scope when it holds a fact + whose effective or recorded time falls inside it. + + + + Projection rule version + { + const raw = event.currentTarget.value + const value = raw ? Number(raw) : undefined + setRequest((current) => ({ ...current, projectionRuleVersion: value })) + }} + type="number" + value={request.projectionRuleVersion ?? ""} + /> + + Leave blank to use the active version{" "} + {activeRuleVersion === undefined ? "" : `(${activeRuleVersion})`}. A version this build + does not derive under is refused rather than approximated. + + +
      + + {scopeIssue ? ( +

      + {describeReplayScopeIssue(scopeIssue)} +

      + ) : null} + +
      + +

      {NO_AUTO_PROMOTION_NOTE}

      +
      + + {error ? ( +

      + {error} +

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

      {replaySummary(result)}

      +
      + +
      +
        + {(result.outcomes ?? []).map((outcome) => ( +
      • +
        + {outcome.projectionScopeKey} + + {outcome.materialized ? ( + + ) : ( + + )} +
        +

        + {replayComparisonExplanation(outcome.comparison)} +

        + {(outcome.changedEntitlementIds ?? []).length > 0 ? ( +

        + Entitlements that moved: {(outcome.changedEntitlementIds ?? []).join(", ")} +

        + ) : null} +
      • + ))} +
      +
      + ) : null} +
      + ) +} + +function toLocalInput(iso: string | undefined) { + if (!iso) return "" + const parsed = new Date(iso) + if (Number.isNaN(parsed.getTime())) return "" + const offset = parsed.getTimezoneOffset() * 60_000 + return new Date(parsed.getTime() - offset).toISOString().slice(0, 16) +} + +function toIsoInstant(local: string) { + if (!local) return undefined + const parsed = new Date(local) + return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString() +} diff --git a/apps/dashboard/src/features/billing-projection/mutations/projection-replay-mutations.ts b/apps/dashboard/src/features/billing-projection/mutations/projection-replay-mutations.ts new file mode 100644 index 00000000..9deae0f4 --- /dev/null +++ b/apps/dashboard/src/features/billing-projection/mutations/projection-replay-mutations.ts @@ -0,0 +1,32 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query" + +import { createBillingProjectionReplay, type CreateProjectionReplayRequest } from "@/generated/api" +import { projectionHealthKeys } from "@/features/billing-projection/queries/projection-health-queries" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * Replay runs synchronously and answers with its comparison, so there is no job + * to poll. Health is invalidated afterwards because a replay that materialised + * new snapshots moves the stale-customer and last-committed figures. + */ +export function createProjectionReplayMutationOptions( + projectId: string, + environmentId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (request: CreateProjectionReplayRequest) => { + const result = await createBillingProjectionReplay({ + body: request, + client: generatedDashboardClient, + path: { environmentId, projectId }, + throwOnError: true, + }) + return result.data.data + }, + onSuccess: async () => + queryClient.invalidateQueries({ + queryKey: projectionHealthKeys.detail(projectId, environmentId), + }), + }) +} diff --git a/apps/dashboard/src/features/billing-projection/queries/projection-health-queries.ts b/apps/dashboard/src/features/billing-projection/queries/projection-health-queries.ts new file mode 100644 index 00000000..f75fc2d8 --- /dev/null +++ b/apps/dashboard/src/features/billing-projection/queries/projection-health-queries.ts @@ -0,0 +1,40 @@ +import { queryOptions } from "@tanstack/react-query" + +import { getBillingProjectionHealth } from "@/generated/api" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * Projection health is a sibling of billing health, not a field on it. + * + * Billing health answers whether Mosaic can still turn store notifications into + * facts. This answers whether the authoritative answer Mosaic gives about a + * customer's access is still current. Both can be green while the other is red, + * and merging them would let a healthy intake pipeline hide a stalled + * projection queue — the failure mode where every fact is recorded correctly and + * every customer is told the wrong thing. + */ +export const projectionHealthKeys = { + detail: (projectId: string, environmentId: string) => + ["billing-projection", projectId, environmentId, "health"] as const, + scope: (projectId: string, environmentId: string) => + ["billing-projection", projectId, environmentId] as const, +} + +export function projectionHealthQueryOptions(projectId: string, environmentId: string) { + return queryOptions({ + queryKey: projectionHealthKeys.detail(projectId, environmentId), + queryFn: async ({ signal }) => { + const result = await getBillingProjectionHealth({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + signal, + throwOnError: true, + }) + return result.data.data + }, + // The same bounded interval billing health uses. An operator watching a + // projection backlog should not have to reload to find out whether it is + // draining. + refetchInterval: 30_000, + }) +} diff --git a/apps/dashboard/src/features/billing-projection/types/projection-vocabulary.test.ts b/apps/dashboard/src/features/billing-projection/types/projection-vocabulary.test.ts new file mode 100644 index 00000000..f9767166 --- /dev/null +++ b/apps/dashboard/src/features/billing-projection/types/projection-vocabulary.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest" + +import { + describeReplayRefusal, + replayComparisonExplanation, + validateReplayScope, +} from "@/features/billing-projection/types/projection-vocabulary" + +/** + * Two risks are protected here. + * + * The first is an unbounded replay. There is deliberately no "replay + * everything" member in the contract, because an unbounded replay is a + * migration; a form that lets an operator submit empty bounds turns that + * design decision into a server-side 422 they have to decode, and — worse — a + * half-filled fact window is accepted by the shape check while meaning + * something nobody intended. + * + * The second is the unimplemented-rule-version refusal. Rendering it as a + * generic validation failure loses the only sentence that explains why Mosaic + * refuses to approximate: a checksum from the wrong engine is + * indistinguishable from a genuine determinism result. + */ +describe("projection replay scope", () => { + it("refuses an unbounded replay before it reaches the API", () => { + expect(validateReplayScope({})).toBe("unbounded") + }) + + it("accepts a single named scope", () => { + expect(validateReplayScope({ subscriptionInstanceId: "sub_01" })).toBeUndefined() + expect(validateReplayScope({ billingCustomerId: "cus_01" })).toBeUndefined() + }) + + it("rejects a half-specified or inverted fact window", () => { + expect(validateReplayScope({ windowStart: "2026-07-01T00:00:00Z" })).toBe("window_incomplete") + expect( + validateReplayScope({ + windowEnd: "2026-07-01T00:00:00Z", + windowStart: "2026-07-02T00:00:00Z", + }), + ).toBe("window_inverted") + }) + + it("accepts a complete, ordered fact window", () => { + expect( + validateReplayScope({ + windowEnd: "2026-07-02T00:00:00Z", + windowStart: "2026-07-01T00:00:00Z", + }), + ).toBeUndefined() + }) +}) + +describe("projection replay outcome copy", () => { + it("explains the unimplemented rule version rather than showing a bare 422", () => { + const message = describeReplayRefusal({ + code: "unsupported_projection_rule_version", + status: 422, + }) + expect(message).toContain("does not derive under the requested projection rule version") + expect(message).toContain("indistinguishable from a genuine determinism result") + }) + + it("leaves other failures to the ordinary error path", () => { + expect(describeReplayRefusal({ code: "not_found", status: 404 })).toBeUndefined() + }) + + it("states that an identical replay wrote nothing and a changed one preserved the old snapshot", () => { + expect(replayComparisonExplanation("unchanged")).toContain("Nothing was written") + expect(replayComparisonExplanation("changed")).toContain("preserved") + }) +}) diff --git a/apps/dashboard/src/features/billing-projection/types/projection-vocabulary.ts b/apps/dashboard/src/features/billing-projection/types/projection-vocabulary.ts new file mode 100644 index 00000000..9d376d85 --- /dev/null +++ b/apps/dashboard/src/features/billing-projection/types/projection-vocabulary.ts @@ -0,0 +1,121 @@ +import type { CreateProjectionReplayRequest, ProjectionReplayResult } from "@/generated/api" + +/** + * Vocabulary and rules for replaying a projection. + * + * Replay is the operational expression of "a projection is derived state": a + * corrupt checkpoint, a promoted rule version, or a repaired Product mapping is + * answered by recomputing, never by patching what was derived. Replayed state + * goes through the same lock, compare-and-swap, and atomic commit as live + * projection, and prior snapshots are never deleted. + */ + +const COMPARISON_LABELS: Record = { + changed: "Changed", + unchanged: "Identical", +} + +const COMPARISON_EXPLANATIONS: Record = { + changed: + "Recomputing produced a different state, so a new snapshot was written beside the old one. The earlier snapshot is preserved; nothing was overwritten.", + unchanged: + "Recomputing from the same facts produced the same checksum. Nothing was written. This is the expected outcome and it is what proves the projection is deterministic.", +} + +function humanize(value: string) { + const spaced = value.replaceAll("_", " ") + return spaced.charAt(0).toUpperCase() + spaced.slice(1) +} + +export function replayComparisonLabel(value: string | undefined) { + if (!value) return "Not compared" + return COMPARISON_LABELS[value] ?? humanize(value) +} + +export function replayComparisonExplanation(value: string | undefined) { + if (!value) return "Mosaic did not report a comparison for this scope." + return ( + COMPARISON_EXPLANATIONS[value] ?? + "Mosaic reported a comparison outcome this build does not recognise. Prior snapshots are preserved either way." + ) +} + +export function replayComparisonTone(value: string | undefined) { + return value === "changed" ? ("attention" as const) : ("neutral" as const) +} + +/** + * There is deliberately no "replay everything" member. An unbounded replay is a + * migration, and bulk migration tooling is out of Phase 9B — so the refusal + * lives here, before the request, rather than arriving as a 422 the operator has + * to interpret. + */ +export type ReplayScopeIssue = "unbounded" | "window_inverted" | "window_incomplete" + +const SCOPE_ISSUE_MESSAGES: Record = { + unbounded: + "A replay must be bounded. Name one Subscription Instance, one Billing Customer, or a fact window.", + window_incomplete: "A fact window needs both a start and an end.", + window_inverted: "The window end must be after the window start.", +} + +export function validateReplayScope( + request: CreateProjectionReplayRequest, +): ReplayScopeIssue | undefined { + const hasInstance = Boolean(request.subscriptionInstanceId) + const hasCustomer = Boolean(request.billingCustomerId) + const hasStart = Boolean(request.windowStart) + const hasEnd = Boolean(request.windowEnd) + + if (!hasInstance && !hasCustomer && !hasStart && !hasEnd) return "unbounded" + if (!hasInstance && !hasCustomer) { + if (!hasStart || !hasEnd) return "window_incomplete" + if (new Date(request.windowEnd ?? "") <= new Date(request.windowStart ?? "")) { + return "window_inverted" + } + } + return undefined +} + +export function describeReplayScopeIssue(issue: ReplayScopeIssue) { + return SCOPE_ISSUE_MESSAGES[issue] +} + +/** + * The 422 an unimplemented rule version answers with. + * + * A checksum produced by the wrong engine is indistinguishable from a genuine + * determinism result, so the API refuses rather than recomputing under the + * active engine and labelling the answer with the requested number. The refusal + * is rendered as that sentence, not as a generic validation failure. + */ +export function describeReplayRefusal(error: unknown): string | undefined { + const status = (error as { status?: number } | undefined)?.status + const code = (error as { code?: string } | undefined)?.code + if (status !== 422) return undefined + if (code === "unsupported_projection_rule_version" || code === "validation_failed") { + return "This build does not derive under the requested projection rule version, so Mosaic refused to run the replay. Recomputing under the active engine and labelling the result with the requested number would produce a checksum indistinguishable from a genuine determinism result. Choose the active rule version, or deploy a build that implements the requested one." + } + return undefined +} + +/** + * Replay reports, it never promotes. + * + * A comparison that came back `changed` is evidence for a decision, not the + * decision. Nothing in this feature turns a replay result into the active rule + * version. + */ +export const NO_AUTO_PROMOTION_NOTE = + "A replay reports what recomputing would produce. It never promotes a rule version and never becomes the active semantics on its own." + +export function replaySummary(result: ProjectionReplayResult | undefined) { + if (!result) return undefined + const replayed = result.scopesReplayed ?? 0 + const changed = result.scopesChanged ?? 0 + if (replayed === 0) return "No scope in this Environment matched the bounds you gave." + if (changed === 0) { + return `${replayed} scope(s) recomputed to the same checksum. Nothing was written.` + } + return `${replayed} scope(s) recomputed; ${changed} produced a different state and had a new snapshot written beside the old one.` +} 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 30ab0bb9..251f91e7 100644 --- a/apps/dashboard/src/features/catalog/components/product-detail-page.tsx +++ b/apps/dashboard/src/features/catalog/components/product-detail-page.tsx @@ -15,14 +15,11 @@ import { NativeProviderMappingSheet } from "@/features/catalog/components/native import { archiveProviderMappingMutationOptions, createProviderMappingDraftMutationOptions, - grantEntitlementMutationOptions, productLifecycleMutationOptions, - removeEntitlementGrantMutationOptions, replaceProviderMappingMutationOptions, setProductReplacementMutationOptions, } from "@/features/catalog/mutations/catalog-mutations" import { - entitlementsQueryOptions, productEntitlementsQueryOptions, productQueryOptions, productReadinessQueryOptions, @@ -53,7 +50,7 @@ import { projectQueryOptions, } from "@/features/projects/queries/projects-query" import { useOrganizationAccess } from "@/hooks/use-organization-access" -import { describeReturnDestination } from "@/lib/routing/workspace-hrefs" +import { describeReturnDestination, grantVersionsHref } from "@/lib/routing/workspace-hrefs" interface ProductDetailPageProps { onReadinessScopeChange: (scope: { applicationId?: string; environmentId?: string }) => void @@ -104,7 +101,6 @@ export function ProductDetailPage({ })), }) const grants = useQuery({ ...productEntitlementsQueryOptions(productId), enabled: scopeReady }) - const entitlements = useQuery({ ...entitlementsQueryOptions(projectId), enabled: scopeReady }) const replacements = useQuery({ ...productsQueryOptions(projectId), enabled: scopeReady }) const applications = useQuery({ ...applicationsQueryOptions(projectId), enabled: scopeReady }) const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) @@ -142,10 +138,6 @@ export function ProductDetailPage({ const archive = useMutation(productLifecycleMutationOptions(queryClient, "archive")) const restore = useMutation(productLifecycleMutationOptions(queryClient, "restore")) const setReplacement = useMutation(setProductReplacementMutationOptions(productId, queryClient)) - const grant = useMutation(grantEntitlementMutationOptions(productId, projectId, queryClient)) - const removeGrant = useMutation( - removeEntitlementGrantMutationOptions(productId, projectId, queryClient), - ) const archiveMapping = useMutation( archiveProviderMappingMutationOptions(productId, projectId, queryClient), ) @@ -425,8 +417,15 @@ export function ProductDetailPage({
    + {/* Retired in Phase 9B. + A grant used to be a single mutable row here, so removing an + Entitlement changed what every past purchase of this Product had + meant — one DELETE away from mass revocation, with no record that it + had ever granted anything. What a Product grants is now a versioned, + immutable interval selected by each purchase's own effective time, so + the change surface moved to Grant versions and this panel reads. */}
      @@ -441,37 +440,33 @@ export function ProductDetailPage({ · {entitlement.key} - {access.canManage ? ( - - ) : null} + + Version history + ))}
    +

    + Changing what a Product grants publishes a new immutable grant version rather than + editing this list. The projection engine selects a version by each purchase’s own + effective time, so a change made here would otherwise rewrite what someone was entitled + to at an instant that has already passed. +

    {access.canManage ? ( -
    - {entitlements.data?.items - .filter( - (entitlement) => - !grants.data?.items.some((granted) => granted.id === entitlement.id), - ) - .map((entitlement) => ( - - ))} -
    + + Open grant versions + ) : ( )} - {grant.error || removeGrant.error ? ( -

    - {(grant.error ?? removeGrant.error)?.message} -

    - ) : null}
    { - const result = await addProductEntitlement({ - body: { entitlementId }, - client: generatedDashboardClient, - path: { productId }, - throwOnError: true, - }) - return result.data.data - }, - onSuccess: async (_, entitlementId) => - invalidateCatalogImpact(queryClient, { - entitlementIds: [entitlementId], - productIds: [productId], - projectId, - }), - }) -} - -export function removeEntitlementGrantMutationOptions( - productId: string, - projectId: string, - queryClient: QueryClient, -) { - return mutationOptions({ - mutationFn: async (entitlementId: string) => { - await removeProductEntitlement({ - client: generatedDashboardClient, - path: { entitlementId, productId }, - throwOnError: true, - }) - return entitlementId - }, - onSuccess: async (entitlementId) => - invalidateCatalogImpact(queryClient, { - entitlementIds: [entitlementId], - productIds: [productId], - projectId, - }), - }) -} +/* + * Retired in Phase 9B. + * + * `grantEntitlementMutationOptions` and `removeEntitlementGrantMutationOptions` + * changed a single mutable grant row, which meant removing an Entitlement + * silently changed what every past purchase of the Product had meant — one + * DELETE away from mass revocation with nothing left saying it had ever granted + * anything. What a Product grants is now an immutable, versioned interval that + * the projection engine selects by each purchase's own effective time, so the + * only write path is publishing a new version: + * `features/entitlement-grants/mutations/grant-version-mutations.ts`. + * + * The read (`productEntitlementsQueryOptions`) stays: Product detail still shows + * what the Product grants today and links to the version history. + */ export function productLifecycleMutationOptions( queryClient: QueryClient, 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 new file mode 100644 index 00000000..dd29ed2a --- /dev/null +++ b/apps/dashboard/src/features/entitlement-grants/components/grant-versions-page.tsx @@ -0,0 +1,280 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { EmptyState } from "@/components/feedback/empty-state" +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 { DefinitionRow, StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { formatBillingTimestamp } from "@/features/billing-ledger/types/billing-vocabulary" +import { + entitlementsQueryOptions, + productsQueryOptions, +} from "@/features/catalog/queries/catalog-query" +import { PublishGrantVersionWizard } from "@/features/entitlement-grants/components/publish-grant-version-wizard" +import { + previewGrantImpactMutationOptions, + publishGrantVersionMutationOptions, +} from "@/features/entitlement-grants/mutations/grant-version-mutations" +import { grantVersionHistoryQueryOptions } from "@/features/entitlement-grants/queries/grant-version-queries" +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 { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { useOrganizationAccess } from "@/hooks/use-organization-access" +import { catalogProductsHref } from "@/lib/routing/workspace-hrefs" +import type { ProductEntitlementGrantVersion } from "@/generated/api" + +interface GrantVersionsPageProps { + entitlementId?: string + onScopeChange: (scope: { entitlementId?: string; productId?: string }) => void + organizationId: string + productId?: string + projectId: string +} + +/** + * What a Product grants, over time. + * + * This surface replaces the Phase 9A grant/revoke buttons on Product detail. A + * grant was a single mutable row there, so removing an Entitlement changed what + * every past purchase had meant — one DELETE away from mass revocation. Here a + * change is a new immutable interval, and history stays readable, which is what + * makes "why is this customer entitled?" answerable for a purchase made under a + * rule that has since been replaced. + */ +export function GrantVersionsPage({ + entitlementId, + onScopeChange, + organizationId, + productId, + projectId, +}: GrantVersionsPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const access = useOrganizationAccess(organizationId) + const queryClient = useQueryClient() + const products = useQuery({ ...productsQueryOptions(projectId), enabled: scopeReady }) + const entitlements = useQuery({ ...entitlementsQueryOptions(projectId), enabled: scopeReady }) + + const productItems = products.data?.items ?? [] + const selectedProductId = productId ?? productItems[0]?.id + const versions = useQuery({ + ...grantVersionHistoryQueryOptions(projectId, selectedProductId ?? "", entitlementId), + enabled: scopeReady && Boolean(selectedProductId), + }) + + const preview = useMutation(previewGrantImpactMutationOptions(projectId)) + const publish = useMutation(publishGrantVersionMutationOptions(projectId, queryClient)) + + const error = project.error ?? products.error ?? entitlements.error ?? versions.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: project.isPending || (scopeReady && (products.isPending || entitlements.isPending)), + loadingDescription: "Loading grant version history for this Project.", + onRetry: () => { + void versions.refetch() + }, + permissionDescription: + "Membership of the owning Organization is required to read grant version history.", + scope: { organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const productsHref = catalogProductsHref({ organizationId, projectId }) ?? "#" + const grouped = groupByEntitlement(versions.data ?? []) + + return ( + +

    + Published versions are immutable. Intervals are half-open and abut exactly, so every instant + is covered by at most one version per Entitlement — never a gap that would strand a purchase + with no applicable grant, never an overlap that would make the applicable version a function + of row order. +

    + + + {productItems.length === 0 ? ( + + Open Products +
    + } + description="Grant versions describe what a Product grants, so this Project needs at least one Product before there is anything to version." + title="No Products in this Project yet" + /> + ) : ( + <> + +
    + + +
    +
    + preview.mutateAsync(proposal)} + onPublish={async (proposal) => { + await publish.mutateAsync(proposal) + }} + productId={selectedProductId ?? ""} + products={productItems} + /> + {!access.canManage ? ( + + Ask an Owner or Admin to change what this Product grants + + ) : null} +
    +
    + + {grouped.length === 0 ? ( + + ) : ( + grouped.map(([key, items]) => ( + +
      + {items.map((version) => ( +
    • +
      + Version {version.version} + {version.current ? ( + + ) : ( + + )} + {version.retroactive ? ( + + ) : null} + +
      +
      + + + + + + +
      +

      + This version cannot be edited or deleted. A purchase made inside its + interval is still explained by it, so changing it would change what + someone was entitled to at an instant that has already passed. Publish a + new version instead. +

      +
    • + ))} +
    +
    + )) + )} + + )} + + + ) +} + +function groupByEntitlement(versions: readonly ProductEntitlementGrantVersion[]) { + const groups = new Map() + for (const version of versions) { + const key = version.entitlementId ?? "unknown" + groups.set(key, [...(groups.get(key) ?? []), version]) + } + for (const items of groups.values()) { + items.sort((left, right) => (right.version ?? 0) - (left.version ?? 0)) + } + return [...groups.entries()] +} + +function describePolicy(version: ProductEntitlementGrantVersion) { + const policy = version.accessPolicy ?? {} + const granted = grantPolicyFields.filter((field) => policy[field] === true).map(grantPolicyLabel) + return granted.length > 0 ? granted.join(", ") : "Nothing — this version grants no access" +} diff --git a/apps/dashboard/src/features/entitlement-grants/components/publish-grant-version-wizard.test.tsx b/apps/dashboard/src/features/entitlement-grants/components/publish-grant-version-wizard.test.tsx new file mode 100644 index 00000000..05f95cf7 --- /dev/null +++ b/apps/dashboard/src/features/entitlement-grants/components/publish-grant-version-wizard.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { PublishGrantVersionWizard } from "@/features/entitlement-grants/components/publish-grant-version-wizard" +import type { + Entitlement, + GrantVersionImpact, + Product, + PublishGrantVersionRequest, +} from "@/generated/api" + +const products = [ + { + createdAt: "2026-01-01T00:00:00Z", + id: "prod_01", + internalName: "Pro monthly", + key: "pro_monthly", + metadataSource: "manual", + projectId: "proj_01", + readiness: "ready", + status: "active", + type: "subscription", + updatedAt: "2026-01-01T00:00:00Z", + }, +] as unknown as Product[] + +const entitlements = [ + { + createdAt: "2026-01-01T00:00:00Z", + id: "ent_01", + key: "pro", + name: "Pro", + projectId: "proj_01", + updatedAt: "2026-01-01T00:00:00Z", + }, +] as unknown as Entitlement[] + +type PreviewFn = (proposal: PublishGrantVersionRequest) => Promise +type PublishFn = (proposal: PublishGrantVersionRequest) => Promise + +function renderWizard( + overrides: { + onPreview?: PreviewFn + onPublish?: PublishFn + } = {}, +) { + const onPreview = vi.fn( + overrides.onPreview ?? (async () => ({ additiveSuperset: true, impactedActiveSources: 12 })), + ) + const onPublish = vi.fn(overrides.onPublish ?? (async () => undefined)) + render( + , + ) + fireEvent.click(screen.getByRole("button", { name: "Create new version" })) + return { onPreview, onPublish } +} + +/** + * This protects the wiring the pure gate cannot: that the wizard actually + * consumes it. + * + * The realistic failure is a Publish button rendered beside the shape fields, or + * enabled from a preview taken before the operator edited the policy. Either + * one lets a change that could remove access from paying customers be published + * without the one number — `impactedActiveSources` — that describes how many. + */ +describe("publish grant version wizard", () => { + it("offers no publish control until the impact of this proposal has been previewed", async () => { + const { onPreview } = renderWizard() + + expect(screen.queryByRole("button", { name: /Publish new version/ })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Preview impact" })) + await waitFor(() => expect(onPreview).toHaveBeenCalledTimes(1)) + + // Step two states the blast radius and still does not publish. + expect(await screen.findByText(/12 purchases currently granting access/)).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /Publish new version/ })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Continue to publish" })) + const publish = await screen.findByRole("button", { name: /Publish new version/ }) + // A reason is required before the version can be written. + expect(publish).toBeDisabled() + + fireEvent.change(screen.getByLabelText("Reason for this change"), { + target: { value: "Grace access was never meant to be off." }, + }) + await waitFor(() => expect(publish).not.toBeDisabled()) + }) + + it("discards the preview when the proposal is edited afterwards", async () => { + const { onPreview } = renderWizard() + + fireEvent.click(screen.getByRole("button", { name: "Preview impact" })) + await waitFor(() => expect(onPreview).toHaveBeenCalled()) + fireEvent.click(await screen.findByRole("button", { name: "Back to shape" })) + + // Widening the policy makes the number the operator was shown wrong, so the + // preview and every downstream step are withdrawn. + fireEvent.click(screen.getByLabelText(/Billing retry/)) + + expect(screen.queryByText(/purchases currently granting access/)).not.toBeInTheDocument() + expect(screen.getByRole("button", { name: "Preview impact" })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /Publish new version/ })).not.toBeInTheDocument() + }) + + it("refuses a retroactive narrowing the publish call would reject", async () => { + renderWizard({ + onPreview: async () => ({ + additiveSuperset: false, + impactedActiveSources: 340, + narrowingCode: "grace_access_narrowed", + }), + }) + + fireEvent.click(screen.getByRole("button", { name: "Preview impact" })) + + expect(await screen.findByText("Publish would be refused")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Continue to publish" })).toBeDisabled() + }) +}) 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 new file mode 100644 index 00000000..d7c09719 --- /dev/null +++ b/apps/dashboard/src/features/entitlement-grants/components/publish-grant-version-wizard.tsx @@ -0,0 +1,455 @@ +import { useState } from "react" + +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" +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { + evaluatePublishGate, + grantPolicyFields, + grantPolicyLabel, + grantPolicyNote, + impactHeadline, + narrowingCodeExplanation, + PAUSE_POLICY_NOTE, + proposalFingerprint, + type GrantProposal, +} 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 { + canManage: boolean + entitlementId: string + entitlements: readonly Entitlement[] + onPreview: (proposal: GrantProposal) => Promise + onPublish: (proposal: GrantProposal) => Promise + productId: string + products: readonly Product[] + triggerLabel?: string +} + +/** + * Creating the next grant version. + * + * Three deliberate steps, in this order and no other: describe the shape, + * *see what it would touch*, then publish with a reason. The middle step is not + * a review screen an operator can skip — it is the only place + * `impactedActiveSources` is stated, and that number is the answer to "how many + * customers could lose access". Preview writes nothing, including no audit + * event, so requiring it costs nothing and buys the whole confirmation. + * + * There is no edit path anywhere in this component. A published version is a + * historical fact about what someone bought; the only forward action is another + * version. + */ +export function PublishGrantVersionWizard({ + canManage, + entitlementId, + entitlements, + onPreview, + onPublish, + productId, + products, + triggerLabel = "Create new version", +}: PublishGrantVersionWizardProps) { + const [open, setOpen] = useState(false) + const [step, setStep] = useState<"publish" | "review" | "shape">("shape") + const [impact, setImpact] = useState(undefined) + const [previewedFingerprint, setPreviewedFingerprint] = useState(undefined) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const [proposal, setProposal] = useState({ + effectiveStart: defaultEffectiveStart(), + entitlementId, + grantsInActive: true, + grantsInGrace: true, + grantsInOneTimeOwnership: true, + grantsInTrial: true, + productId, + reason: "", + supportedPurchaseTypes: ["auto_renewable_subscription"], + }) + + /** + * Any change to a published field discards the preview. The alternative — + * keeping the earlier numbers on screen — would let an operator confirm a + * blast radius that describes a proposal they have since edited. + */ + function update(patch: Partial) { + setProposal((current) => { + const next = { ...current, ...patch } + if (proposalFingerprint(next) !== proposalFingerprint(current)) { + setImpact(undefined) + setPreviewedFingerprint(undefined) + setStep("shape") + } + return next + }) + } + + const gate = evaluatePublishGate({ + canManage, + impact, + isSubmitting: busy, + previewedFingerprint, + proposal, + }) + + function reset() { + setStep("shape") + setImpact(undefined) + setPreviewedFingerprint(undefined) + setError(null) + setBusy(false) + } + + async function preview() { + setBusy(true) + setError(null) + try { + const result = await onPreview(proposal) + setImpact(result) + setPreviewedFingerprint(proposalFingerprint(proposal)) + setStep("review") + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Mosaic could not preview this change.") + } finally { + setBusy(false) + } + } + + async function publish() { + setBusy(true) + setError(null) + try { + await onPublish(proposal) + setOpen(false) + reset() + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Mosaic could not publish this version.") + } finally { + setBusy(false) + } + } + + return ( + { + setOpen(nextOpen) + if (!nextOpen) reset() + }} + open={open} + > + }> + {triggerLabel} + + + + 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. + + + +
    +
      + + + +
    + + {step === "shape" ? ( + <> + + Product + + + + + Entitlement + + + One version history belongs to one (Product, Entitlement) pair. + + + + + Takes effect at (local time) + + update({ effectiveStart: toIsoInstant(event.currentTarget.value) }) + } + type="datetime-local" + value={toLocalInput(proposal.effectiveStart)} + /> + + The projection engine selects a version by each purchase’s own effective + time, not by now. A change that silently applied to yesterday is the failure grant + versioning exists to prevent, so backdating is a separate, checked choice. + + + + + +
    + + Purchase types this version covers + + {(["auto_renewable_subscription", "non_consumable"] as PurchaseType[]).map( + (type) => ( + + ), + )} +
    + +
    + + Subscription states that grant access + + {grantPolicyFields.map((field) => ( + + ))} +

    {PAUSE_POLICY_NOTE}

    +
    + + ) : null} + + {step !== "shape" && impact ? ( +
    +
    +

    {impactHeadline(impact)}

    +
    + + + + +
    +

    + Every count is from current committed state — the snapshot each customer’s + pointer names, not the whole snapshot history. Previewing writes nothing, not even + an audit event. +

    +
    + + {impact.additiveSuperset === false ? ( +
    + +

    + {narrowingCodeExplanation(impact.narrowingCode)} +

    +
    + ) : null} +
    + ) : null} + + {step === "publish" ? ( + + Reason for this change + { + // The value is read before the updater runs: React nulls + // `currentTarget` once the handler returns, so a lazy read + // inside the updater throws. + const reason = event.currentTarget.value + setProposal((current) => ({ ...current, reason })) + }} + value={proposal.reason ?? ""} + /> + + Recorded with the version and the audit event. It is what an investigation reads + months from now. + + + ) : null} + + {error ? ( +

    + {error} +

    + ) : null} +
    + + +
    + {step === "shape" ? ( + + ) : null} + {step === "review" ? ( + <> + + + + ) : null} + {step === "publish" ? ( + <> + + + + ) : null} +
    + {gate.explanation && step === "publish" ? ( +

    {gate.explanation}

    + ) : null} +

    + Publishing enqueues a reprojection for every Billing Customer whose current snapshot + cites this Product, in the same transaction as the version itself. +

    +
    +
    +
    + ) +} + +function StepChip({ active, index, label }: { active: boolean; index: number; label: string }) { + return ( +
  • + + {index}. {label} + +
  • + ) +} + +function ImpactRow({ label, value }: { label: string; value: number }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ) +} + +function defaultEffectiveStart() { + return new Date(Date.now() + 5 * 60 * 1000).toISOString() +} + +function toLocalInput(iso: string | undefined) { + if (!iso) return "" + const parsed = new Date(iso) + if (Number.isNaN(parsed.getTime())) return "" + const offset = parsed.getTimezoneOffset() * 60_000 + return new Date(parsed.getTime() - offset).toISOString().slice(0, 16) +} + +function toIsoInstant(local: string) { + if (!local) return "" + const parsed = new Date(local) + return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString() +} diff --git a/apps/dashboard/src/features/entitlement-grants/mutations/grant-version-mutations.ts b/apps/dashboard/src/features/entitlement-grants/mutations/grant-version-mutations.ts new file mode 100644 index 00000000..2cf6b21c --- /dev/null +++ b/apps/dashboard/src/features/entitlement-grants/mutations/grant-version-mutations.ts @@ -0,0 +1,56 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query" + +import { + previewProductEntitlementGrantImpact, + publishProductEntitlementGrantVersion, + type PublishGrantVersionRequest, +} from "@/generated/api" +import { grantVersionKeys } from "@/features/entitlement-grants/queries/grant-version-queries" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * Preview writes nothing — not even an audit event. + * + * That is deliberate on the API side and it is why this is a mutation rather + * than a query: an operator comparing three candidate policies before choosing + * one has not made three changes, so the result is never cached under a key + * that could be mistaken for committed state. + */ +export function previewGrantImpactMutationOptions(projectId: string) { + return mutationOptions({ + mutationFn: async (request: PublishGrantVersionRequest) => { + const result = await previewProductEntitlementGrantImpact({ + body: request, + client: generatedDashboardClient, + path: { projectId }, + throwOnError: true, + }) + return result.data.data + }, + }) +} + +/** + * The only call on this surface that changes what a Product grants. + * + * Publishing closes the current version at exactly the new version's start, so + * the intervals abut, and the same server transaction enqueues a reprojection + * for every Billing Customer whose current snapshot cites the Product. The + * history query is invalidated on success because the previously open interval + * has just been closed. + */ +export function publishGrantVersionMutationOptions(projectId: string, queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (request: PublishGrantVersionRequest) => { + const result = await publishProductEntitlementGrantVersion({ + body: request, + client: generatedDashboardClient, + path: { projectId }, + throwOnError: true, + }) + return result.data.data + }, + onSuccess: async () => + queryClient.invalidateQueries({ queryKey: grantVersionKeys.scope(projectId) }), + }) +} diff --git a/apps/dashboard/src/features/entitlement-grants/queries/grant-version-queries.ts b/apps/dashboard/src/features/entitlement-grants/queries/grant-version-queries.ts new file mode 100644 index 00000000..f462e7b2 --- /dev/null +++ b/apps/dashboard/src/features/entitlement-grants/queries/grant-version-queries.ts @@ -0,0 +1,35 @@ +import { queryOptions } from "@tanstack/react-query" + +import { listProductEntitlementGrantVersions } from "@/generated/api" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * Grant version history is Project-scoped and read by any member of the owning + * organization: an operator who can see a customer's entitlements but not the + * rule that produced them has been handed a fact with no explanation. + */ +export const grantVersionKeys = { + history: (projectId: string, productId: string, entitlementId: string | undefined) => + ["entitlement-grants", projectId, "versions", productId, entitlementId ?? "all"] as const, + scope: (projectId: string) => ["entitlement-grants", projectId] as const, +} + +export function grantVersionHistoryQueryOptions( + projectId: string, + productId: string, + entitlementId?: string, +) { + return queryOptions({ + queryKey: grantVersionKeys.history(projectId, productId, entitlementId), + queryFn: async ({ signal }) => { + const result = await listProductEntitlementGrantVersions({ + client: generatedDashboardClient, + path: { projectId }, + query: { limit: 200, productId, ...(entitlementId ? { entitlementId } : {}) }, + signal, + throwOnError: true, + }) + return result.data.data?.items ?? [] + }, + }) +} diff --git a/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.test.ts b/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.test.ts new file mode 100644 index 00000000..a9c58eca --- /dev/null +++ b/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest" + +import { + evaluatePublishGate, + isGrantVersionEditable, + narrowingCodeExplanation, + proposalFingerprint, + type GrantProposal, +} from "@/features/entitlement-grants/types/grant-version-view" + +const proposal: GrantProposal = { + effectiveStart: "2026-08-01T00:00:00.000Z", + entitlementId: "ent_01", + grantsInActive: true, + grantsInGrace: true, + grantsInTrial: true, + productId: "prod_01", + reason: "Grace access was never intended to be off.", + supportedPurchaseTypes: ["auto_renewable_subscription"], +} + +const previewed = proposalFingerprint(proposal) + +/** + * These tests protect the confirmation contract of the only Mosaic operation + * that can take access away from a customer who did nothing wrong. + * + * The realistic failure is a form that enables "Publish" from a preview taken + * against an earlier version of the proposal: the operator confirms "3 + * customers could lose access" and publishes a policy whose real number is 300. + * The gate is pure, so this is the cheapest layer that catches it. + */ +describe("grant version publish gate", () => { + it("refuses to publish until this exact proposal has been previewed", () => { + const withoutPreview = evaluatePublishGate({ + canManage: true, + impact: undefined, + isSubmitting: false, + previewedFingerprint: undefined, + proposal, + }) + expect(withoutPreview.allowed).toBe(false) + expect(withoutPreview.blockedBy).toBe("preview_stale") + }) + + it("invalidates the preview when any published field changes afterwards", () => { + const widened: GrantProposal = { ...proposal, grantsInBillingRetry: true } + const gate = evaluatePublishGate({ + canManage: true, + impact: { additiveSuperset: true, impactedActiveSources: 3 }, + isSubmitting: false, + previewedFingerprint: previewed, + proposal: widened, + }) + expect(gate.allowed).toBe(false) + expect(gate.blockedBy).toBe("preview_stale") + }) + + it("allows publishing a previewed, complete, additive proposal", () => { + expect( + evaluatePublishGate({ + canManage: true, + impact: { additiveSuperset: true, impactedActiveSources: 3 }, + isSubmitting: false, + previewedFingerprint: previewed, + proposal, + }).allowed, + ).toBe(true) + }) + + it("refuses a retroactive narrowing the publish call would reject anyway", () => { + const gate = evaluatePublishGate({ + canManage: true, + impact: { + additiveSuperset: false, + impactedActiveSources: 120, + narrowingCode: "grace_access_narrowed", + }, + isSubmitting: false, + previewedFingerprint: previewed, + proposal, + }) + expect(gate.allowed).toBe(false) + expect(gate.blockedBy).toBe("narrowing") + }) + + it("requires a reason and management permission", () => { + expect( + evaluatePublishGate({ + canManage: true, + impact: { additiveSuperset: true }, + isSubmitting: false, + previewedFingerprint: previewed, + proposal: { ...proposal, reason: " " }, + }).blockedBy, + ).toBe("reason_required") + + expect( + evaluatePublishGate({ + canManage: false, + impact: { additiveSuperset: true }, + isSubmitting: false, + previewedFingerprint: previewed, + proposal, + }).blockedBy, + ).toBe("no_permission") + }) +}) + +describe("grant version immutability", () => { + it("offers no edit affordance for any published version", () => { + // The API answers 409 grant_version_immutable to PATCH, PUT, and DELETE. + // A UI that offers editing turns a documented rule into a failed request. + expect(isGrantVersionEditable()).toBe(false) + }) + + it("explains every narrowing code the additive-superset rule can produce", () => { + for (const code of [ + "active_access_narrowed", + "billing_retry_access_narrowed", + "grace_access_narrowed", + "grant_identity_changed", + "one_time_access_narrowed", + "purchase_type_support_narrowed", + "trial_access_narrowed", + ]) { + expect(narrowingCodeExplanation(code)).toBeTruthy() + expect(narrowingCodeExplanation(code)).not.toContain(code) + } + // A code this build has not seen still explains the rule rather than + // rendering a bare enum member. + expect(narrowingCodeExplanation("future_narrowing")).toContain("never remove or narrow") + }) +}) diff --git a/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.ts b/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.ts new file mode 100644 index 00000000..c605271f --- /dev/null +++ b/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.ts @@ -0,0 +1,216 @@ +import type { GrantVersionImpact, PublishGrantVersionRequest } from "@/generated/api" + +/** + * What a Product grants, versioned. + * + * A grant version is selected by the *purchase's own effective time*, not by + * "now", so a published version is a historical fact about what someone bought. + * Editing one would change what a customer was entitled to at an instant that + * has already passed, which is why the API answers `409 grant_version_immutable` + * to PATCH, PUT, and DELETE alike and the database permits only one update ever + * (closing an open interval). + * + * This module holds the rules the UI has to enforce so an operator meets the + * refusal as a sentence rather than as a failed request: + * + * 1. There is no edit affordance on a published version, ever. The only forward + * action is "Create new version". + * 2. Publishing is gated on having previewed *this exact proposal*. Preview is + * free, repeatable, and writes nothing — including no audit event — so there + * is no cost to requiring it, and `impactedActiveSources` is the only number + * that answers "how many people could lose access". + */ + +export type GrantProposal = PublishGrantVersionRequest + +/** Every published version is immutable. The function exists to say so once. */ +export function isGrantVersionEditable() { + return false +} + +const NARROWING_CODE_SENTENCES: Record = { + active_access_narrowed: + "The version in force grants access during an active paid period and this proposal does not. A retroactive change may only widen access.", + billing_retry_access_narrowed: + "The version in force grants access during billing retry and this proposal does not. A retroactive change may only widen access.", + grace_access_narrowed: + "The version in force grants access during the store's grace period and this proposal does not. A retroactive change may only widen access.", + grant_identity_changed: + "The proposal names a different Product or Entitlement than the version it would replace. A version history belongs to one pair.", + one_time_access_narrowed: + "The version in force grants access from a one-time purchase and this proposal does not. A retroactive change may only widen access.", + purchase_type_support_narrowed: + "The version in force supports a purchase type this proposal drops. Purchases already made under it would be left granting nothing.", + trial_access_narrowed: + "The version in force grants access during a trial and this proposal does not. A retroactive change may only widen access.", +} + +function humanize(value: string) { + const spaced = value.replaceAll("_", " ") + return spaced.charAt(0).toUpperCase() + spaced.slice(1) +} + +export function narrowingCodeExplanation(code: string | undefined) { + if (!code) return undefined + return ( + NARROWING_CODE_SENTENCES[code] ?? + `Mosaic reported the narrowing "${humanize(code).toLowerCase()}". A retroactive grant version may add Entitlements or widen access policy, never remove or narrow either.` + ) +} + +const POLICY_LABELS: Record = { + grantsInActive: "Active paid period", + grantsInBillingRetry: "Billing retry", + grantsInGrace: "Store grace period", + grantsInOneTimeOwnership: "One-time purchase ownership", + grantsInTrial: "Trial period", +} + +interface GrantAccessPolicyFields { + grantsInActive?: boolean + grantsInBillingRetry?: boolean + grantsInGrace?: boolean + grantsInOneTimeOwnership?: boolean + grantsInTrial?: boolean +} + +export const grantPolicyFields = Object.keys(POLICY_LABELS) as (keyof GrantAccessPolicyFields)[] + +export function grantPolicyLabel(field: keyof GrantAccessPolicyFields) { + return POLICY_LABELS[field] +} + +const POLICY_NOTES: Partial> = { + grantsInBillingRetry: + "Contradicts both stores' documentation: neither grants access while a charge is being retried. Only an organization owner may turn this on.", + grantsInGrace: "Both stores grant access during grace, so this is on by default.", +} + +export function grantPolicyNote(field: keyof GrantAccessPolicyFields) { + return POLICY_NOTES[field] +} + +/** + * Google's pause never grants access and the policy is not overridable, so the + * form never offers it. The API accepts `grantsInPaused` only in order to refuse + * it with 422; offering a control whose only outcome is a refusal is worse than + * stating the rule. + */ +export const PAUSE_POLICY_NOTE = + "Google's pause never grants access. That is fixed and cannot be overridden by a grant version." + +/** + * A stable identity for a proposal. + * + * The publish gate compares this against the proposal the preview was taken + * for. Any field the operator changes after previewing invalidates the + * confirmation, because the number they were shown — how many customers could + * lose access — no longer describes what they are about to publish. + */ +export function proposalFingerprint(proposal: GrantProposal) { + return JSON.stringify([ + proposal.productId, + proposal.entitlementId, + proposal.effectiveStart, + proposal.retroactive === true, + [...(proposal.supportedPurchaseTypes ?? [])].sort(), + proposal.grantsInActive === true, + proposal.grantsInTrial === true, + proposal.grantsInGrace === true, + proposal.grantsInBillingRetry === true, + proposal.grantsInOneTimeOwnership === true, + ]) +} + +export type PublishBlockedReason = + | "already_publishing" + | "incomplete" + | "narrowing" + | "no_permission" + | "preview_stale" + | "reason_required" + +export interface PublishGate { + allowed: boolean + blockedBy?: PublishBlockedReason + explanation?: string +} + +const BLOCKED_EXPLANATIONS: Record = { + already_publishing: "Mosaic is publishing this version.", + incomplete: "Choose a Product, an Entitlement, and the instant the new version takes effect.", + narrowing: + "This retroactive proposal would take access away. The publish call would refuse it, so it is refused here too.", + no_permission: "Publishing a grant version requires organization owner or admin permission.", + preview_stale: + "Preview the impact of this exact proposal first. The confirmation has to state how many customers could lose access, and that number changes with every field.", + reason_required: + "Give the reason for this change. It is what an investigation reads months from now.", +} + +/** + * The one place that decides whether "Publish" is enabled. + * + * The preview requirement is not a nicety: a retroactive narrowing is the only + * operation in Mosaic that can take access from a customer who did nothing + * wrong, and `impactedActiveSources` is the number that describes it. Enabling + * publish before that number has been shown for the *current* proposal would + * make the confirmation describe something adjacent to what is published. + */ +export function evaluatePublishGate(input: { + canManage: boolean + impact: GrantVersionImpact | undefined + isSubmitting: boolean + previewedFingerprint: string | undefined + proposal: GrantProposal +}): PublishGate { + const gate = (blockedBy: PublishBlockedReason): PublishGate => ({ + allowed: false, + blockedBy, + explanation: BLOCKED_EXPLANATIONS[blockedBy], + }) + + if (!input.canManage) return gate("no_permission") + if (input.isSubmitting) return gate("already_publishing") + if ( + !input.proposal.productId || + !input.proposal.entitlementId || + !input.proposal.effectiveStart + ) { + return gate("incomplete") + } + if (!input.proposal.reason || input.proposal.reason.trim().length === 0) { + return gate("reason_required") + } + if (!input.impact || input.previewedFingerprint !== proposalFingerprint(input.proposal)) { + return gate("preview_stale") + } + if (input.impact.additiveSuperset === false) return gate("narrowing") + + return { allowed: true } +} + +/** + * The sentence the confirmation step leads with. + * + * `impactedActiveSources` comes first and in words, because "12" beside a label + * is read as a statistic while "12 customers could lose access" is read as a + * decision. + */ +export function impactHeadline(impact: GrantVersionImpact | undefined) { + if (!impact) return "Nothing has been previewed yet." + const active = impact.impactedActiveSources ?? 0 + if (active === 0) { + return "No purchase currently granting access cites this Product, so no customer can lose access from this change." + } + return `${active} purchase${active === 1 ? "" : "s"} currently granting access cite this Product. That is how many customers could lose access if this change narrows what it grants.` +} + +/** Half-open `[start, end)`. The absent end is the version in force now. */ +export function grantIntervalLabel( + effectiveStart: string | undefined, + effectiveEnd: string | undefined, +) { + const start = effectiveStart ?? "—" + return effectiveEnd ? `${start} → ${effectiveEnd}` : `${start} → in force now` +} diff --git a/apps/dashboard/src/features/organizations/components/cloud-workspace-shell.tsx b/apps/dashboard/src/features/organizations/components/cloud-workspace-shell.tsx index ed99e927..1eb90083 100644 --- a/apps/dashboard/src/features/organizations/components/cloud-workspace-shell.tsx +++ b/apps/dashboard/src/features/organizations/components/cloud-workspace-shell.tsx @@ -109,6 +109,15 @@ export function CloudWorkspaceShell() { 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", @@ -129,6 +138,31 @@ export function CloudWorkspaceShell() { 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` @@ -157,6 +191,16 @@ export function CloudWorkspaceShell() { 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: <>, + }, ] : []), { diff --git a/apps/dashboard/src/features/organizations/types/workspace-navigation.test.ts b/apps/dashboard/src/features/organizations/types/workspace-navigation.test.ts index 9656b62e..8b927529 100644 --- a/apps/dashboard/src/features/organizations/types/workspace-navigation.test.ts +++ b/apps/dashboard/src/features/organizations/types/workspace-navigation.test.ts @@ -53,4 +53,17 @@ describe("hosted workspace route scope", () => { expect(isEnvironmentSurface(route)).toBe(true) expect(isProjectWideSurface(route)).toBe(false) }) + + it("keeps Billing Environment identity URL-owned", () => { + const route = + "/organizations/org_one/projects/project_one/billing/env_production/projection-health" + + expect(readWorkspaceScope(route)).toEqual({ + environmentId: "env_production", + organizationId: "org_one", + projectId: "project_one", + }) + expect(isEnvironmentSurface(route)).toBe(true) + expect(isProjectWideSurface(route)).toBe(false) + }) }) diff --git a/apps/dashboard/src/features/organizations/types/workspace-navigation.ts b/apps/dashboard/src/features/organizations/types/workspace-navigation.ts index 039569b2..30494424 100644 --- a/apps/dashboard/src/features/organizations/types/workspace-navigation.ts +++ b/apps/dashboard/src/features/organizations/types/workspace-navigation.ts @@ -10,6 +10,7 @@ export function readWorkspaceScope(pathname: string): WorkspaceScope { const projectIndex = segments.indexOf("projects") const monetizationIndex = segments.indexOf("monetization") const analyticsIndex = segments.indexOf("analytics") + const billingIndex = segments.indexOf("billing") return { environmentId: @@ -17,7 +18,9 @@ export function readWorkspaceScope(pathname: string): WorkspaceScope { ? segments[monetizationIndex + 1] : analyticsIndex >= 0 ? segments[analyticsIndex + 1] - : undefined, + : billingIndex >= 0 + ? segments[billingIndex + 1] + : undefined, organizationId: organizationIndex >= 0 && segments[organizationIndex + 1] !== "new" ? segments[organizationIndex + 1] @@ -37,6 +40,7 @@ export function isEnvironmentSurface(pathname: string) { return ( pathname.endsWith("/settings/api-keys") || pathname.includes("/monetization/") || - pathname.includes("/analytics/") + pathname.includes("/analytics/") || + pathname.includes("/billing/") ) } diff --git a/apps/dashboard/src/generated/api/index.ts b/apps/dashboard/src/generated/api/index.ts index 3b9c3c33..6c81cf8e 100644 --- a/apps/dashboard/src/generated/api/index.ts +++ b/apps/dashboard/src/generated/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addMember, addPlanProduct, addProductEntitlement, archiveAsset, archivePlacementAttribute, archivePlacementRuleSet, archivePlacementWithUsageCheck, archiveProduct, archiveProject, archiveProviderMapping, bindPlacement, clearActiveProviderAssignment, clonePaywallVersionToDraft, clonePlacementRuleSetVersion, closeQuarantineRecordSuperseded, compareAnalyticsPaywallVersions, createAnalyticsEventExport, createAnalyticsPrivacyDeletion, createAnalyticsPrivacyExport, createApiKey, createApplication, createEntitlement, createExperiment, createExperimentGroupVersion, createExperimentMutualExclusionGroupVersion, createExperimentQaOverride, createExperimentRawExport, createOrganization, createPaywall, createPaywallDraft, createPlacement, createPlacementAlias, createPlacementAttribute, createPlacementQaOverride, createPlacementRuleSet, createPlan, createProduct, createProject, createProviderConnection, createProviderMapping, createProviderMappingDraft, createProviderMappingObservation, createReconciliationRun, createReplayJob, createStoreServerCredential, deleteProduct, downloadAnalyticsJob, enqueueProviderSync, getActivePaywallDraft, getActiveProviderAssignment, getAnalyticsBreakdown, getAnalyticsFreshness, getAnalyticsFunnel, getAnalyticsJob, getAnalyticsOverview, getAnalyticsProductAvailabilityFailures, getAnalyticsProviderErrors, getAnalyticsSettings, getAsset, getAssetContent, getAssetUsage, getBillingHealth, getEntitlement, getExperiment, getExperimentResults, getExperimentSampleRatioMismatch, getHealth, getNativeProviderProfile, getOrganization, getPaywall, getPaywallDraft, getPaywallVersion, getPlacementBinding, getPlacementDecision, getPlacementUsage, getPlan, getProduct, getProductReadiness, getProductUsage, getProject, getProviderConnection, getProviderConnectionCapabilities, getProviderConnectionHealth, getProviderMappingMetadata, getProviderMappingUsage, getProviderReadiness, getQuarantineRecord, getReadiness, getSdkCommerceConfiguration, getSdkConfiguration, getSession, getStoreServerCredential, importProviderProducts, ingestAnalyticsEventBatch, listApiKeys, listApplications, listAssets, listAuditEvents, listBillingLedger, listBillingQuarantine, listConfigurationReleases, listEntitlements, listEnvironments, listExperimentGroups, listExperimentHistory, listExperimentMetricDefinitions, listExperimentMutualExclusionGroupVersions, listExperimentQaOverrides, listExperiments, listExperimentVersions, listMembers, listOrganizations, listPaywalls, listPaywallVersions, listPlacementAliases, listPlacementAttributes, listPlacementQaOverrides, listPlacementRuleSetVersions, listPlacements, listPlanProducts, listPlans, listProductEntitlements, listProducts, listProjects, listProviderConnectionDiagnostics, listProviderConnections, listProviderMappingObservations, listProviderMappings, listProviderSyncRuns, listReconciliationRuns, listReplayJobs, listStoreServerCredentials, listTransactionFacts, listValidationAttempts, login, logout, type Options, previewAnalyticsPrivacyRequest, previewProviderCatalog, publishConfiguration, publishExperiment, publishPlacementRuleSet, receiveAppleStoreNotification, reconnectProviderConnection, removeMember, removePlanProduct, removeProductEntitlement, replaceProviderConnectionScopes, replaceProviderMapping, restoreProduct, restoreProject, retryQuarantinedInput, revokeApiKey, revokeExperimentQaOverride, revokePlacementQaOverride, revokeProviderConnection, revokeStoreServerCredential, rollbackConfigurationRelease, rotateApiKey, rotateProviderCredential, rotateStoreServerCredential, setActiveProviderAssignment, setEnvironmentMode, setProductReplacement, signUp, simulatePlacementDecision, submitServerTransactionObservation, submitTransactionObservation, testProviderConnection, testStoreServerCredential, transitionExperimentLifecycle, updateAnalyticsSettings, updateBillingSettings, updateEntitlement, updateEnvironment, updateExperimentDraft, updateMember, updateOrganization, updatePaywall, updatePaywallDraft, updatePlacement, updatePlacementRuleSetDraft, updatePlan, updateProduct, updateProject, uploadAsset, validateExperimentDraft, validatePaywallDraft, validatePlacementRuleSet } from './sdk.gen'; -export type { ActiveProviderAssignment, ActorId, AddMemberData, AddMemberError, AddMemberErrors, AddMemberRequest, AddMemberResponse, AddMemberResponses, AddPlanProductData, AddPlanProductError, AddPlanProductErrors, AddPlanProductResponse, AddPlanProductResponses, AddProductEntitlementData, AddProductEntitlementError, AddProductEntitlementErrors, AddProductEntitlementResponse, AddProductEntitlementResponses, AnalyticsApplicationVersion, AnalyticsEventBatch, AnalyticsEventResult, AnalyticsFreshness, AnalyticsFrom, AnalyticsIdentityRequest, AnalyticsIngestionResult, AnalyticsJob, AnalyticsJobEnvelope, AnalyticsLocale, AnalyticsMetric, AnalyticsMetricBasis, AnalyticsPlatform, AnalyticsPrivacyPreview, AnalyticsPrivacyPreviewEnvelope, AnalyticsResult, AnalyticsResultEnvelope, AnalyticsSettings, AnalyticsSettingsEnvelope, AnalyticsTimezone, AnalyticsTo, ApiKey, ApiKeyEnvelope, ApiKeyId, ApiKeyKind, ApiKeyList, ApiKeyListEnvelope, ApiKeySecretEnvelope, ApiKeySecretResult, Application, ApplicationEnvelope, ApplicationId, ApplicationList, ApplicationListEnvelope, ArchiveAssetData, ArchiveAssetError, ArchiveAssetErrors, ArchiveAssetResponse, ArchiveAssetResponses, ArchivePlacementAttributeData, ArchivePlacementAttributeError, ArchivePlacementAttributeErrors, ArchivePlacementAttributeResponse, ArchivePlacementAttributeResponses, ArchivePlacementRuleSetData, ArchivePlacementRuleSetError, ArchivePlacementRuleSetErrors, ArchivePlacementRuleSetResponse, ArchivePlacementRuleSetResponses, ArchivePlacementWithUsageCheckData, ArchivePlacementWithUsageCheckError, ArchivePlacementWithUsageCheckErrors, ArchivePlacementWithUsageCheckResponse, ArchivePlacementWithUsageCheckResponses, ArchiveProductData, ArchiveProductError, ArchiveProductErrors, ArchiveProductResponse, ArchiveProductResponses, ArchiveProjectData, ArchiveProjectError, ArchiveProjectErrors, ArchiveProjectResponse, ArchiveProjectResponses, ArchiveProviderMappingData, ArchiveProviderMappingError, ArchiveProviderMappingErrors, ArchiveProviderMappingResponse, ArchiveProviderMappingResponses, Asset, AssetEnvelope, AssetId, AssetListEnvelope, AssetUsage, AssetUsageEnvelope, AuditEvent, AuditEventList, AuditEventListEnvelope, BillingFrom, BillingHealth, BillingLedgerEntry, BillingProviderFilter, BillingTo, BindPlacementData, BindPlacementError, BindPlacementErrors, BindPlacementRequest, BindPlacementResponse, BindPlacementResponses, ClearActiveProviderAssignmentData, ClearActiveProviderAssignmentError, ClearActiveProviderAssignmentErrors, ClearActiveProviderAssignmentResponse, ClearActiveProviderAssignmentResponses, ClientOptions, ClientTransactionObservation, ClientTransactionObservationRecord, ClonePaywallVersionToDraftData, ClonePaywallVersionToDraftError, ClonePaywallVersionToDraftErrors, ClonePaywallVersionToDraftResponse, ClonePaywallVersionToDraftResponses, ClonePlacementRuleSetVersionData, ClonePlacementRuleSetVersionError, ClonePlacementRuleSetVersionErrors, ClonePlacementRuleSetVersionResponse, ClonePlacementRuleSetVersionResponses, CloseQuarantineRecordSupersededData, CloseQuarantineRecordSupersededError, CloseQuarantineRecordSupersededErrors, CloseQuarantineRecordSupersededResponse, CloseQuarantineRecordSupersededResponses, CompareAnalyticsPaywallVersionsData, CompareAnalyticsPaywallVersionsError, CompareAnalyticsPaywallVersionsErrors, CompareAnalyticsPaywallVersionsResponse, CompareAnalyticsPaywallVersionsResponses, ConfigurationRelease, CreateAnalyticsEventExportData, CreateAnalyticsEventExportError, CreateAnalyticsEventExportErrors, CreateAnalyticsEventExportRequest, CreateAnalyticsEventExportResponse, CreateAnalyticsEventExportResponses, CreateAnalyticsPrivacyDeletionData, CreateAnalyticsPrivacyDeletionError, CreateAnalyticsPrivacyDeletionErrors, CreateAnalyticsPrivacyDeletionRequest, CreateAnalyticsPrivacyDeletionResponse, CreateAnalyticsPrivacyDeletionResponses, CreateAnalyticsPrivacyExportData, CreateAnalyticsPrivacyExportError, CreateAnalyticsPrivacyExportErrors, CreateAnalyticsPrivacyExportRequest, CreateAnalyticsPrivacyExportResponse, CreateAnalyticsPrivacyExportResponses, CreateApiKeyData, CreateApiKeyError, CreateApiKeyErrors, CreateApiKeyRequest, CreateApiKeyResponse, CreateApiKeyResponses, CreateApplicationData, CreateApplicationError, CreateApplicationErrors, CreateApplicationRequest, CreateApplicationResponse, CreateApplicationResponses, CreateCatalogResourceRequest, CreateDraftRequest, CreateEntitlementData, CreateEntitlementError, CreateEntitlementErrors, CreateEntitlementResponse, CreateEntitlementResponses, CreateExperimentData, CreateExperimentError, CreateExperimentErrors, CreateExperimentExportRequest, CreateExperimentGroupRequest, CreateExperimentGroupVersionData, CreateExperimentGroupVersionError, CreateExperimentGroupVersionErrors, CreateExperimentGroupVersionRequest, CreateExperimentGroupVersionResponse, CreateExperimentGroupVersionResponses, CreateExperimentMutualExclusionGroupVersionData, CreateExperimentMutualExclusionGroupVersionError, CreateExperimentMutualExclusionGroupVersionErrors, CreateExperimentMutualExclusionGroupVersionResponse, CreateExperimentMutualExclusionGroupVersionResponses, CreateExperimentQaOverrideData, CreateExperimentQaOverrideError, CreateExperimentQaOverrideErrors, CreateExperimentQaOverrideRequest, CreateExperimentQaOverrideResponse, CreateExperimentQaOverrideResponses, CreateExperimentRawExportData, CreateExperimentRawExportError, CreateExperimentRawExportErrors, CreateExperimentRawExportResponse, CreateExperimentRawExportResponses, CreateExperimentRequest, CreateExperimentResponse, CreateExperimentResponses, CreateOrganizationData, CreateOrganizationError, CreateOrganizationErrors, CreateOrganizationRequest, CreateOrganizationResponse, CreateOrganizationResponses, CreatePaywallData, CreatePaywallDraftData, CreatePaywallDraftError, CreatePaywallDraftErrors, CreatePaywallDraftResponse, CreatePaywallDraftResponses, CreatePaywallError, CreatePaywallErrors, CreatePaywallRequest, CreatePaywallResponse, CreatePaywallResponses, CreatePlacementAliasData, CreatePlacementAliasError, CreatePlacementAliasErrors, CreatePlacementAliasResponse, CreatePlacementAliasResponses, CreatePlacementAttributeData, CreatePlacementAttributeError, CreatePlacementAttributeErrors, CreatePlacementAttributeRequest, CreatePlacementAttributeResponse, CreatePlacementAttributeResponses, CreatePlacementData, CreatePlacementError, CreatePlacementErrors, CreatePlacementQaOverrideData, CreatePlacementQaOverrideError, CreatePlacementQaOverrideErrors, CreatePlacementQaOverrideResponse, CreatePlacementQaOverrideResponses, CreatePlacementRequest, CreatePlacementResponse, CreatePlacementResponses, CreatePlacementRuleSetData, CreatePlacementRuleSetError, CreatePlacementRuleSetErrors, CreatePlacementRuleSetResponse, CreatePlacementRuleSetResponses, CreatePlanData, CreatePlanError, CreatePlanErrors, CreatePlanResponse, CreatePlanResponses, CreateProductData, CreateProductError, CreateProductErrors, CreateProductRequest, CreateProductResponse, CreateProductResponses, CreateProjectData, CreateProjectError, CreateProjectErrors, CreateProjectRequest, CreateProjectResponse, CreateProjectResponses, CreateProviderConnectionData, CreateProviderConnectionError, CreateProviderConnectionErrors, CreateProviderConnectionRequest, CreateProviderConnectionRequestWritable, CreateProviderConnectionResponse, CreateProviderConnectionResponses, CreateProviderMappingData, CreateProviderMappingDraftData, CreateProviderMappingDraftError, CreateProviderMappingDraftErrors, CreateProviderMappingDraftRequest, CreateProviderMappingDraftResponse, CreateProviderMappingDraftResponses, CreateProviderMappingError, CreateProviderMappingErrors, CreateProviderMappingObservationData, CreateProviderMappingObservationError, CreateProviderMappingObservationErrors, CreateProviderMappingObservationRequest, CreateProviderMappingObservationResponse, CreateProviderMappingObservationResponses, CreateProviderMappingRequest, CreateProviderMappingResponse, CreateProviderMappingResponses, CreateQaOverrideRequest, CreateQaOverrideRequestWritable, CreateReconciliationRunData, CreateReconciliationRunError, CreateReconciliationRunErrors, CreateReconciliationRunRequest, CreateReconciliationRunResponse, CreateReconciliationRunResponses, CreateReplayJobData, CreateReplayJobError, CreateReplayJobErrors, CreateReplayJobRequest, CreateReplayJobResponse, CreateReplayJobResponses, CreateStoreServerCredentialData, CreateStoreServerCredentialError, CreateStoreServerCredentialErrors, CreateStoreServerCredentialRequest, CreateStoreServerCredentialResponse, CreateStoreServerCredentialResponses, Cursor, DeleteProductData, DeleteProductError, DeleteProductErrors, DeleteProductResponse, DeleteProductResponses, DownloadAnalyticsJobData, DownloadAnalyticsJobError, DownloadAnalyticsJobErrors, DownloadAnalyticsJobResponse, DownloadAnalyticsJobResponses, Draft, DraftEnvelope, DraftId, DraftResource, EnqueueProviderSyncData, EnqueueProviderSyncError, EnqueueProviderSyncErrors, EnqueueProviderSyncResponse, EnqueueProviderSyncResponses, Entitlement, EntitlementEnvelope, EntitlementId, EntitlementList, EntitlementListEnvelope, EntitlementReferenceRequest, Environment, EnvironmentEnvelope, EnvironmentId, EnvironmentList, EnvironmentListEnvelope, EnvironmentMode, ErrorEnvelope, Experiment, ExperimentDraft, ExperimentDraftDocument, ExperimentDraftEnvelope, ExperimentEnvelope, ExperimentGroup, ExperimentGroupCreated, ExperimentGroupCreatedEnvelope, ExperimentGroupListEnvelope, ExperimentGroupMemberInput, ExperimentGroupVersion, ExperimentGroupVersionEnvelope, ExperimentGroupVersionListEnvelope, ExperimentGuardrailMaturity, ExperimentGuardrailResult, ExperimentGuardrailVariantResult, ExperimentHistory, ExperimentHistoryListEnvelope, ExperimentId, ExperimentInterval, ExperimentLift, ExperimentListEnvelope, ExperimentMetricDefinition, ExperimentMetricListEnvelope, ExperimentQaOverride, ExperimentQaOverrideCreated, ExperimentQaOverrideCreatedEnvelope, ExperimentQaOverrideCreatedEnvelopeWritable, ExperimentQaOverrideCreatedWritable, ExperimentQaOverrideListEnvelope, ExperimentResults, ExperimentResultsEnvelope, ExperimentSchedule, ExperimentSrm, ExperimentValidation, ExperimentValidationEnvelope, ExperimentValidationIssue, ExperimentVariantDraft, ExperimentVariantResult, ExperimentVariantVersion, ExperimentVersion, ExperimentVersionEnvelope, ExperimentVersionListEnvelope, GetActivePaywallDraftData, GetActivePaywallDraftError, GetActivePaywallDraftErrors, GetActivePaywallDraftResponse, GetActivePaywallDraftResponses, GetActiveProviderAssignmentData, GetActiveProviderAssignmentError, GetActiveProviderAssignmentErrors, GetActiveProviderAssignmentResponse, GetActiveProviderAssignmentResponses, GetAnalyticsBreakdownData, GetAnalyticsBreakdownError, GetAnalyticsBreakdownErrors, GetAnalyticsBreakdownResponse, GetAnalyticsBreakdownResponses, GetAnalyticsFreshnessData, GetAnalyticsFreshnessError, GetAnalyticsFreshnessErrors, GetAnalyticsFreshnessResponse, GetAnalyticsFreshnessResponses, GetAnalyticsFunnelData, GetAnalyticsFunnelError, GetAnalyticsFunnelErrors, GetAnalyticsFunnelResponse, GetAnalyticsFunnelResponses, GetAnalyticsJobData, GetAnalyticsJobError, GetAnalyticsJobErrors, GetAnalyticsJobResponse, GetAnalyticsJobResponses, GetAnalyticsOverviewData, GetAnalyticsOverviewError, GetAnalyticsOverviewErrors, GetAnalyticsOverviewResponse, GetAnalyticsOverviewResponses, GetAnalyticsProductAvailabilityFailuresData, GetAnalyticsProductAvailabilityFailuresError, GetAnalyticsProductAvailabilityFailuresErrors, GetAnalyticsProductAvailabilityFailuresResponse, GetAnalyticsProductAvailabilityFailuresResponses, GetAnalyticsProviderErrorsData, GetAnalyticsProviderErrorsError, GetAnalyticsProviderErrorsErrors, GetAnalyticsProviderErrorsResponse, GetAnalyticsProviderErrorsResponses, GetAnalyticsSettingsData, GetAnalyticsSettingsError, GetAnalyticsSettingsErrors, GetAnalyticsSettingsResponse, GetAnalyticsSettingsResponses, GetAssetContentData, GetAssetContentError, GetAssetContentErrors, GetAssetContentResponse, GetAssetContentResponses, GetAssetData, GetAssetError, GetAssetErrors, GetAssetResponse, GetAssetResponses, GetAssetUsageData, GetAssetUsageError, GetAssetUsageErrors, GetAssetUsageResponse, GetAssetUsageResponses, GetBillingHealthData, GetBillingHealthError, GetBillingHealthErrors, GetBillingHealthResponse, GetBillingHealthResponses, GetEntitlementData, GetEntitlementError, GetEntitlementErrors, GetEntitlementResponse, GetEntitlementResponses, GetExperimentData, GetExperimentError, GetExperimentErrors, GetExperimentResponse, GetExperimentResponses, GetExperimentResultsData, GetExperimentResultsResponse, GetExperimentResultsResponses, GetExperimentSampleRatioMismatchData, GetExperimentSampleRatioMismatchResponse, GetExperimentSampleRatioMismatchResponses, GetHealthData, GetHealthResponse, GetHealthResponses, GetNativeProviderProfileData, GetNativeProviderProfileError, GetNativeProviderProfileErrors, GetNativeProviderProfileResponse, GetNativeProviderProfileResponses, GetOrganizationData, GetOrganizationError, GetOrganizationErrors, GetOrganizationResponse, GetOrganizationResponses, GetPaywallData, GetPaywallDraftData, GetPaywallDraftError, GetPaywallDraftErrors, GetPaywallDraftResponse, GetPaywallDraftResponses, GetPaywallError, GetPaywallErrors, GetPaywallResponse, GetPaywallResponses, GetPaywallVersionData, GetPaywallVersionError, GetPaywallVersionErrors, GetPaywallVersionResponse, GetPaywallVersionResponses, GetPlacementBindingData, GetPlacementBindingError, GetPlacementBindingErrors, GetPlacementBindingResponse, GetPlacementBindingResponses, GetPlacementDecisionData, GetPlacementDecisionError, GetPlacementDecisionErrors, GetPlacementDecisionResponse, GetPlacementDecisionResponses, GetPlacementUsageData, GetPlacementUsageError, GetPlacementUsageErrors, GetPlacementUsageResponse, GetPlacementUsageResponses, GetPlanData, GetPlanError, GetPlanErrors, GetPlanResponse, GetPlanResponses, GetProductData, GetProductError, GetProductErrors, GetProductReadinessData, GetProductReadinessError, GetProductReadinessErrors, GetProductReadinessResponse, GetProductReadinessResponses, GetProductResponse, GetProductResponses, GetProductUsageData, GetProductUsageError, GetProductUsageErrors, GetProductUsageResponse, GetProductUsageResponses, GetProjectData, GetProjectError, GetProjectErrors, GetProjectResponse, GetProjectResponses, GetProviderConnectionCapabilitiesData, GetProviderConnectionCapabilitiesError, GetProviderConnectionCapabilitiesErrors, GetProviderConnectionCapabilitiesResponse, GetProviderConnectionCapabilitiesResponses, GetProviderConnectionData, GetProviderConnectionError, GetProviderConnectionErrors, GetProviderConnectionHealthData, GetProviderConnectionHealthError, GetProviderConnectionHealthErrors, GetProviderConnectionHealthResponse, GetProviderConnectionHealthResponses, GetProviderConnectionResponse, GetProviderConnectionResponses, GetProviderMappingMetadataData, GetProviderMappingMetadataError, GetProviderMappingMetadataErrors, GetProviderMappingMetadataResponse, GetProviderMappingMetadataResponses, GetProviderMappingUsageData, GetProviderMappingUsageError, GetProviderMappingUsageErrors, GetProviderMappingUsageResponse, GetProviderMappingUsageResponses, GetProviderReadinessData, GetProviderReadinessError, GetProviderReadinessErrors, GetProviderReadinessResponse, GetProviderReadinessResponses, GetQuarantineRecordData, GetQuarantineRecordError, GetQuarantineRecordErrors, GetQuarantineRecordResponse, GetQuarantineRecordResponses, GetReadinessData, GetReadinessError, GetReadinessErrors, GetReadinessResponse, GetReadinessResponses, GetSdkCommerceConfigurationData, GetSdkCommerceConfigurationError, GetSdkCommerceConfigurationErrors, GetSdkCommerceConfigurationResponse, GetSdkCommerceConfigurationResponses, GetSdkConfigurationData, GetSdkConfigurationError, GetSdkConfigurationErrors, GetSdkConfigurationResponse, GetSdkConfigurationResponses, GetSessionData, GetSessionError, GetSessionErrors, GetSessionResponse, GetSessionResponses, GetStoreServerCredentialData, GetStoreServerCredentialError, GetStoreServerCredentialErrors, GetStoreServerCredentialResponse, GetStoreServerCredentialResponses, HealthEnvelope, IdempotencyKey, IfMatch, ImportProviderProductsData, ImportProviderProductsError, ImportProviderProductsErrors, ImportProviderProductsResponse, ImportProviderProductsResponses, IngestAnalyticsEventBatchData, IngestAnalyticsEventBatchError, IngestAnalyticsEventBatchErrors, IngestAnalyticsEventBatchResponse, IngestAnalyticsEventBatchResponses, Limit, ListApiKeysData, ListApiKeysError, ListApiKeysErrors, ListApiKeysResponse, ListApiKeysResponses, ListApplicationsData, ListApplicationsError, ListApplicationsErrors, ListApplicationsResponse, ListApplicationsResponses, ListAssetsData, ListAssetsError, ListAssetsErrors, ListAssetsResponse, ListAssetsResponses, ListAuditEventsData, ListAuditEventsError, ListAuditEventsErrors, ListAuditEventsResponse, ListAuditEventsResponses, ListBillingLedgerData, ListBillingLedgerError, ListBillingLedgerErrors, ListBillingLedgerResponse, ListBillingLedgerResponses, ListBillingQuarantineData, ListBillingQuarantineError, ListBillingQuarantineErrors, ListBillingQuarantineResponse, ListBillingQuarantineResponses, ListConfigurationReleasesData, ListConfigurationReleasesError, ListConfigurationReleasesErrors, ListConfigurationReleasesResponse, ListConfigurationReleasesResponses, ListEntitlementsData, ListEntitlementsError, ListEntitlementsErrors, ListEntitlementsResponse, ListEntitlementsResponses, ListEnvironmentsData, ListEnvironmentsError, ListEnvironmentsErrors, ListEnvironmentsResponse, ListEnvironmentsResponses, ListExperimentGroupsData, ListExperimentGroupsResponse, ListExperimentGroupsResponses, ListExperimentHistoryData, ListExperimentHistoryResponse, ListExperimentHistoryResponses, ListExperimentMetricDefinitionsData, ListExperimentMetricDefinitionsResponse, ListExperimentMetricDefinitionsResponses, ListExperimentMutualExclusionGroupVersionsData, ListExperimentMutualExclusionGroupVersionsError, ListExperimentMutualExclusionGroupVersionsErrors, ListExperimentMutualExclusionGroupVersionsResponse, ListExperimentMutualExclusionGroupVersionsResponses, ListExperimentQaOverridesData, ListExperimentQaOverridesResponse, ListExperimentQaOverridesResponses, ListExperimentsData, ListExperimentsResponse, ListExperimentsResponses, ListExperimentVersionsData, ListExperimentVersionsResponse, ListExperimentVersionsResponses, ListMembersData, ListMembersError, ListMembersErrors, ListMembersResponse, ListMembersResponses, ListOrganizationsData, ListOrganizationsError, ListOrganizationsErrors, ListOrganizationsResponse, ListOrganizationsResponses, ListPaywallsData, ListPaywallsError, ListPaywallsErrors, ListPaywallsResponse, ListPaywallsResponses, ListPaywallVersionsData, ListPaywallVersionsError, ListPaywallVersionsErrors, ListPaywallVersionsResponse, ListPaywallVersionsResponses, ListPlacementAliasesData, ListPlacementAliasesResponse, ListPlacementAliasesResponses, ListPlacementAttributesData, ListPlacementAttributesError, ListPlacementAttributesErrors, ListPlacementAttributesResponse, ListPlacementAttributesResponses, ListPlacementQaOverridesData, ListPlacementQaOverridesResponse, ListPlacementQaOverridesResponses, ListPlacementRuleSetVersionsData, ListPlacementRuleSetVersionsResponse, ListPlacementRuleSetVersionsResponses, ListPlacementsData, ListPlacementsError, ListPlacementsErrors, ListPlacementsResponse, ListPlacementsResponses, ListPlanProductsData, ListPlanProductsError, ListPlanProductsErrors, ListPlanProductsResponse, ListPlanProductsResponses, ListPlansData, ListPlansError, ListPlansErrors, ListPlansResponse, ListPlansResponses, ListProductEntitlementsData, ListProductEntitlementsError, ListProductEntitlementsErrors, ListProductEntitlementsResponse, ListProductEntitlementsResponses, ListProductsData, ListProductsError, ListProductsErrors, ListProductsResponse, ListProductsResponses, ListProjectsData, ListProjectsError, ListProjectsErrors, ListProjectsResponse, ListProjectsResponses, ListProviderConnectionDiagnosticsData, ListProviderConnectionDiagnosticsError, ListProviderConnectionDiagnosticsErrors, ListProviderConnectionDiagnosticsResponse, ListProviderConnectionDiagnosticsResponses, ListProviderConnectionsData, ListProviderConnectionsError, ListProviderConnectionsErrors, ListProviderConnectionsResponse, ListProviderConnectionsResponses, ListProviderMappingObservationsData, ListProviderMappingObservationsError, ListProviderMappingObservationsErrors, ListProviderMappingObservationsResponse, ListProviderMappingObservationsResponses, ListProviderMappingsData, ListProviderMappingsError, ListProviderMappingsErrors, ListProviderMappingsResponse, ListProviderMappingsResponses, ListProviderSyncRunsData, ListProviderSyncRunsError, ListProviderSyncRunsErrors, ListProviderSyncRunsResponse, ListProviderSyncRunsResponses, ListReconciliationRunsData, ListReconciliationRunsError, ListReconciliationRunsErrors, ListReconciliationRunsResponse, ListReconciliationRunsResponses, ListReplayJobsData, ListReplayJobsError, ListReplayJobsErrors, ListReplayJobsResponse, ListReplayJobsResponses, ListStoreServerCredentialsData, ListStoreServerCredentialsError, ListStoreServerCredentialsErrors, ListStoreServerCredentialsResponse, ListStoreServerCredentialsResponses, ListTransactionFactsData, ListTransactionFactsError, ListTransactionFactsErrors, ListTransactionFactsResponse, ListTransactionFactsResponses, ListValidationAttemptsData, ListValidationAttemptsError, ListValidationAttemptsErrors, ListValidationAttemptsResponse, ListValidationAttemptsResponses, LoginData, LoginError, LoginErrors, LoginRequest, LoginResponse, LoginResponses, LogoutData, LogoutResponse, LogoutResponses, Membership, MembershipEnvelope, MembershipList, MembershipListEnvelope, MetadataSource, ObservationContext, ObservationCorrelation, ObservationSubmissionResult, ObservationSubmissionResultRecord, Organization, OrganizationEnvelope, OrganizationId, OrganizationList, OrganizationListEnvelope, Page, Paywall, PaywallEnvelope, PaywallId, PaywallListEnvelope, PaywallVersion, PaywallVersionEnvelope, PaywallVersionListEnvelope, Placement, PlacementAlias, PlacementAliasEnvelope, PlacementAliasListEnvelope, PlacementAttribute, PlacementAttributeEnvelope, PlacementAttributeListEnvelope, PlacementBinding, PlacementBindingEnvelope, PlacementDecisionDocument, PlacementDecisionDocumentRequest, PlacementEnvelope, PlacementId, PlacementListEnvelope, PlacementOutcome, PlacementRuleSet, PlacementRuleSetDraft, PlacementRuleSetDraftEnvelope, PlacementRuleSetDraftResource, PlacementRuleSetVersion, PlacementRuleSetVersionEnvelope, PlacementRuleSetVersionListEnvelope, PlacementSimulationEnvelope, PlacementSimulationRequest, PlacementSimulationRequestWritable, PlacementSimulationResult, PlacementUsage, PlacementUsageEnvelope, PlacementValidation, PlacementValidationEnvelope, PlacementValidationIssue, Plan, PlanEnvelope, PlanId, PlanList, PlanListEnvelope, PlanProduct, PlanProductEnvelope, Platform, PreviewAnalyticsPrivacyRequestData, PreviewAnalyticsPrivacyRequestError, PreviewAnalyticsPrivacyRequestErrors, PreviewAnalyticsPrivacyRequestResponse, PreviewAnalyticsPrivacyRequestResponses, PreviewProviderCatalogData, PreviewProviderCatalogError, PreviewProviderCatalogErrors, PreviewProviderCatalogResponse, PreviewProviderCatalogResponses, Product, ProductEntitlementGrant, ProductEntitlementGrantEnvelope, ProductEnvelope, ProductId, ProductList, ProductListEnvelope, ProductReadiness, ProductReadinessEnvelope, ProductReferenceRequest, ProductStatus, ProductType, ProductUsage, ProductUsageEnvelope, Project, ProjectEnvelope, ProjectId, ProjectList, ProjectListEnvelope, ProjectStatus, ProviderActivationKind, ProviderAssignmentEnvelope, ProviderAvailability, ProviderCapability, ProviderCatalogApplication, ProviderCatalogEntitlement, ProviderCatalogOffering, ProviderCatalogPackage, ProviderCatalogPreview, ProviderCatalogProduct, ProviderConnection, ProviderConnectionCapabilities, ProviderConnectionEnvelope, ProviderConnectionHealth, ProviderConnectionId, ProviderConnectionKind, ProviderConnectionList, ProviderConnectionListEnvelope, ProviderConnectionMode, ProviderConnectionStatus, ProviderCredential, ProviderCredentialRequest, ProviderDiagnostic, ProviderEntitlementImportRequest, ProviderErrorCode, ProviderHealthStatus, ProviderImport, ProviderImportItem, ProviderImportRequest, ProviderImportResult, ProviderIntegrationMode, ProviderKind, ProviderMappingEnvelope, ProviderMappingId, ProviderMappingList, ProviderMappingListEnvelope, ProviderMappingObservation, ProviderMappingObservationEnvelope, ProviderMappingObservationMetadata, ProviderMappingStatus, ProviderMappingUsage, ProviderMappingUsageEnvelope, ProviderOrderReference, ProviderProductImportItemRequest, ProviderProductMapping, ProviderProductMetadataSnapshot, ProviderProfile, ProviderProfileEnvelope, ProviderReadiness, ProviderReadinessEnvelope, ProviderReadinessIssue, ProviderSyncJob, ProviderSyncRun, ProviderSyncState, PublishConfigurationData, PublishConfigurationError, PublishConfigurationErrors, PublishConfigurationResponse, PublishConfigurationResponses, PublishExperimentData, PublishExperimentError, PublishExperimentErrors, PublishExperimentRequest, PublishExperimentResponse, PublishExperimentResponses, PublishPlacementRuleSetData, PublishPlacementRuleSetError, PublishPlacementRuleSetErrors, PublishPlacementRuleSetResponse, PublishPlacementRuleSetResponses, PublishRequest, PublishResult, PublishResultEnvelope, QaOverride, QaOverrideCreated, QaOverrideCreatedEnvelope, QaOverrideListEnvelope, QuarantineRecord, QuarantineRecordId, ReceiveAppleStoreNotificationData, ReceiveAppleStoreNotificationError, ReceiveAppleStoreNotificationErrors, ReceiveAppleStoreNotificationResponse, ReceiveAppleStoreNotificationResponses, ReconciliationRun, ReconnectProviderConnectionData, ReconnectProviderConnectionError, ReconnectProviderConnectionErrors, ReconnectProviderConnectionResponse, ReconnectProviderConnectionResponses, ReleaseEnvelope, ReleaseId, ReleaseListEnvelope, RemoveMemberData, RemoveMemberError, RemoveMemberErrors, RemoveMemberResponse, RemoveMemberResponses, RemovePlanProductData, RemovePlanProductError, RemovePlanProductErrors, RemovePlanProductResponse, RemovePlanProductResponses, RemoveProductEntitlementData, RemoveProductEntitlementError, RemoveProductEntitlementErrors, RemoveProductEntitlementResponse, RemoveProductEntitlementResponses, ReplaceProviderConnectionScopesData, ReplaceProviderConnectionScopesError, ReplaceProviderConnectionScopesErrors, ReplaceProviderConnectionScopesRequest, ReplaceProviderConnectionScopesResponse, ReplaceProviderConnectionScopesResponses, ReplaceProviderMappingData, ReplaceProviderMappingError, ReplaceProviderMappingErrors, ReplaceProviderMappingRequest, ReplaceProviderMappingResponse, ReplaceProviderMappingResponses, ReplayJob, RestoreProductData, RestoreProductError, RestoreProductErrors, RestoreProductResponse, RestoreProductResponses, RestoreProjectData, RestoreProjectError, RestoreProjectErrors, RestoreProjectResponse, RestoreProjectResponses, RetryQuarantinedInputData, RetryQuarantinedInputError, RetryQuarantinedInputErrors, RetryQuarantinedInputResponse, RetryQuarantinedInputResponses, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyErrors, RevokeApiKeyResponse, RevokeApiKeyResponses, RevokeExperimentQaOverrideData, RevokeExperimentQaOverrideError, RevokeExperimentQaOverrideErrors, RevokeExperimentQaOverrideResponse, RevokeExperimentQaOverrideResponses, RevokePlacementQaOverrideData, RevokePlacementQaOverrideError, RevokePlacementQaOverrideErrors, RevokePlacementQaOverrideResponse, RevokePlacementQaOverrideResponses, RevokeProviderConnectionData, RevokeProviderConnectionError, RevokeProviderConnectionErrors, RevokeProviderConnectionResponse, RevokeProviderConnectionResponses, RevokeStoreServerCredentialData, RevokeStoreServerCredentialError, RevokeStoreServerCredentialErrors, RevokeStoreServerCredentialResponse, RevokeStoreServerCredentialResponses, Role, RollbackConfigurationReleaseData, RollbackConfigurationReleaseError, RollbackConfigurationReleaseErrors, RollbackConfigurationReleaseResponse, RollbackConfigurationReleaseResponses, RotateApiKeyData, RotateApiKeyError, RotateApiKeyErrors, RotateApiKeyResponse, RotateApiKeyResponses, RotateProviderCredentialData, RotateProviderCredentialError, RotateProviderCredentialErrors, RotateProviderCredentialResponse, RotateProviderCredentialResponses, RotateStoreServerCredentialData, RotateStoreServerCredentialError, RotateStoreServerCredentialErrors, RotateStoreServerCredentialResponse, RotateStoreServerCredentialResponses, RuleSetId, ServerTransactionObservation, ServerTransactionObservationRecord, SetActiveProviderAssignmentData, SetActiveProviderAssignmentError, SetActiveProviderAssignmentErrors, SetActiveProviderAssignmentResponse, SetActiveProviderAssignmentResponses, SetEnvironmentModeData, SetEnvironmentModeError, SetEnvironmentModeErrors, SetEnvironmentModeRequest, SetEnvironmentModeResponse, SetEnvironmentModeResponses, SetProductReplacementData, SetProductReplacementError, SetProductReplacementErrors, SetProductReplacementResponse, SetProductReplacementResponses, SetProviderAssignmentRequest, SignUpData, SignUpError, SignUpErrors, SignUpRequest, SignUpResponse, SignUpResponses, SimulatePlacementDecisionData, SimulatePlacementDecisionError, SimulatePlacementDecisionErrors, SimulatePlacementDecisionResponse, SimulatePlacementDecisionResponses, StoreCredentialId, StoreEnvironmentClassification, StoreServerCredential, StoreServerCredentialApplication, StoreServerCredentialWithEndpoint, SubmitServerTransactionObservationData, SubmitServerTransactionObservationError, SubmitServerTransactionObservationErrors, SubmitServerTransactionObservationResponse, SubmitServerTransactionObservationResponses, SubmitTransactionObservationData, SubmitTransactionObservationError, SubmitTransactionObservationErrors, SubmitTransactionObservationResponse, SubmitTransactionObservationResponses, TestProviderConnectionData, TestProviderConnectionError, TestProviderConnectionErrors, TestProviderConnectionResponse, TestProviderConnectionResponses, TestStoreServerCredentialData, TestStoreServerCredentialError, TestStoreServerCredentialErrors, TestStoreServerCredentialResponse, TestStoreServerCredentialResponses, Timestamp, TransactionFact, TransactionReference, TransitionExperimentLifecycleData, TransitionExperimentLifecycleError, TransitionExperimentLifecycleErrors, TransitionExperimentLifecycleResponse, TransitionExperimentLifecycleResponses, UpdateAnalyticsSettingsData, UpdateAnalyticsSettingsError, UpdateAnalyticsSettingsErrors, UpdateAnalyticsSettingsRequest, UpdateAnalyticsSettingsResponse, UpdateAnalyticsSettingsResponses, UpdateBillingSettingsData, UpdateBillingSettingsError, UpdateBillingSettingsErrors, UpdateBillingSettingsResponse, UpdateBillingSettingsResponses, UpdateDraftRequest, UpdateEntitlementData, UpdateEntitlementError, UpdateEntitlementErrors, UpdateEntitlementResponse, UpdateEntitlementResponses, UpdateEnvironmentData, UpdateEnvironmentError, UpdateEnvironmentErrors, UpdateEnvironmentResponse, UpdateEnvironmentResponses, UpdateExperimentDraftData, UpdateExperimentDraftError, UpdateExperimentDraftErrors, UpdateExperimentDraftRequest, UpdateExperimentDraftResponse, UpdateExperimentDraftResponses, UpdateMemberData, UpdateMemberError, UpdateMemberErrors, UpdateMemberRequest, UpdateMemberResponse, UpdateMemberResponses, UpdateNameRequest, UpdateOrganizationData, UpdateOrganizationError, UpdateOrganizationErrors, UpdateOrganizationRequest, UpdateOrganizationResponse, UpdateOrganizationResponses, UpdatePaywallData, UpdatePaywallDraftData, UpdatePaywallDraftError, UpdatePaywallDraftErrors, UpdatePaywallDraftResponse, UpdatePaywallDraftResponses, UpdatePaywallError, UpdatePaywallErrors, UpdatePaywallRequest, UpdatePaywallResponse, UpdatePaywallResponses, UpdatePlacementData, UpdatePlacementError, UpdatePlacementErrors, UpdatePlacementRequest, UpdatePlacementResponse, UpdatePlacementResponses, UpdatePlacementRuleSetDraftData, UpdatePlacementRuleSetDraftError, UpdatePlacementRuleSetDraftErrors, UpdatePlacementRuleSetDraftResponse, UpdatePlacementRuleSetDraftResponses, UpdatePlanData, UpdatePlanError, UpdatePlanErrors, UpdatePlanResponse, UpdatePlanResponses, UpdateProductData, UpdateProductError, UpdateProductErrors, UpdateProductResponse, UpdateProductResponses, UpdateProjectData, UpdateProjectError, UpdateProjectErrors, UpdateProjectResponse, UpdateProjectResponses, UploadAssetData, UploadAssetError, UploadAssetErrors, UploadAssetResponse, UploadAssetResponses, User, UserEnvelope, ValidateExperimentDraftData, ValidateExperimentDraftResponse, ValidateExperimentDraftResponses, ValidatePaywallDraftData, ValidatePaywallDraftError, ValidatePaywallDraftErrors, ValidatePaywallDraftResponse, ValidatePaywallDraftResponses, ValidatePlacementRuleSetData, ValidatePlacementRuleSetResponse, ValidatePlacementRuleSetResponses, ValidationAttempt, ValidationSummary, ValidationSummaryEnvelope, VersionId } from './types.gen'; +export { addMember, addPlanProduct, addProductEntitlement, archiveAsset, archivePlacementAttribute, archivePlacementRuleSet, archivePlacementWithUsageCheck, archiveProduct, archiveProject, archiveProviderMapping, attachBillingCustomerAlias, bindPlacement, checkCustomerEntitlements, clearActiveProviderAssignment, clonePaywallVersionToDraft, clonePlacementRuleSetVersion, closeQuarantineRecordSuperseded, compareAnalyticsPaywallVersions, createAnalyticsEventExport, createAnalyticsPrivacyDeletion, createAnalyticsPrivacyExport, createApiKey, createApplication, createBillingCustomerSyncRequest, createBillingProjectionReplay, createEntitlement, createExperiment, createExperimentGroupVersion, createExperimentMutualExclusionGroupVersion, createExperimentQaOverride, createExperimentRawExport, createOrganization, createPaywall, createPaywallDraft, createPlacement, createPlacementAlias, createPlacementAttribute, createPlacementQaOverride, createPlacementRuleSet, createPlan, createProduct, createProject, createProviderConnection, createProviderMapping, createProviderMappingDraft, createProviderMappingObservation, createReconciliationRun, createReplayJob, createStoreServerCredential, createWebhookDestination, deleteProduct, deleteWebhookDestination, downloadAnalyticsJob, enqueueProviderSync, getActivePaywallDraft, getActiveProviderAssignment, getAnalyticsBreakdown, getAnalyticsFreshness, getAnalyticsFunnel, getAnalyticsJob, getAnalyticsOverview, getAnalyticsProductAvailabilityFailures, getAnalyticsProviderErrors, getAnalyticsSettings, getAsset, getAssetContent, getAssetUsage, getBillingCustomer, getBillingCustomerEntitlementSnapshot, getBillingHealth, getBillingIdentityConflict, getBillingProjectionHealth, getBillingRestoreJob, getBillingSettings, getBillingSubscription, getCustomerEntitlementSnapshot, getEntitlement, getExperiment, getExperimentResults, getExperimentSampleRatioMismatch, getHealth, getNativeProviderProfile, getOperatorBillingCustomer, getOperatorBillingIdentityConflict, getOrganization, getPaywall, getPaywallDraft, getPaywallVersion, getPlacementBinding, getPlacementDecision, getPlacementUsage, getPlan, getProduct, getProductEntitlementGrantVersion, getProductReadiness, getProductUsage, getProject, getProviderConnection, getProviderConnectionCapabilities, getProviderConnectionHealth, getProviderMappingMetadata, getProviderMappingUsage, getProviderReadiness, getQuarantineRecord, getReadiness, getSdkCommerceConfiguration, getSdkConfiguration, getSdkRestore, getServerRestore, getSession, getStoreServerCredential, getSubscriptionSnapshot, getWebhookDelivery, getWebhookDestination, identifyBillingCustomer, importProviderProducts, ingestAnalyticsEventBatch, issueCustomerAccessToken, listApiKeys, listApplications, listAssets, listAuditEvents, listBillingCustomerAliases, listBillingCustomers, listBillingCustomerSubscriptions, listBillingIdentityConflicts, listBillingLedger, listBillingQuarantine, listBillingRestoreJobs, listBillingSubscriptionTimeline, listConfigurationReleases, listCustomerAccessTokens, listCustomerSubscriptions, listEntitlements, listEnvironments, listExperimentGroups, listExperimentHistory, listExperimentMetricDefinitions, listExperimentMutualExclusionGroupVersions, listExperimentQaOverrides, listExperiments, listExperimentVersions, listMembers, listOperatorBillingIdentityConflicts, listOrganizations, listPaywalls, listPaywallVersions, listPlacementAliases, listPlacementAttributes, listPlacementQaOverrides, listPlacementRuleSetVersions, listPlacements, listPlanProducts, listPlans, listProductEntitlementGrantVersions, listProductEntitlements, listProducts, listProjects, listProviderConnectionDiagnostics, listProviderConnections, listProviderMappingObservations, listProviderMappings, listProviderSyncRuns, listReconciliationRuns, listReplayJobs, listStoreServerCredentials, listSubscriptionTimeline, listTransactionFacts, listValidationAttempts, listWebhookDeliveries, listWebhookDeliveryAttempts, listWebhookDestinations, listWebhookSigningSecrets, login, logout, lookupBillingCustomer, type Options, previewAnalyticsPrivacyRequest, previewProductEntitlementGrantImpact, previewProviderCatalog, publishConfiguration, publishExperiment, publishPlacementRuleSet, publishProductEntitlementGrantVersion, receiveAppleStoreNotification, reconnectProviderConnection, removeMember, removePlanProduct, removeProductEntitlement, replaceProviderConnectionScopes, replaceProviderMapping, replayWebhookDelivery, requestBillingCustomerSync, resolveBillingIdentityConflict, restoreProduct, restoreProject, retireWebhookSigningSecret, retryQuarantinedInput, revokeApiKey, revokeBillingCustomerAlias, revokeCustomerAccessToken, revokeExperimentQaOverride, revokePlacementQaOverride, revokeProviderConnection, revokeStoreServerCredential, rollbackConfigurationRelease, rotateApiKey, rotateProviderCredential, rotateStoreServerCredential, rotateWebhookSigningSecret, setActiveProviderAssignment, setEnvironmentMode, setProductReplacement, setWebhookDestinationStatus, signUp, simulatePlacementDecision, submitSdkRestore, submitServerRestore, submitServerTransactionObservation, submitTransactionObservation, syncCustomerEntitlements, syncCustomerEntitlementsWithNegotiation, testProviderConnection, testStoreServerCredential, transitionExperimentLifecycle, updateAnalyticsSettings, updateBillingSettings, updateEntitlement, updateEnvironment, updateExperimentDraft, updateMember, updateOrganization, updatePaywall, updatePaywallDraft, updatePlacement, updatePlacementRuleSetDraft, updatePlan, updateProduct, updateProductEntitlementGrantVersion, updateProject, updateWebhookDestination, uploadAsset, validateExperimentDraft, validatePaywallDraft, validatePlacementRuleSet } from './sdk.gen'; +export type { ActiveProviderAssignment, ActorId, AddMemberData, AddMemberError, AddMemberErrors, AddMemberRequest, AddMemberResponse, AddMemberResponses, AddPlanProductData, AddPlanProductError, AddPlanProductErrors, AddPlanProductResponse, AddPlanProductResponses, AddProductEntitlementData, AddProductEntitlementError, AddProductEntitlementErrors, AddProductEntitlementResponse, AddProductEntitlementResponses, AnalyticsApplicationVersion, AnalyticsEventBatch, AnalyticsEventResult, AnalyticsFreshness, AnalyticsFrom, AnalyticsIdentityRequest, AnalyticsIngestionResult, AnalyticsJob, AnalyticsJobEnvelope, AnalyticsLocale, AnalyticsMetric, AnalyticsMetricBasis, AnalyticsPlatform, AnalyticsPrivacyPreview, AnalyticsPrivacyPreviewEnvelope, AnalyticsResult, AnalyticsResultEnvelope, AnalyticsSettings, AnalyticsSettingsEnvelope, AnalyticsTimezone, AnalyticsTo, ApiKey, ApiKeyEnvelope, ApiKeyId, ApiKeyKind, ApiKeyList, ApiKeyListEnvelope, ApiKeySecretEnvelope, ApiKeySecretResult, Application, ApplicationEnvelope, ApplicationId, ApplicationList, ApplicationListEnvelope, ArchiveAssetData, ArchiveAssetError, ArchiveAssetErrors, ArchiveAssetResponse, ArchiveAssetResponses, ArchivePlacementAttributeData, ArchivePlacementAttributeError, ArchivePlacementAttributeErrors, ArchivePlacementAttributeResponse, ArchivePlacementAttributeResponses, ArchivePlacementRuleSetData, ArchivePlacementRuleSetError, ArchivePlacementRuleSetErrors, ArchivePlacementRuleSetResponse, ArchivePlacementRuleSetResponses, ArchivePlacementWithUsageCheckData, ArchivePlacementWithUsageCheckError, ArchivePlacementWithUsageCheckErrors, ArchivePlacementWithUsageCheckResponse, ArchivePlacementWithUsageCheckResponses, ArchiveProductData, ArchiveProductError, ArchiveProductErrors, ArchiveProductResponse, ArchiveProductResponses, ArchiveProjectData, ArchiveProjectError, ArchiveProjectErrors, ArchiveProjectResponse, ArchiveProjectResponses, ArchiveProviderMappingData, ArchiveProviderMappingError, ArchiveProviderMappingErrors, ArchiveProviderMappingResponse, ArchiveProviderMappingResponses, Asset, AssetEnvelope, AssetId, AssetListEnvelope, AssetUsage, AssetUsageEnvelope, AttachBillingCustomerAliasData, AttachBillingCustomerAliasError, AttachBillingCustomerAliasErrors, AttachBillingCustomerAliasRequest, AttachBillingCustomerAliasResponse, AttachBillingCustomerAliasResponses, AuditEvent, AuditEventList, AuditEventListEnvelope, BillingCursor, BillingCustomer, BillingCustomerAlias, BillingCustomerDetail, BillingCustomerId, BillingCustomerLookupRequest, BillingCustomerLookupResult, BillingCustomerSummary, BillingEntitlementSnapshot, BillingEntitlementSnapshotEntry, BillingEntitlementSource, BillingFrom, BillingHealth, BillingIdentityConflict, BillingIdentityConflictDetail, BillingIdentityCustomer, BillingLedgerEntry, BillingOneTimePurchase, BillingProjectionHealth, BillingProjectionStatus, BillingProviderFilter, BillingPurchaseLineage, BillingRestoreJob, BillingSettings, BillingSubscriptionSnapshot, BillingSyncRequest, BillingTimelineEntry, BillingTo, BindPlacementData, BindPlacementError, BindPlacementErrors, BindPlacementRequest, BindPlacementResponse, BindPlacementResponses, CheckCustomerEntitlementsData, CheckCustomerEntitlementsError, CheckCustomerEntitlementsErrors, CheckCustomerEntitlementsResponse, CheckCustomerEntitlementsResponses, ClearActiveProviderAssignmentData, ClearActiveProviderAssignmentError, ClearActiveProviderAssignmentErrors, ClearActiveProviderAssignmentResponse, ClearActiveProviderAssignmentResponses, ClientOptions, ClientTransactionObservation, ClientTransactionObservationRecord, ClonePaywallVersionToDraftData, ClonePaywallVersionToDraftError, ClonePaywallVersionToDraftErrors, ClonePaywallVersionToDraftResponse, ClonePaywallVersionToDraftResponses, ClonePlacementRuleSetVersionData, ClonePlacementRuleSetVersionError, ClonePlacementRuleSetVersionErrors, ClonePlacementRuleSetVersionResponse, ClonePlacementRuleSetVersionResponses, CloseQuarantineRecordSupersededData, CloseQuarantineRecordSupersededError, CloseQuarantineRecordSupersededErrors, CloseQuarantineRecordSupersededResponse, CloseQuarantineRecordSupersededResponses, CompareAnalyticsPaywallVersionsData, CompareAnalyticsPaywallVersionsError, CompareAnalyticsPaywallVersionsErrors, CompareAnalyticsPaywallVersionsResponse, CompareAnalyticsPaywallVersionsResponses, ConfigurationRelease, CreateAnalyticsEventExportData, CreateAnalyticsEventExportError, CreateAnalyticsEventExportErrors, CreateAnalyticsEventExportRequest, CreateAnalyticsEventExportResponse, CreateAnalyticsEventExportResponses, CreateAnalyticsPrivacyDeletionData, CreateAnalyticsPrivacyDeletionError, CreateAnalyticsPrivacyDeletionErrors, CreateAnalyticsPrivacyDeletionRequest, CreateAnalyticsPrivacyDeletionResponse, CreateAnalyticsPrivacyDeletionResponses, CreateAnalyticsPrivacyExportData, CreateAnalyticsPrivacyExportError, CreateAnalyticsPrivacyExportErrors, CreateAnalyticsPrivacyExportRequest, CreateAnalyticsPrivacyExportResponse, CreateAnalyticsPrivacyExportResponses, CreateApiKeyData, CreateApiKeyError, CreateApiKeyErrors, CreateApiKeyRequest, CreateApiKeyResponse, CreateApiKeyResponses, CreateApplicationData, CreateApplicationError, CreateApplicationErrors, CreateApplicationRequest, CreateApplicationResponse, CreateApplicationResponses, CreateBillingCustomerSyncRequestData, CreateBillingCustomerSyncRequestError, CreateBillingCustomerSyncRequestErrors, CreateBillingCustomerSyncRequestResponse, CreateBillingCustomerSyncRequestResponses, CreateBillingProjectionReplayData, CreateBillingProjectionReplayError, CreateBillingProjectionReplayErrors, CreateBillingProjectionReplayResponse, CreateBillingProjectionReplayResponses, CreateCatalogResourceRequest, CreateDraftRequest, CreateEntitlementData, CreateEntitlementError, CreateEntitlementErrors, CreateEntitlementResponse, CreateEntitlementResponses, CreateExperimentData, CreateExperimentError, CreateExperimentErrors, CreateExperimentExportRequest, CreateExperimentGroupRequest, CreateExperimentGroupVersionData, CreateExperimentGroupVersionError, CreateExperimentGroupVersionErrors, CreateExperimentGroupVersionRequest, CreateExperimentGroupVersionResponse, CreateExperimentGroupVersionResponses, CreateExperimentMutualExclusionGroupVersionData, CreateExperimentMutualExclusionGroupVersionError, CreateExperimentMutualExclusionGroupVersionErrors, CreateExperimentMutualExclusionGroupVersionResponse, CreateExperimentMutualExclusionGroupVersionResponses, CreateExperimentQaOverrideData, CreateExperimentQaOverrideError, CreateExperimentQaOverrideErrors, CreateExperimentQaOverrideRequest, CreateExperimentQaOverrideResponse, CreateExperimentQaOverrideResponses, CreateExperimentRawExportData, CreateExperimentRawExportError, CreateExperimentRawExportErrors, CreateExperimentRawExportResponse, CreateExperimentRawExportResponses, CreateExperimentRequest, CreateExperimentResponse, CreateExperimentResponses, CreateOrganizationData, CreateOrganizationError, CreateOrganizationErrors, CreateOrganizationRequest, CreateOrganizationResponse, CreateOrganizationResponses, CreatePaywallData, CreatePaywallDraftData, CreatePaywallDraftError, CreatePaywallDraftErrors, CreatePaywallDraftResponse, CreatePaywallDraftResponses, CreatePaywallError, CreatePaywallErrors, CreatePaywallRequest, CreatePaywallResponse, CreatePaywallResponses, CreatePlacementAliasData, CreatePlacementAliasError, CreatePlacementAliasErrors, CreatePlacementAliasResponse, CreatePlacementAliasResponses, CreatePlacementAttributeData, CreatePlacementAttributeError, CreatePlacementAttributeErrors, CreatePlacementAttributeRequest, CreatePlacementAttributeResponse, CreatePlacementAttributeResponses, CreatePlacementData, CreatePlacementError, CreatePlacementErrors, CreatePlacementQaOverrideData, CreatePlacementQaOverrideError, CreatePlacementQaOverrideErrors, CreatePlacementQaOverrideResponse, CreatePlacementQaOverrideResponses, CreatePlacementRequest, CreatePlacementResponse, CreatePlacementResponses, CreatePlacementRuleSetData, CreatePlacementRuleSetError, CreatePlacementRuleSetErrors, CreatePlacementRuleSetResponse, CreatePlacementRuleSetResponses, CreatePlanData, CreatePlanError, CreatePlanErrors, CreatePlanResponse, CreatePlanResponses, CreateProductData, CreateProductError, CreateProductErrors, CreateProductRequest, CreateProductResponse, CreateProductResponses, CreateProjectData, CreateProjectError, CreateProjectErrors, CreateProjectionReplayRequest, CreateProjectRequest, CreateProjectResponse, CreateProjectResponses, CreateProviderConnectionData, CreateProviderConnectionError, CreateProviderConnectionErrors, CreateProviderConnectionRequest, CreateProviderConnectionRequestWritable, CreateProviderConnectionResponse, CreateProviderConnectionResponses, CreateProviderMappingData, CreateProviderMappingDraftData, CreateProviderMappingDraftError, CreateProviderMappingDraftErrors, CreateProviderMappingDraftRequest, CreateProviderMappingDraftResponse, CreateProviderMappingDraftResponses, CreateProviderMappingError, CreateProviderMappingErrors, CreateProviderMappingObservationData, CreateProviderMappingObservationError, CreateProviderMappingObservationErrors, CreateProviderMappingObservationRequest, CreateProviderMappingObservationResponse, CreateProviderMappingObservationResponses, CreateProviderMappingRequest, CreateProviderMappingResponse, CreateProviderMappingResponses, CreateQaOverrideRequest, CreateQaOverrideRequestWritable, CreateReconciliationRunData, CreateReconciliationRunError, CreateReconciliationRunErrors, CreateReconciliationRunRequest, CreateReconciliationRunResponse, CreateReconciliationRunResponses, CreateReplayJobData, CreateReplayJobError, CreateReplayJobErrors, CreateReplayJobRequest, CreateReplayJobResponse, CreateReplayJobResponses, CreateStoreServerCredentialData, CreateStoreServerCredentialError, CreateStoreServerCredentialErrors, CreateStoreServerCredentialRequest, CreateStoreServerCredentialResponse, CreateStoreServerCredentialResponses, CreateWebhookDestinationData, CreateWebhookDestinationError, CreateWebhookDestinationErrors, CreateWebhookDestinationRequest, CreateWebhookDestinationResponse, CreateWebhookDestinationResponses, Cursor, CustomerAccessTokenIssuanceRequest, CustomerAccessTokenIssuanceResult, CustomerAccessTokenMetadata, CustomerEntitlementSnapshotRecord, DeleteProductData, DeleteProductError, DeleteProductErrors, DeleteProductResponse, DeleteProductResponses, DeleteWebhookDestinationData, DeleteWebhookDestinationError, DeleteWebhookDestinationErrors, DeleteWebhookDestinationResponse, DeleteWebhookDestinationResponses, DownloadAnalyticsJobData, DownloadAnalyticsJobError, DownloadAnalyticsJobErrors, DownloadAnalyticsJobResponse, DownloadAnalyticsJobResponses, Draft, DraftEnvelope, DraftId, DraftResource, EnqueueProviderSyncData, EnqueueProviderSyncError, EnqueueProviderSyncErrors, EnqueueProviderSyncResponse, EnqueueProviderSyncResponses, Entitlement, EntitlementCheckRequestRecord, EntitlementCheckResultRecord, EntitlementEntry, EntitlementEnvelope, EntitlementId, EntitlementList, EntitlementListEnvelope, EntitlementReferenceRequest, EntitlementSourceSummary, EntitlementSyncRequestRecord, Environment, EnvironmentEnvelope, EnvironmentId, EnvironmentList, EnvironmentListEnvelope, EnvironmentMode, ErrorEnvelope, Experiment, ExperimentDraft, ExperimentDraftDocument, ExperimentDraftEnvelope, ExperimentEnvelope, ExperimentGroup, ExperimentGroupCreated, ExperimentGroupCreatedEnvelope, ExperimentGroupListEnvelope, ExperimentGroupMemberInput, ExperimentGroupVersion, ExperimentGroupVersionEnvelope, ExperimentGroupVersionListEnvelope, ExperimentGuardrailMaturity, ExperimentGuardrailResult, ExperimentGuardrailVariantResult, ExperimentHistory, ExperimentHistoryListEnvelope, ExperimentId, ExperimentInterval, ExperimentLift, ExperimentListEnvelope, ExperimentMetricDefinition, ExperimentMetricListEnvelope, ExperimentQaOverride, ExperimentQaOverrideCreated, ExperimentQaOverrideCreatedEnvelope, ExperimentQaOverrideCreatedEnvelopeWritable, ExperimentQaOverrideCreatedWritable, ExperimentQaOverrideListEnvelope, ExperimentResults, ExperimentResultsEnvelope, ExperimentSchedule, ExperimentSrm, ExperimentValidation, ExperimentValidationEnvelope, ExperimentValidationIssue, ExperimentVariantDraft, ExperimentVariantResult, ExperimentVariantVersion, ExperimentVersion, ExperimentVersionEnvelope, ExperimentVersionListEnvelope, GetActivePaywallDraftData, GetActivePaywallDraftError, GetActivePaywallDraftErrors, GetActivePaywallDraftResponse, GetActivePaywallDraftResponses, GetActiveProviderAssignmentData, GetActiveProviderAssignmentError, GetActiveProviderAssignmentErrors, GetActiveProviderAssignmentResponse, GetActiveProviderAssignmentResponses, GetAnalyticsBreakdownData, GetAnalyticsBreakdownError, GetAnalyticsBreakdownErrors, GetAnalyticsBreakdownResponse, GetAnalyticsBreakdownResponses, GetAnalyticsFreshnessData, GetAnalyticsFreshnessError, GetAnalyticsFreshnessErrors, GetAnalyticsFreshnessResponse, GetAnalyticsFreshnessResponses, GetAnalyticsFunnelData, GetAnalyticsFunnelError, GetAnalyticsFunnelErrors, GetAnalyticsFunnelResponse, GetAnalyticsFunnelResponses, GetAnalyticsJobData, GetAnalyticsJobError, GetAnalyticsJobErrors, GetAnalyticsJobResponse, GetAnalyticsJobResponses, GetAnalyticsOverviewData, GetAnalyticsOverviewError, GetAnalyticsOverviewErrors, GetAnalyticsOverviewResponse, GetAnalyticsOverviewResponses, GetAnalyticsProductAvailabilityFailuresData, GetAnalyticsProductAvailabilityFailuresError, GetAnalyticsProductAvailabilityFailuresErrors, GetAnalyticsProductAvailabilityFailuresResponse, GetAnalyticsProductAvailabilityFailuresResponses, GetAnalyticsProviderErrorsData, GetAnalyticsProviderErrorsError, GetAnalyticsProviderErrorsErrors, GetAnalyticsProviderErrorsResponse, GetAnalyticsProviderErrorsResponses, GetAnalyticsSettingsData, GetAnalyticsSettingsError, GetAnalyticsSettingsErrors, GetAnalyticsSettingsResponse, GetAnalyticsSettingsResponses, GetAssetContentData, GetAssetContentError, GetAssetContentErrors, GetAssetContentResponse, GetAssetContentResponses, GetAssetData, GetAssetError, GetAssetErrors, GetAssetResponse, GetAssetResponses, GetAssetUsageData, GetAssetUsageError, GetAssetUsageErrors, GetAssetUsageResponse, GetAssetUsageResponses, GetBillingCustomerData, GetBillingCustomerEntitlementSnapshotData, GetBillingCustomerEntitlementSnapshotError, GetBillingCustomerEntitlementSnapshotErrors, GetBillingCustomerEntitlementSnapshotResponse, GetBillingCustomerEntitlementSnapshotResponses, GetBillingCustomerError, GetBillingCustomerErrors, GetBillingCustomerResponse, GetBillingCustomerResponses, GetBillingHealthData, GetBillingHealthError, GetBillingHealthErrors, GetBillingHealthResponse, GetBillingHealthResponses, GetBillingIdentityConflictData, GetBillingIdentityConflictError, GetBillingIdentityConflictErrors, GetBillingIdentityConflictResponse, GetBillingIdentityConflictResponses, GetBillingProjectionHealthData, GetBillingProjectionHealthError, GetBillingProjectionHealthErrors, GetBillingProjectionHealthResponse, GetBillingProjectionHealthResponses, GetBillingRestoreJobData, GetBillingRestoreJobError, GetBillingRestoreJobErrors, GetBillingRestoreJobResponse, GetBillingRestoreJobResponses, GetBillingSettingsData, GetBillingSettingsError, GetBillingSettingsErrors, GetBillingSettingsResponse, GetBillingSettingsResponses, GetBillingSubscriptionData, GetBillingSubscriptionError, GetBillingSubscriptionErrors, GetBillingSubscriptionResponse, GetBillingSubscriptionResponses, GetCustomerEntitlementSnapshotData, GetCustomerEntitlementSnapshotError, GetCustomerEntitlementSnapshotErrors, GetCustomerEntitlementSnapshotResponse, GetCustomerEntitlementSnapshotResponses, GetEntitlementData, GetEntitlementError, GetEntitlementErrors, GetEntitlementResponse, GetEntitlementResponses, GetExperimentData, GetExperimentError, GetExperimentErrors, GetExperimentResponse, GetExperimentResponses, GetExperimentResultsData, GetExperimentResultsResponse, GetExperimentResultsResponses, GetExperimentSampleRatioMismatchData, GetExperimentSampleRatioMismatchResponse, GetExperimentSampleRatioMismatchResponses, GetHealthData, GetHealthResponse, GetHealthResponses, GetNativeProviderProfileData, GetNativeProviderProfileError, GetNativeProviderProfileErrors, GetNativeProviderProfileResponse, GetNativeProviderProfileResponses, GetOperatorBillingCustomerData, GetOperatorBillingCustomerError, GetOperatorBillingCustomerErrors, GetOperatorBillingCustomerResponse, GetOperatorBillingCustomerResponses, GetOperatorBillingIdentityConflictData, GetOperatorBillingIdentityConflictError, GetOperatorBillingIdentityConflictErrors, GetOperatorBillingIdentityConflictResponse, GetOperatorBillingIdentityConflictResponses, GetOrganizationData, GetOrganizationError, GetOrganizationErrors, GetOrganizationResponse, GetOrganizationResponses, GetPaywallData, GetPaywallDraftData, GetPaywallDraftError, GetPaywallDraftErrors, GetPaywallDraftResponse, GetPaywallDraftResponses, GetPaywallError, GetPaywallErrors, GetPaywallResponse, GetPaywallResponses, GetPaywallVersionData, GetPaywallVersionError, GetPaywallVersionErrors, GetPaywallVersionResponse, GetPaywallVersionResponses, GetPlacementBindingData, GetPlacementBindingError, GetPlacementBindingErrors, GetPlacementBindingResponse, GetPlacementBindingResponses, GetPlacementDecisionData, GetPlacementDecisionError, GetPlacementDecisionErrors, GetPlacementDecisionResponse, GetPlacementDecisionResponses, GetPlacementUsageData, GetPlacementUsageError, GetPlacementUsageErrors, GetPlacementUsageResponse, GetPlacementUsageResponses, GetPlanData, GetPlanError, GetPlanErrors, GetPlanResponse, GetPlanResponses, GetProductData, GetProductEntitlementGrantVersionData, GetProductEntitlementGrantVersionError, GetProductEntitlementGrantVersionErrors, GetProductEntitlementGrantVersionResponse, GetProductEntitlementGrantVersionResponses, GetProductError, GetProductErrors, GetProductReadinessData, GetProductReadinessError, GetProductReadinessErrors, GetProductReadinessResponse, GetProductReadinessResponses, GetProductResponse, GetProductResponses, GetProductUsageData, GetProductUsageError, GetProductUsageErrors, GetProductUsageResponse, GetProductUsageResponses, GetProjectData, GetProjectError, GetProjectErrors, GetProjectResponse, GetProjectResponses, GetProviderConnectionCapabilitiesData, GetProviderConnectionCapabilitiesError, GetProviderConnectionCapabilitiesErrors, GetProviderConnectionCapabilitiesResponse, GetProviderConnectionCapabilitiesResponses, GetProviderConnectionData, GetProviderConnectionError, GetProviderConnectionErrors, GetProviderConnectionHealthData, GetProviderConnectionHealthError, GetProviderConnectionHealthErrors, GetProviderConnectionHealthResponse, GetProviderConnectionHealthResponses, GetProviderConnectionResponse, GetProviderConnectionResponses, GetProviderMappingMetadataData, GetProviderMappingMetadataError, GetProviderMappingMetadataErrors, GetProviderMappingMetadataResponse, GetProviderMappingMetadataResponses, GetProviderMappingUsageData, GetProviderMappingUsageError, GetProviderMappingUsageErrors, GetProviderMappingUsageResponse, GetProviderMappingUsageResponses, GetProviderReadinessData, GetProviderReadinessError, GetProviderReadinessErrors, GetProviderReadinessResponse, GetProviderReadinessResponses, GetQuarantineRecordData, GetQuarantineRecordError, GetQuarantineRecordErrors, GetQuarantineRecordResponse, GetQuarantineRecordResponses, GetReadinessData, GetReadinessError, GetReadinessErrors, GetReadinessResponse, GetReadinessResponses, GetSdkCommerceConfigurationData, GetSdkCommerceConfigurationError, GetSdkCommerceConfigurationErrors, GetSdkCommerceConfigurationResponse, GetSdkCommerceConfigurationResponses, GetSdkConfigurationData, GetSdkConfigurationError, GetSdkConfigurationErrors, GetSdkConfigurationResponse, GetSdkConfigurationResponses, GetSdkRestoreData, GetSdkRestoreError, GetSdkRestoreErrors, GetSdkRestoreResponse, GetSdkRestoreResponses, GetServerRestoreData, GetServerRestoreError, GetServerRestoreErrors, GetServerRestoreResponse, GetServerRestoreResponses, GetSessionData, GetSessionError, GetSessionErrors, GetSessionResponse, GetSessionResponses, GetStoreServerCredentialData, GetStoreServerCredentialError, GetStoreServerCredentialErrors, GetStoreServerCredentialResponse, GetStoreServerCredentialResponses, GetSubscriptionSnapshotData, GetSubscriptionSnapshotError, GetSubscriptionSnapshotErrors, GetSubscriptionSnapshotResponse, GetSubscriptionSnapshotResponses, GetWebhookDeliveryData, GetWebhookDeliveryError, GetWebhookDeliveryErrors, GetWebhookDeliveryResponse, GetWebhookDeliveryResponses, GetWebhookDestinationData, GetWebhookDestinationError, GetWebhookDestinationErrors, GetWebhookDestinationResponse, GetWebhookDestinationResponses, GrantAccessPolicy, GrantVersionImpact, HealthEnvelope, IdempotencyKey, IdentifyBillingCustomerData, IdentifyBillingCustomerError, IdentifyBillingCustomerErrors, IdentifyBillingCustomerRequest, IdentifyBillingCustomerResponse, IdentifyBillingCustomerResponses, IdentityConflictId, IfMatch, ImportProviderProductsData, ImportProviderProductsError, ImportProviderProductsErrors, ImportProviderProductsResponse, ImportProviderProductsResponses, IngestAnalyticsEventBatchData, IngestAnalyticsEventBatchError, IngestAnalyticsEventBatchErrors, IngestAnalyticsEventBatchResponse, IngestAnalyticsEventBatchResponses, IssueCustomerAccessTokenData, IssueCustomerAccessTokenError, IssueCustomerAccessTokenErrors, IssueCustomerAccessTokenResponse, IssueCustomerAccessTokenResponses, Limit, ListApiKeysData, ListApiKeysError, ListApiKeysErrors, ListApiKeysResponse, ListApiKeysResponses, ListApplicationsData, ListApplicationsError, ListApplicationsErrors, ListApplicationsResponse, ListApplicationsResponses, ListAssetsData, ListAssetsError, ListAssetsErrors, ListAssetsResponse, ListAssetsResponses, ListAuditEventsData, ListAuditEventsError, ListAuditEventsErrors, ListAuditEventsResponse, ListAuditEventsResponses, ListBillingCustomerAliasesData, ListBillingCustomerAliasesError, ListBillingCustomerAliasesErrors, ListBillingCustomerAliasesResponse, ListBillingCustomerAliasesResponses, ListBillingCustomersData, ListBillingCustomersError, ListBillingCustomersErrors, ListBillingCustomersResponse, ListBillingCustomersResponses, ListBillingCustomerSubscriptionsData, ListBillingCustomerSubscriptionsError, ListBillingCustomerSubscriptionsErrors, ListBillingCustomerSubscriptionsResponse, ListBillingCustomerSubscriptionsResponses, ListBillingIdentityConflictsData, ListBillingIdentityConflictsError, ListBillingIdentityConflictsErrors, ListBillingIdentityConflictsResponse, ListBillingIdentityConflictsResponses, ListBillingLedgerData, ListBillingLedgerError, ListBillingLedgerErrors, ListBillingLedgerResponse, ListBillingLedgerResponses, ListBillingQuarantineData, ListBillingQuarantineError, ListBillingQuarantineErrors, ListBillingQuarantineResponse, ListBillingQuarantineResponses, ListBillingRestoreJobsData, ListBillingRestoreJobsError, ListBillingRestoreJobsErrors, ListBillingRestoreJobsResponse, ListBillingRestoreJobsResponses, ListBillingSubscriptionTimelineData, ListBillingSubscriptionTimelineError, ListBillingSubscriptionTimelineErrors, ListBillingSubscriptionTimelineResponse, ListBillingSubscriptionTimelineResponses, ListConfigurationReleasesData, ListConfigurationReleasesError, ListConfigurationReleasesErrors, ListConfigurationReleasesResponse, ListConfigurationReleasesResponses, ListCustomerAccessTokensData, ListCustomerAccessTokensError, ListCustomerAccessTokensErrors, ListCustomerAccessTokensResponse, ListCustomerAccessTokensResponses, ListCustomerSubscriptionsData, ListCustomerSubscriptionsError, ListCustomerSubscriptionsErrors, ListCustomerSubscriptionsResponse, ListCustomerSubscriptionsResponses, ListEntitlementsData, ListEntitlementsError, ListEntitlementsErrors, ListEntitlementsResponse, ListEntitlementsResponses, ListEnvironmentsData, ListEnvironmentsError, ListEnvironmentsErrors, ListEnvironmentsResponse, ListEnvironmentsResponses, ListExperimentGroupsData, ListExperimentGroupsResponse, ListExperimentGroupsResponses, ListExperimentHistoryData, ListExperimentHistoryResponse, ListExperimentHistoryResponses, ListExperimentMetricDefinitionsData, ListExperimentMetricDefinitionsResponse, ListExperimentMetricDefinitionsResponses, ListExperimentMutualExclusionGroupVersionsData, ListExperimentMutualExclusionGroupVersionsError, ListExperimentMutualExclusionGroupVersionsErrors, ListExperimentMutualExclusionGroupVersionsResponse, ListExperimentMutualExclusionGroupVersionsResponses, ListExperimentQaOverridesData, ListExperimentQaOverridesResponse, ListExperimentQaOverridesResponses, ListExperimentsData, ListExperimentsResponse, ListExperimentsResponses, ListExperimentVersionsData, ListExperimentVersionsResponse, ListExperimentVersionsResponses, ListMembersData, ListMembersError, ListMembersErrors, ListMembersResponse, ListMembersResponses, ListOperatorBillingIdentityConflictsData, ListOperatorBillingIdentityConflictsError, ListOperatorBillingIdentityConflictsErrors, ListOperatorBillingIdentityConflictsResponse, ListOperatorBillingIdentityConflictsResponses, ListOrganizationsData, ListOrganizationsError, ListOrganizationsErrors, ListOrganizationsResponse, ListOrganizationsResponses, ListPaywallsData, ListPaywallsError, ListPaywallsErrors, ListPaywallsResponse, ListPaywallsResponses, ListPaywallVersionsData, ListPaywallVersionsError, ListPaywallVersionsErrors, ListPaywallVersionsResponse, ListPaywallVersionsResponses, ListPlacementAliasesData, ListPlacementAliasesResponse, ListPlacementAliasesResponses, ListPlacementAttributesData, ListPlacementAttributesError, ListPlacementAttributesErrors, ListPlacementAttributesResponse, ListPlacementAttributesResponses, ListPlacementQaOverridesData, ListPlacementQaOverridesResponse, ListPlacementQaOverridesResponses, ListPlacementRuleSetVersionsData, ListPlacementRuleSetVersionsResponse, ListPlacementRuleSetVersionsResponses, ListPlacementsData, ListPlacementsError, ListPlacementsErrors, ListPlacementsResponse, ListPlacementsResponses, ListPlanProductsData, ListPlanProductsError, ListPlanProductsErrors, ListPlanProductsResponse, ListPlanProductsResponses, ListPlansData, ListPlansError, ListPlansErrors, ListPlansResponse, ListPlansResponses, ListProductEntitlementGrantVersionsData, ListProductEntitlementGrantVersionsError, ListProductEntitlementGrantVersionsErrors, ListProductEntitlementGrantVersionsResponse, ListProductEntitlementGrantVersionsResponses, ListProductEntitlementsData, ListProductEntitlementsError, ListProductEntitlementsErrors, ListProductEntitlementsResponse, ListProductEntitlementsResponses, ListProductsData, ListProductsError, ListProductsErrors, ListProductsResponse, ListProductsResponses, ListProjectsData, ListProjectsError, ListProjectsErrors, ListProjectsResponse, ListProjectsResponses, ListProviderConnectionDiagnosticsData, ListProviderConnectionDiagnosticsError, ListProviderConnectionDiagnosticsErrors, ListProviderConnectionDiagnosticsResponse, ListProviderConnectionDiagnosticsResponses, ListProviderConnectionsData, ListProviderConnectionsError, ListProviderConnectionsErrors, ListProviderConnectionsResponse, ListProviderConnectionsResponses, ListProviderMappingObservationsData, ListProviderMappingObservationsError, ListProviderMappingObservationsErrors, ListProviderMappingObservationsResponse, ListProviderMappingObservationsResponses, ListProviderMappingsData, ListProviderMappingsError, ListProviderMappingsErrors, ListProviderMappingsResponse, ListProviderMappingsResponses, ListProviderSyncRunsData, ListProviderSyncRunsError, ListProviderSyncRunsErrors, ListProviderSyncRunsResponse, ListProviderSyncRunsResponses, ListReconciliationRunsData, ListReconciliationRunsError, ListReconciliationRunsErrors, ListReconciliationRunsResponse, ListReconciliationRunsResponses, ListReplayJobsData, ListReplayJobsError, ListReplayJobsErrors, ListReplayJobsResponse, ListReplayJobsResponses, ListStoreServerCredentialsData, ListStoreServerCredentialsError, ListStoreServerCredentialsErrors, ListStoreServerCredentialsResponse, ListStoreServerCredentialsResponses, ListSubscriptionTimelineData, ListSubscriptionTimelineError, ListSubscriptionTimelineErrors, ListSubscriptionTimelineResponse, ListSubscriptionTimelineResponses, ListTransactionFactsData, ListTransactionFactsError, ListTransactionFactsErrors, ListTransactionFactsResponse, ListTransactionFactsResponses, ListValidationAttemptsData, ListValidationAttemptsError, ListValidationAttemptsErrors, ListValidationAttemptsResponse, ListValidationAttemptsResponses, ListWebhookDeliveriesData, ListWebhookDeliveriesError, ListWebhookDeliveriesErrors, ListWebhookDeliveriesResponse, ListWebhookDeliveriesResponses, ListWebhookDeliveryAttemptsData, ListWebhookDeliveryAttemptsError, ListWebhookDeliveryAttemptsErrors, ListWebhookDeliveryAttemptsResponse, ListWebhookDeliveryAttemptsResponses, ListWebhookDestinationsData, ListWebhookDestinationsError, ListWebhookDestinationsErrors, ListWebhookDestinationsResponse, ListWebhookDestinationsResponses, ListWebhookSigningSecretsData, ListWebhookSigningSecretsError, ListWebhookSigningSecretsErrors, ListWebhookSigningSecretsResponse, ListWebhookSigningSecretsResponses, LoginData, LoginError, LoginErrors, LoginRequest, LoginResponse, LoginResponses, LogoutData, LogoutResponse, LogoutResponses, LookupBillingCustomerData, LookupBillingCustomerError, LookupBillingCustomerErrors, LookupBillingCustomerResponse, LookupBillingCustomerResponses, Membership, MembershipEnvelope, MembershipList, MembershipListEnvelope, MetadataSource, ObservationContext, ObservationCorrelation, ObservationSubmissionResult, ObservationSubmissionResultRecord, OperatorBillingCustomerAlias, OperatorBillingIdentityConflict, OperatorBillingIdentityConflictDetail, OperatorBillingSyncRequest, Organization, OrganizationEnvelope, OrganizationId, OrganizationList, OrganizationListEnvelope, Page, Paywall, PaywallEnvelope, PaywallId, PaywallListEnvelope, PaywallVersion, PaywallVersionEnvelope, PaywallVersionListEnvelope, Placement, PlacementAlias, PlacementAliasEnvelope, PlacementAliasListEnvelope, PlacementAttribute, PlacementAttributeEnvelope, PlacementAttributeListEnvelope, PlacementBinding, PlacementBindingEnvelope, PlacementDecisionDocument, PlacementDecisionDocumentRequest, PlacementEnvelope, PlacementId, PlacementListEnvelope, PlacementOutcome, PlacementRuleSet, PlacementRuleSetDraft, PlacementRuleSetDraftEnvelope, PlacementRuleSetDraftResource, PlacementRuleSetVersion, PlacementRuleSetVersionEnvelope, PlacementRuleSetVersionListEnvelope, PlacementSimulationEnvelope, PlacementSimulationRequest, PlacementSimulationRequestWritable, PlacementSimulationResult, PlacementUsage, PlacementUsageEnvelope, PlacementValidation, PlacementValidationEnvelope, PlacementValidationIssue, Plan, PlanEnvelope, PlanId, PlanList, PlanListEnvelope, PlanProduct, PlanProductEnvelope, Platform, PreviewAnalyticsPrivacyRequestData, PreviewAnalyticsPrivacyRequestError, PreviewAnalyticsPrivacyRequestErrors, PreviewAnalyticsPrivacyRequestResponse, PreviewAnalyticsPrivacyRequestResponses, PreviewProductEntitlementGrantImpactData, PreviewProductEntitlementGrantImpactError, PreviewProductEntitlementGrantImpactErrors, PreviewProductEntitlementGrantImpactResponse, PreviewProductEntitlementGrantImpactResponses, PreviewProviderCatalogData, PreviewProviderCatalogError, PreviewProviderCatalogErrors, PreviewProviderCatalogResponse, PreviewProviderCatalogResponses, PrimaryExplanation, Product, ProductEntitlementGrant, ProductEntitlementGrantEnvelope, ProductEntitlementGrantVersion, ProductEnvelope, ProductId, ProductList, ProductListEnvelope, ProductReadiness, ProductReadinessEnvelope, ProductReferenceRequest, ProductStatus, ProductType, ProductUsage, ProductUsageEnvelope, Project, ProjectEnvelope, ProjectId, ProjectionReplayResult, ProjectionStatus, ProjectList, ProjectListEnvelope, ProjectStatus, ProviderActivationKind, ProviderAssignmentEnvelope, ProviderAvailability, ProviderCapability, ProviderCatalogApplication, ProviderCatalogEntitlement, ProviderCatalogOffering, ProviderCatalogPackage, ProviderCatalogPreview, ProviderCatalogProduct, ProviderConnection, ProviderConnectionCapabilities, ProviderConnectionEnvelope, ProviderConnectionHealth, ProviderConnectionId, ProviderConnectionKind, ProviderConnectionList, ProviderConnectionListEnvelope, ProviderConnectionMode, ProviderConnectionStatus, ProviderCredential, ProviderCredentialRequest, ProviderDiagnostic, ProviderEntitlementImportRequest, ProviderErrorCode, ProviderHealthStatus, ProviderImport, ProviderImportItem, ProviderImportRequest, ProviderImportResult, ProviderIntegrationMode, ProviderKind, ProviderMappingEnvelope, ProviderMappingId, ProviderMappingList, ProviderMappingListEnvelope, ProviderMappingObservation, ProviderMappingObservationEnvelope, ProviderMappingObservationMetadata, ProviderMappingStatus, ProviderMappingUsage, ProviderMappingUsageEnvelope, ProviderOrderReference, ProviderProductImportItemRequest, ProviderProductMapping, ProviderProductMetadataSnapshot, ProviderProfile, ProviderProfileEnvelope, ProviderReadiness, ProviderReadinessEnvelope, ProviderReadinessIssue, ProviderSyncJob, ProviderSyncRun, ProviderSyncState, PublishConfigurationData, PublishConfigurationError, PublishConfigurationErrors, PublishConfigurationResponse, PublishConfigurationResponses, PublishExperimentData, PublishExperimentError, PublishExperimentErrors, PublishExperimentRequest, PublishExperimentResponse, PublishExperimentResponses, PublishGrantVersionRequest, PublishPlacementRuleSetData, PublishPlacementRuleSetError, PublishPlacementRuleSetErrors, PublishPlacementRuleSetResponse, PublishPlacementRuleSetResponses, PublishProductEntitlementGrantVersionData, PublishProductEntitlementGrantVersionError, PublishProductEntitlementGrantVersionErrors, PublishProductEntitlementGrantVersionResponse, PublishProductEntitlementGrantVersionResponses, PublishRequest, PublishResult, PublishResultEnvelope, QaOverride, QaOverrideCreated, QaOverrideCreatedEnvelope, QaOverrideListEnvelope, QuarantineRecord, QuarantineRecordId, ReceiveAppleStoreNotificationData, ReceiveAppleStoreNotificationError, ReceiveAppleStoreNotificationErrors, ReceiveAppleStoreNotificationResponse, ReceiveAppleStoreNotificationResponses, ReconciliationRun, ReconnectProviderConnectionData, ReconnectProviderConnectionError, ReconnectProviderConnectionErrors, ReconnectProviderConnectionResponse, ReconnectProviderConnectionResponses, ReleaseEnvelope, ReleaseId, ReleaseListEnvelope, RemoveMemberData, RemoveMemberError, RemoveMemberErrors, RemoveMemberResponse, RemoveMemberResponses, RemovePlanProductData, RemovePlanProductError, RemovePlanProductErrors, RemovePlanProductResponse, RemovePlanProductResponses, RemoveProductEntitlementData, RemoveProductEntitlementError, RemoveProductEntitlementErrors, RemoveProductEntitlementResponse, RemoveProductEntitlementResponses, ReplaceProviderConnectionScopesData, ReplaceProviderConnectionScopesError, ReplaceProviderConnectionScopesErrors, ReplaceProviderConnectionScopesRequest, ReplaceProviderConnectionScopesResponse, ReplaceProviderConnectionScopesResponses, ReplaceProviderMappingData, ReplaceProviderMappingError, ReplaceProviderMappingErrors, ReplaceProviderMappingRequest, ReplaceProviderMappingResponse, ReplaceProviderMappingResponses, ReplayJob, ReplayWebhookDeliveryData, ReplayWebhookDeliveryError, ReplayWebhookDeliveryErrors, ReplayWebhookDeliveryResponse, ReplayWebhookDeliveryResponses, RequestBillingCustomerSyncData, RequestBillingCustomerSyncError, RequestBillingCustomerSyncErrors, RequestBillingCustomerSyncResponse, RequestBillingCustomerSyncResponses, ResolveBillingIdentityConflictData, ResolveBillingIdentityConflictError, ResolveBillingIdentityConflictErrors, ResolveBillingIdentityConflictResponse, ResolveBillingIdentityConflictResponses, ResolveIdentityConflictRequest, RestoreJobId, RestoreProductData, RestoreProductError, RestoreProductErrors, RestoreProductResponse, RestoreProductResponses, RestoreProjectData, RestoreProjectError, RestoreProjectErrors, RestoreProjectResponse, RestoreProjectResponses, RestoreRequestRecord, RestoreResultRecord, RetireWebhookSigningSecretData, RetireWebhookSigningSecretError, RetireWebhookSigningSecretErrors, RetireWebhookSigningSecretResponse, RetireWebhookSigningSecretResponses, RetryQuarantinedInputData, RetryQuarantinedInputError, RetryQuarantinedInputErrors, RetryQuarantinedInputResponse, RetryQuarantinedInputResponses, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyErrors, RevokeApiKeyResponse, RevokeApiKeyResponses, RevokeBillingCustomerAliasData, RevokeBillingCustomerAliasError, RevokeBillingCustomerAliasErrors, RevokeBillingCustomerAliasResponse, RevokeBillingCustomerAliasResponses, RevokeCustomerAccessTokenData, RevokeCustomerAccessTokenError, RevokeCustomerAccessTokenErrors, RevokeCustomerAccessTokenResponse, RevokeCustomerAccessTokenResponses, RevokeExperimentQaOverrideData, RevokeExperimentQaOverrideError, RevokeExperimentQaOverrideErrors, RevokeExperimentQaOverrideResponse, RevokeExperimentQaOverrideResponses, RevokePlacementQaOverrideData, RevokePlacementQaOverrideError, RevokePlacementQaOverrideErrors, RevokePlacementQaOverrideResponse, RevokePlacementQaOverrideResponses, RevokeProviderConnectionData, RevokeProviderConnectionError, RevokeProviderConnectionErrors, RevokeProviderConnectionResponse, RevokeProviderConnectionResponses, RevokeStoreServerCredentialData, RevokeStoreServerCredentialError, RevokeStoreServerCredentialErrors, RevokeStoreServerCredentialResponse, RevokeStoreServerCredentialResponses, Role, RollbackConfigurationReleaseData, RollbackConfigurationReleaseError, RollbackConfigurationReleaseErrors, RollbackConfigurationReleaseResponse, RollbackConfigurationReleaseResponses, RotateApiKeyData, RotateApiKeyError, RotateApiKeyErrors, RotateApiKeyResponse, RotateApiKeyResponses, RotateProviderCredentialData, RotateProviderCredentialError, RotateProviderCredentialErrors, RotateProviderCredentialResponse, RotateProviderCredentialResponses, RotateStoreServerCredentialData, RotateStoreServerCredentialError, RotateStoreServerCredentialErrors, RotateStoreServerCredentialResponse, RotateStoreServerCredentialResponses, RotateWebhookSigningSecretData, RotateWebhookSigningSecretError, RotateWebhookSigningSecretErrors, RotateWebhookSigningSecretResponse, RotateWebhookSigningSecretResponses, RuleSetId, ServerTransactionObservation, ServerTransactionObservationRecord, SetActiveProviderAssignmentData, SetActiveProviderAssignmentError, SetActiveProviderAssignmentErrors, SetActiveProviderAssignmentResponse, SetActiveProviderAssignmentResponses, SetEnvironmentModeData, SetEnvironmentModeError, SetEnvironmentModeErrors, SetEnvironmentModeRequest, SetEnvironmentModeResponse, SetEnvironmentModeResponses, SetProductReplacementData, SetProductReplacementError, SetProductReplacementErrors, SetProductReplacementResponse, SetProductReplacementResponses, SetProviderAssignmentRequest, SetWebhookDestinationStatusData, SetWebhookDestinationStatusError, SetWebhookDestinationStatusErrors, SetWebhookDestinationStatusRequest, SetWebhookDestinationStatusResponse, SetWebhookDestinationStatusResponses, SignUpData, SignUpError, SignUpErrors, SignUpRequest, SignUpResponse, SignUpResponses, SimulatePlacementDecisionData, SimulatePlacementDecisionError, SimulatePlacementDecisionErrors, SimulatePlacementDecisionResponse, SimulatePlacementDecisionResponses, StoreCredentialId, StoreEnvironmentClassification, StoreServerCredential, StoreServerCredentialApplication, StoreServerCredentialWithEndpoint, SubmitSdkRestoreData, SubmitSdkRestoreError, SubmitSdkRestoreErrors, SubmitSdkRestoreResponse, SubmitSdkRestoreResponses, SubmitServerRestoreData, SubmitServerRestoreError, SubmitServerRestoreErrors, SubmitServerRestoreResponse, SubmitServerRestoreResponses, SubmitServerTransactionObservationData, SubmitServerTransactionObservationError, SubmitServerTransactionObservationErrors, SubmitServerTransactionObservationResponse, SubmitServerTransactionObservationResponses, SubmitTransactionObservationData, SubmitTransactionObservationError, SubmitTransactionObservationErrors, SubmitTransactionObservationResponse, SubmitTransactionObservationResponses, SubscriptionInstanceId, SubscriptionSnapshotRecord, SubscriptionSummary, SubscriptionTimelineEntry, SyncCustomerEntitlementsData, SyncCustomerEntitlementsError, SyncCustomerEntitlementsErrors, SyncCustomerEntitlementsResponse, SyncCustomerEntitlementsResponses, SyncCustomerEntitlementsWithNegotiationData, SyncCustomerEntitlementsWithNegotiationError, SyncCustomerEntitlementsWithNegotiationErrors, SyncCustomerEntitlementsWithNegotiationResponse, SyncCustomerEntitlementsWithNegotiationResponses, TestProviderConnectionData, TestProviderConnectionError, TestProviderConnectionErrors, TestProviderConnectionResponse, TestProviderConnectionResponses, TestStoreServerCredentialData, TestStoreServerCredentialError, TestStoreServerCredentialErrors, TestStoreServerCredentialResponse, TestStoreServerCredentialResponses, Timestamp, TransactionFact, TransactionReference, TransitionExperimentLifecycleData, TransitionExperimentLifecycleError, TransitionExperimentLifecycleErrors, TransitionExperimentLifecycleResponse, TransitionExperimentLifecycleResponses, Uncertainty, UpdateAnalyticsSettingsData, UpdateAnalyticsSettingsError, UpdateAnalyticsSettingsErrors, UpdateAnalyticsSettingsRequest, UpdateAnalyticsSettingsResponse, UpdateAnalyticsSettingsResponses, UpdateBillingSettingsData, UpdateBillingSettingsError, UpdateBillingSettingsErrors, UpdateBillingSettingsResponse, UpdateBillingSettingsResponses, UpdateDraftRequest, UpdateEntitlementData, UpdateEntitlementError, UpdateEntitlementErrors, UpdateEntitlementResponse, UpdateEntitlementResponses, UpdateEnvironmentData, UpdateEnvironmentError, UpdateEnvironmentErrors, UpdateEnvironmentResponse, UpdateEnvironmentResponses, UpdateExperimentDraftData, UpdateExperimentDraftError, UpdateExperimentDraftErrors, UpdateExperimentDraftRequest, UpdateExperimentDraftResponse, UpdateExperimentDraftResponses, UpdateMemberData, UpdateMemberError, UpdateMemberErrors, UpdateMemberRequest, UpdateMemberResponse, UpdateMemberResponses, UpdateNameRequest, UpdateOrganizationData, UpdateOrganizationError, UpdateOrganizationErrors, UpdateOrganizationRequest, UpdateOrganizationResponse, UpdateOrganizationResponses, UpdatePaywallData, UpdatePaywallDraftData, UpdatePaywallDraftError, UpdatePaywallDraftErrors, UpdatePaywallDraftResponse, UpdatePaywallDraftResponses, UpdatePaywallError, UpdatePaywallErrors, UpdatePaywallRequest, UpdatePaywallResponse, UpdatePaywallResponses, UpdatePlacementData, UpdatePlacementError, UpdatePlacementErrors, UpdatePlacementRequest, UpdatePlacementResponse, UpdatePlacementResponses, UpdatePlacementRuleSetDraftData, UpdatePlacementRuleSetDraftError, UpdatePlacementRuleSetDraftErrors, UpdatePlacementRuleSetDraftResponse, UpdatePlacementRuleSetDraftResponses, UpdatePlanData, UpdatePlanError, UpdatePlanErrors, UpdatePlanResponse, UpdatePlanResponses, UpdateProductData, UpdateProductEntitlementGrantVersionData, UpdateProductEntitlementGrantVersionError, UpdateProductEntitlementGrantVersionErrors, UpdateProductError, UpdateProductErrors, UpdateProductResponse, UpdateProductResponses, UpdateProjectData, UpdateProjectError, UpdateProjectErrors, UpdateProjectResponse, UpdateProjectResponses, UpdateWebhookDestinationData, UpdateWebhookDestinationError, UpdateWebhookDestinationErrors, UpdateWebhookDestinationRequest, UpdateWebhookDestinationResponse, UpdateWebhookDestinationResponses, UploadAssetData, UploadAssetError, UploadAssetErrors, UploadAssetResponse, UploadAssetResponses, User, UserEnvelope, ValidateExperimentDraftData, ValidateExperimentDraftResponse, ValidateExperimentDraftResponses, ValidatePaywallDraftData, ValidatePaywallDraftError, ValidatePaywallDraftErrors, ValidatePaywallDraftResponse, ValidatePaywallDraftResponses, ValidatePlacementRuleSetData, ValidatePlacementRuleSetResponse, ValidatePlacementRuleSetResponses, ValidationAttempt, ValidationSummary, ValidationSummaryEnvelope, VersionId, WebhookDelivery, WebhookDeliveryAttempt, WebhookDeliveryId, WebhookDestination, WebhookDestinationId, WebhookDestinationWithSecret, WebhookSigningSecretMetadata } from './types.gen'; diff --git a/apps/dashboard/src/generated/api/sdk.gen.ts b/apps/dashboard/src/generated/api/sdk.gen.ts index 21a307ff..a5cae79b 100644 --- a/apps/dashboard/src/generated/api/sdk.gen.ts +++ b/apps/dashboard/src/generated/api/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, type ClientMeta, formDataBodySerializer, type Options as Options2, type RequestResult, type TDataShape } from './client'; import { client } from './client.gen'; -import type { AddMemberData, AddMemberErrors, AddMemberResponses, AddPlanProductData, AddPlanProductErrors, AddPlanProductResponses, AddProductEntitlementData, AddProductEntitlementErrors, AddProductEntitlementResponses, ArchiveAssetData, ArchiveAssetErrors, ArchiveAssetResponses, ArchivePlacementAttributeData, ArchivePlacementAttributeErrors, ArchivePlacementAttributeResponses, ArchivePlacementRuleSetData, ArchivePlacementRuleSetErrors, ArchivePlacementRuleSetResponses, ArchivePlacementWithUsageCheckData, ArchivePlacementWithUsageCheckErrors, ArchivePlacementWithUsageCheckResponses, ArchiveProductData, ArchiveProductErrors, ArchiveProductResponses, ArchiveProjectData, ArchiveProjectErrors, ArchiveProjectResponses, ArchiveProviderMappingData, ArchiveProviderMappingErrors, ArchiveProviderMappingResponses, BindPlacementData, BindPlacementErrors, BindPlacementResponses, ClearActiveProviderAssignmentData, ClearActiveProviderAssignmentErrors, ClearActiveProviderAssignmentResponses, ClonePaywallVersionToDraftData, ClonePaywallVersionToDraftErrors, ClonePaywallVersionToDraftResponses, ClonePlacementRuleSetVersionData, ClonePlacementRuleSetVersionErrors, ClonePlacementRuleSetVersionResponses, CloseQuarantineRecordSupersededData, CloseQuarantineRecordSupersededErrors, CloseQuarantineRecordSupersededResponses, CompareAnalyticsPaywallVersionsData, CompareAnalyticsPaywallVersionsErrors, CompareAnalyticsPaywallVersionsResponses, CreateAnalyticsEventExportData, CreateAnalyticsEventExportErrors, CreateAnalyticsEventExportResponses, CreateAnalyticsPrivacyDeletionData, CreateAnalyticsPrivacyDeletionErrors, CreateAnalyticsPrivacyDeletionResponses, CreateAnalyticsPrivacyExportData, CreateAnalyticsPrivacyExportErrors, CreateAnalyticsPrivacyExportResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateApplicationData, CreateApplicationErrors, CreateApplicationResponses, CreateEntitlementData, CreateEntitlementErrors, CreateEntitlementResponses, CreateExperimentData, CreateExperimentErrors, CreateExperimentGroupVersionData, CreateExperimentGroupVersionErrors, CreateExperimentGroupVersionResponses, CreateExperimentMutualExclusionGroupVersionData, CreateExperimentMutualExclusionGroupVersionErrors, CreateExperimentMutualExclusionGroupVersionResponses, CreateExperimentQaOverrideData, CreateExperimentQaOverrideErrors, CreateExperimentQaOverrideResponses, CreateExperimentRawExportData, CreateExperimentRawExportErrors, CreateExperimentRawExportResponses, CreateExperimentResponses, CreateOrganizationData, CreateOrganizationErrors, CreateOrganizationResponses, CreatePaywallData, CreatePaywallDraftData, CreatePaywallDraftErrors, CreatePaywallDraftResponses, CreatePaywallErrors, CreatePaywallResponses, CreatePlacementAliasData, CreatePlacementAliasErrors, CreatePlacementAliasResponses, CreatePlacementAttributeData, CreatePlacementAttributeErrors, CreatePlacementAttributeResponses, CreatePlacementData, CreatePlacementErrors, CreatePlacementQaOverrideData, CreatePlacementQaOverrideErrors, CreatePlacementQaOverrideResponses, CreatePlacementResponses, CreatePlacementRuleSetData, CreatePlacementRuleSetErrors, CreatePlacementRuleSetResponses, CreatePlanData, CreatePlanErrors, CreatePlanResponses, CreateProductData, CreateProductErrors, CreateProductResponses, CreateProjectData, CreateProjectErrors, CreateProjectResponses, CreateProviderConnectionData, CreateProviderConnectionErrors, CreateProviderConnectionResponses, CreateProviderMappingData, CreateProviderMappingDraftData, CreateProviderMappingDraftErrors, CreateProviderMappingDraftResponses, CreateProviderMappingErrors, CreateProviderMappingObservationData, CreateProviderMappingObservationErrors, CreateProviderMappingObservationResponses, CreateProviderMappingResponses, CreateReconciliationRunData, CreateReconciliationRunErrors, CreateReconciliationRunResponses, CreateReplayJobData, CreateReplayJobErrors, CreateReplayJobResponses, CreateStoreServerCredentialData, CreateStoreServerCredentialErrors, CreateStoreServerCredentialResponses, DeleteProductData, DeleteProductErrors, DeleteProductResponses, DownloadAnalyticsJobData, DownloadAnalyticsJobErrors, DownloadAnalyticsJobResponses, EnqueueProviderSyncData, EnqueueProviderSyncErrors, EnqueueProviderSyncResponses, GetActivePaywallDraftData, GetActivePaywallDraftErrors, GetActivePaywallDraftResponses, GetActiveProviderAssignmentData, GetActiveProviderAssignmentErrors, GetActiveProviderAssignmentResponses, GetAnalyticsBreakdownData, GetAnalyticsBreakdownErrors, GetAnalyticsBreakdownResponses, GetAnalyticsFreshnessData, GetAnalyticsFreshnessErrors, GetAnalyticsFreshnessResponses, GetAnalyticsFunnelData, GetAnalyticsFunnelErrors, GetAnalyticsFunnelResponses, GetAnalyticsJobData, GetAnalyticsJobErrors, GetAnalyticsJobResponses, GetAnalyticsOverviewData, GetAnalyticsOverviewErrors, GetAnalyticsOverviewResponses, GetAnalyticsProductAvailabilityFailuresData, GetAnalyticsProductAvailabilityFailuresErrors, GetAnalyticsProductAvailabilityFailuresResponses, GetAnalyticsProviderErrorsData, GetAnalyticsProviderErrorsErrors, GetAnalyticsProviderErrorsResponses, GetAnalyticsSettingsData, GetAnalyticsSettingsErrors, GetAnalyticsSettingsResponses, GetAssetContentData, GetAssetContentErrors, GetAssetContentResponses, GetAssetData, GetAssetErrors, GetAssetResponses, GetAssetUsageData, GetAssetUsageErrors, GetAssetUsageResponses, GetBillingHealthData, GetBillingHealthErrors, GetBillingHealthResponses, GetEntitlementData, GetEntitlementErrors, GetEntitlementResponses, GetExperimentData, GetExperimentErrors, GetExperimentResponses, GetExperimentResultsData, GetExperimentResultsResponses, GetExperimentSampleRatioMismatchData, GetExperimentSampleRatioMismatchResponses, GetHealthData, GetHealthResponses, GetNativeProviderProfileData, GetNativeProviderProfileErrors, GetNativeProviderProfileResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationResponses, GetPaywallData, GetPaywallDraftData, GetPaywallDraftErrors, GetPaywallDraftResponses, GetPaywallErrors, GetPaywallResponses, GetPaywallVersionData, GetPaywallVersionErrors, GetPaywallVersionResponses, GetPlacementBindingData, GetPlacementBindingErrors, GetPlacementBindingResponses, GetPlacementDecisionData, GetPlacementDecisionErrors, GetPlacementDecisionResponses, GetPlacementUsageData, GetPlacementUsageErrors, GetPlacementUsageResponses, GetPlanData, GetPlanErrors, GetPlanResponses, GetProductData, GetProductErrors, GetProductReadinessData, GetProductReadinessErrors, GetProductReadinessResponses, GetProductResponses, GetProductUsageData, GetProductUsageErrors, GetProductUsageResponses, GetProjectData, GetProjectErrors, GetProjectResponses, GetProviderConnectionCapabilitiesData, GetProviderConnectionCapabilitiesErrors, GetProviderConnectionCapabilitiesResponses, GetProviderConnectionData, GetProviderConnectionErrors, GetProviderConnectionHealthData, GetProviderConnectionHealthErrors, GetProviderConnectionHealthResponses, GetProviderConnectionResponses, GetProviderMappingMetadataData, GetProviderMappingMetadataErrors, GetProviderMappingMetadataResponses, GetProviderMappingUsageData, GetProviderMappingUsageErrors, GetProviderMappingUsageResponses, GetProviderReadinessData, GetProviderReadinessErrors, GetProviderReadinessResponses, GetQuarantineRecordData, GetQuarantineRecordErrors, GetQuarantineRecordResponses, GetReadinessData, GetReadinessErrors, GetReadinessResponses, GetSdkCommerceConfigurationData, GetSdkCommerceConfigurationErrors, GetSdkCommerceConfigurationResponses, GetSdkConfigurationData, GetSdkConfigurationErrors, GetSdkConfigurationResponses, GetSessionData, GetSessionErrors, GetSessionResponses, GetStoreServerCredentialData, GetStoreServerCredentialErrors, GetStoreServerCredentialResponses, ImportProviderProductsData, ImportProviderProductsErrors, ImportProviderProductsResponses, IngestAnalyticsEventBatchData, IngestAnalyticsEventBatchErrors, IngestAnalyticsEventBatchResponses, ListApiKeysData, ListApiKeysErrors, ListApiKeysResponses, ListApplicationsData, ListApplicationsErrors, ListApplicationsResponses, ListAssetsData, ListAssetsErrors, ListAssetsResponses, ListAuditEventsData, ListAuditEventsErrors, ListAuditEventsResponses, ListBillingLedgerData, ListBillingLedgerErrors, ListBillingLedgerResponses, ListBillingQuarantineData, ListBillingQuarantineErrors, ListBillingQuarantineResponses, ListConfigurationReleasesData, ListConfigurationReleasesErrors, ListConfigurationReleasesResponses, ListEntitlementsData, ListEntitlementsErrors, ListEntitlementsResponses, ListEnvironmentsData, ListEnvironmentsErrors, ListEnvironmentsResponses, ListExperimentGroupsData, ListExperimentGroupsResponses, ListExperimentHistoryData, ListExperimentHistoryResponses, ListExperimentMetricDefinitionsData, ListExperimentMetricDefinitionsResponses, ListExperimentMutualExclusionGroupVersionsData, ListExperimentMutualExclusionGroupVersionsErrors, ListExperimentMutualExclusionGroupVersionsResponses, ListExperimentQaOverridesData, ListExperimentQaOverridesResponses, ListExperimentsData, ListExperimentsResponses, ListExperimentVersionsData, ListExperimentVersionsResponses, ListMembersData, ListMembersErrors, ListMembersResponses, ListOrganizationsData, ListOrganizationsErrors, ListOrganizationsResponses, ListPaywallsData, ListPaywallsErrors, ListPaywallsResponses, ListPaywallVersionsData, ListPaywallVersionsErrors, ListPaywallVersionsResponses, ListPlacementAliasesData, ListPlacementAliasesResponses, ListPlacementAttributesData, ListPlacementAttributesErrors, ListPlacementAttributesResponses, ListPlacementQaOverridesData, ListPlacementQaOverridesResponses, ListPlacementRuleSetVersionsData, ListPlacementRuleSetVersionsResponses, ListPlacementsData, ListPlacementsErrors, ListPlacementsResponses, ListPlanProductsData, ListPlanProductsErrors, ListPlanProductsResponses, ListPlansData, ListPlansErrors, ListPlansResponses, ListProductEntitlementsData, ListProductEntitlementsErrors, ListProductEntitlementsResponses, ListProductsData, ListProductsErrors, ListProductsResponses, ListProjectsData, ListProjectsErrors, ListProjectsResponses, ListProviderConnectionDiagnosticsData, ListProviderConnectionDiagnosticsErrors, ListProviderConnectionDiagnosticsResponses, ListProviderConnectionsData, ListProviderConnectionsErrors, ListProviderConnectionsResponses, ListProviderMappingObservationsData, ListProviderMappingObservationsErrors, ListProviderMappingObservationsResponses, ListProviderMappingsData, ListProviderMappingsErrors, ListProviderMappingsResponses, ListProviderSyncRunsData, ListProviderSyncRunsErrors, ListProviderSyncRunsResponses, ListReconciliationRunsData, ListReconciliationRunsErrors, ListReconciliationRunsResponses, ListReplayJobsData, ListReplayJobsErrors, ListReplayJobsResponses, ListStoreServerCredentialsData, ListStoreServerCredentialsErrors, ListStoreServerCredentialsResponses, ListTransactionFactsData, ListTransactionFactsErrors, ListTransactionFactsResponses, ListValidationAttemptsData, ListValidationAttemptsErrors, ListValidationAttemptsResponses, LoginData, LoginErrors, LoginResponses, LogoutData, LogoutResponses, PreviewAnalyticsPrivacyRequestData, PreviewAnalyticsPrivacyRequestErrors, PreviewAnalyticsPrivacyRequestResponses, PreviewProviderCatalogData, PreviewProviderCatalogErrors, PreviewProviderCatalogResponses, PublishConfigurationData, PublishConfigurationErrors, PublishConfigurationResponses, PublishExperimentData, PublishExperimentErrors, PublishExperimentResponses, PublishPlacementRuleSetData, PublishPlacementRuleSetErrors, PublishPlacementRuleSetResponses, ReceiveAppleStoreNotificationData, ReceiveAppleStoreNotificationErrors, ReceiveAppleStoreNotificationResponses, ReconnectProviderConnectionData, ReconnectProviderConnectionErrors, ReconnectProviderConnectionResponses, RemoveMemberData, RemoveMemberErrors, RemoveMemberResponses, RemovePlanProductData, RemovePlanProductErrors, RemovePlanProductResponses, RemoveProductEntitlementData, RemoveProductEntitlementErrors, RemoveProductEntitlementResponses, ReplaceProviderConnectionScopesData, ReplaceProviderConnectionScopesErrors, ReplaceProviderConnectionScopesResponses, ReplaceProviderMappingData, ReplaceProviderMappingErrors, ReplaceProviderMappingResponses, RestoreProductData, RestoreProductErrors, RestoreProductResponses, RestoreProjectData, RestoreProjectErrors, RestoreProjectResponses, RetryQuarantinedInputData, RetryQuarantinedInputErrors, RetryQuarantinedInputResponses, RevokeApiKeyData, RevokeApiKeyErrors, RevokeApiKeyResponses, RevokeExperimentQaOverrideData, RevokeExperimentQaOverrideErrors, RevokeExperimentQaOverrideResponses, RevokePlacementQaOverrideData, RevokePlacementQaOverrideErrors, RevokePlacementQaOverrideResponses, RevokeProviderConnectionData, RevokeProviderConnectionErrors, RevokeProviderConnectionResponses, RevokeStoreServerCredentialData, RevokeStoreServerCredentialErrors, RevokeStoreServerCredentialResponses, RollbackConfigurationReleaseData, RollbackConfigurationReleaseErrors, RollbackConfigurationReleaseResponses, RotateApiKeyData, RotateApiKeyErrors, RotateApiKeyResponses, RotateProviderCredentialData, RotateProviderCredentialErrors, RotateProviderCredentialResponses, RotateStoreServerCredentialData, RotateStoreServerCredentialErrors, RotateStoreServerCredentialResponses, SetActiveProviderAssignmentData, SetActiveProviderAssignmentErrors, SetActiveProviderAssignmentResponses, SetEnvironmentModeData, SetEnvironmentModeErrors, SetEnvironmentModeResponses, SetProductReplacementData, SetProductReplacementErrors, SetProductReplacementResponses, SignUpData, SignUpErrors, SignUpResponses, SimulatePlacementDecisionData, SimulatePlacementDecisionErrors, SimulatePlacementDecisionResponses, SubmitServerTransactionObservationData, SubmitServerTransactionObservationErrors, SubmitServerTransactionObservationResponses, SubmitTransactionObservationData, SubmitTransactionObservationErrors, SubmitTransactionObservationResponses, TestProviderConnectionData, TestProviderConnectionErrors, TestProviderConnectionResponses, TestStoreServerCredentialData, TestStoreServerCredentialErrors, TestStoreServerCredentialResponses, TransitionExperimentLifecycleData, TransitionExperimentLifecycleErrors, TransitionExperimentLifecycleResponses, UpdateAnalyticsSettingsData, UpdateAnalyticsSettingsErrors, UpdateAnalyticsSettingsResponses, UpdateBillingSettingsData, UpdateBillingSettingsErrors, UpdateBillingSettingsResponses, UpdateEntitlementData, UpdateEntitlementErrors, UpdateEntitlementResponses, UpdateEnvironmentData, UpdateEnvironmentErrors, UpdateEnvironmentResponses, UpdateExperimentDraftData, UpdateExperimentDraftErrors, UpdateExperimentDraftResponses, UpdateMemberData, UpdateMemberErrors, UpdateMemberResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdatePaywallData, UpdatePaywallDraftData, UpdatePaywallDraftErrors, UpdatePaywallDraftResponses, UpdatePaywallErrors, UpdatePaywallResponses, UpdatePlacementData, UpdatePlacementErrors, UpdatePlacementResponses, UpdatePlacementRuleSetDraftData, UpdatePlacementRuleSetDraftErrors, UpdatePlacementRuleSetDraftResponses, UpdatePlanData, UpdatePlanErrors, UpdatePlanResponses, UpdateProductData, UpdateProductErrors, UpdateProductResponses, UpdateProjectData, UpdateProjectErrors, UpdateProjectResponses, UploadAssetData, UploadAssetErrors, UploadAssetResponses, ValidateExperimentDraftData, ValidateExperimentDraftResponses, ValidatePaywallDraftData, ValidatePaywallDraftErrors, ValidatePaywallDraftResponses, ValidatePlacementRuleSetData, ValidatePlacementRuleSetResponses } from './types.gen'; +import type { AddMemberData, AddMemberErrors, AddMemberResponses, AddPlanProductData, AddPlanProductErrors, AddPlanProductResponses, AddProductEntitlementData, AddProductEntitlementErrors, AddProductEntitlementResponses, ArchiveAssetData, ArchiveAssetErrors, ArchiveAssetResponses, ArchivePlacementAttributeData, ArchivePlacementAttributeErrors, ArchivePlacementAttributeResponses, ArchivePlacementRuleSetData, ArchivePlacementRuleSetErrors, ArchivePlacementRuleSetResponses, ArchivePlacementWithUsageCheckData, ArchivePlacementWithUsageCheckErrors, ArchivePlacementWithUsageCheckResponses, ArchiveProductData, ArchiveProductErrors, ArchiveProductResponses, ArchiveProjectData, ArchiveProjectErrors, ArchiveProjectResponses, ArchiveProviderMappingData, ArchiveProviderMappingErrors, ArchiveProviderMappingResponses, AttachBillingCustomerAliasData, AttachBillingCustomerAliasErrors, AttachBillingCustomerAliasResponses, BindPlacementData, BindPlacementErrors, BindPlacementResponses, CheckCustomerEntitlementsData, CheckCustomerEntitlementsErrors, CheckCustomerEntitlementsResponses, ClearActiveProviderAssignmentData, ClearActiveProviderAssignmentErrors, ClearActiveProviderAssignmentResponses, ClonePaywallVersionToDraftData, ClonePaywallVersionToDraftErrors, ClonePaywallVersionToDraftResponses, ClonePlacementRuleSetVersionData, ClonePlacementRuleSetVersionErrors, ClonePlacementRuleSetVersionResponses, CloseQuarantineRecordSupersededData, CloseQuarantineRecordSupersededErrors, CloseQuarantineRecordSupersededResponses, CompareAnalyticsPaywallVersionsData, CompareAnalyticsPaywallVersionsErrors, CompareAnalyticsPaywallVersionsResponses, CreateAnalyticsEventExportData, CreateAnalyticsEventExportErrors, CreateAnalyticsEventExportResponses, CreateAnalyticsPrivacyDeletionData, CreateAnalyticsPrivacyDeletionErrors, CreateAnalyticsPrivacyDeletionResponses, CreateAnalyticsPrivacyExportData, CreateAnalyticsPrivacyExportErrors, CreateAnalyticsPrivacyExportResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateApplicationData, CreateApplicationErrors, CreateApplicationResponses, CreateBillingCustomerSyncRequestData, CreateBillingCustomerSyncRequestErrors, CreateBillingCustomerSyncRequestResponses, CreateBillingProjectionReplayData, CreateBillingProjectionReplayErrors, CreateBillingProjectionReplayResponses, CreateEntitlementData, CreateEntitlementErrors, CreateEntitlementResponses, CreateExperimentData, CreateExperimentErrors, CreateExperimentGroupVersionData, CreateExperimentGroupVersionErrors, CreateExperimentGroupVersionResponses, CreateExperimentMutualExclusionGroupVersionData, CreateExperimentMutualExclusionGroupVersionErrors, CreateExperimentMutualExclusionGroupVersionResponses, CreateExperimentQaOverrideData, CreateExperimentQaOverrideErrors, CreateExperimentQaOverrideResponses, CreateExperimentRawExportData, CreateExperimentRawExportErrors, CreateExperimentRawExportResponses, CreateExperimentResponses, CreateOrganizationData, CreateOrganizationErrors, CreateOrganizationResponses, CreatePaywallData, CreatePaywallDraftData, CreatePaywallDraftErrors, CreatePaywallDraftResponses, CreatePaywallErrors, CreatePaywallResponses, CreatePlacementAliasData, CreatePlacementAliasErrors, CreatePlacementAliasResponses, CreatePlacementAttributeData, CreatePlacementAttributeErrors, CreatePlacementAttributeResponses, CreatePlacementData, CreatePlacementErrors, CreatePlacementQaOverrideData, CreatePlacementQaOverrideErrors, CreatePlacementQaOverrideResponses, CreatePlacementResponses, CreatePlacementRuleSetData, CreatePlacementRuleSetErrors, CreatePlacementRuleSetResponses, CreatePlanData, CreatePlanErrors, CreatePlanResponses, CreateProductData, CreateProductErrors, CreateProductResponses, CreateProjectData, CreateProjectErrors, CreateProjectResponses, CreateProviderConnectionData, CreateProviderConnectionErrors, CreateProviderConnectionResponses, CreateProviderMappingData, CreateProviderMappingDraftData, CreateProviderMappingDraftErrors, CreateProviderMappingDraftResponses, CreateProviderMappingErrors, CreateProviderMappingObservationData, CreateProviderMappingObservationErrors, CreateProviderMappingObservationResponses, CreateProviderMappingResponses, CreateReconciliationRunData, CreateReconciliationRunErrors, CreateReconciliationRunResponses, CreateReplayJobData, CreateReplayJobErrors, CreateReplayJobResponses, CreateStoreServerCredentialData, CreateStoreServerCredentialErrors, CreateStoreServerCredentialResponses, CreateWebhookDestinationData, CreateWebhookDestinationErrors, CreateWebhookDestinationResponses, DeleteProductData, DeleteProductErrors, DeleteProductResponses, DeleteWebhookDestinationData, DeleteWebhookDestinationErrors, DeleteWebhookDestinationResponses, DownloadAnalyticsJobData, DownloadAnalyticsJobErrors, DownloadAnalyticsJobResponses, EnqueueProviderSyncData, EnqueueProviderSyncErrors, EnqueueProviderSyncResponses, GetActivePaywallDraftData, GetActivePaywallDraftErrors, GetActivePaywallDraftResponses, GetActiveProviderAssignmentData, GetActiveProviderAssignmentErrors, GetActiveProviderAssignmentResponses, GetAnalyticsBreakdownData, GetAnalyticsBreakdownErrors, GetAnalyticsBreakdownResponses, GetAnalyticsFreshnessData, GetAnalyticsFreshnessErrors, GetAnalyticsFreshnessResponses, GetAnalyticsFunnelData, GetAnalyticsFunnelErrors, GetAnalyticsFunnelResponses, GetAnalyticsJobData, GetAnalyticsJobErrors, GetAnalyticsJobResponses, GetAnalyticsOverviewData, GetAnalyticsOverviewErrors, GetAnalyticsOverviewResponses, GetAnalyticsProductAvailabilityFailuresData, GetAnalyticsProductAvailabilityFailuresErrors, GetAnalyticsProductAvailabilityFailuresResponses, GetAnalyticsProviderErrorsData, GetAnalyticsProviderErrorsErrors, GetAnalyticsProviderErrorsResponses, GetAnalyticsSettingsData, GetAnalyticsSettingsErrors, GetAnalyticsSettingsResponses, GetAssetContentData, GetAssetContentErrors, GetAssetContentResponses, GetAssetData, GetAssetErrors, GetAssetResponses, GetAssetUsageData, GetAssetUsageErrors, GetAssetUsageResponses, GetBillingCustomerData, GetBillingCustomerEntitlementSnapshotData, GetBillingCustomerEntitlementSnapshotErrors, GetBillingCustomerEntitlementSnapshotResponses, GetBillingCustomerErrors, GetBillingCustomerResponses, GetBillingHealthData, GetBillingHealthErrors, GetBillingHealthResponses, GetBillingIdentityConflictData, GetBillingIdentityConflictErrors, GetBillingIdentityConflictResponses, GetBillingProjectionHealthData, GetBillingProjectionHealthErrors, GetBillingProjectionHealthResponses, GetBillingRestoreJobData, GetBillingRestoreJobErrors, GetBillingRestoreJobResponses, GetBillingSettingsData, GetBillingSettingsErrors, GetBillingSettingsResponses, GetBillingSubscriptionData, GetBillingSubscriptionErrors, GetBillingSubscriptionResponses, GetCustomerEntitlementSnapshotData, GetCustomerEntitlementSnapshotErrors, GetCustomerEntitlementSnapshotResponses, GetEntitlementData, GetEntitlementErrors, GetEntitlementResponses, GetExperimentData, GetExperimentErrors, GetExperimentResponses, GetExperimentResultsData, GetExperimentResultsResponses, GetExperimentSampleRatioMismatchData, GetExperimentSampleRatioMismatchResponses, GetHealthData, GetHealthResponses, GetNativeProviderProfileData, GetNativeProviderProfileErrors, GetNativeProviderProfileResponses, GetOperatorBillingCustomerData, GetOperatorBillingCustomerErrors, GetOperatorBillingCustomerResponses, GetOperatorBillingIdentityConflictData, GetOperatorBillingIdentityConflictErrors, GetOperatorBillingIdentityConflictResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationResponses, GetPaywallData, GetPaywallDraftData, GetPaywallDraftErrors, GetPaywallDraftResponses, GetPaywallErrors, GetPaywallResponses, GetPaywallVersionData, GetPaywallVersionErrors, GetPaywallVersionResponses, GetPlacementBindingData, GetPlacementBindingErrors, GetPlacementBindingResponses, GetPlacementDecisionData, GetPlacementDecisionErrors, GetPlacementDecisionResponses, GetPlacementUsageData, GetPlacementUsageErrors, GetPlacementUsageResponses, GetPlanData, GetPlanErrors, GetPlanResponses, GetProductData, GetProductEntitlementGrantVersionData, GetProductEntitlementGrantVersionErrors, GetProductEntitlementGrantVersionResponses, GetProductErrors, GetProductReadinessData, GetProductReadinessErrors, GetProductReadinessResponses, GetProductResponses, GetProductUsageData, GetProductUsageErrors, GetProductUsageResponses, GetProjectData, GetProjectErrors, GetProjectResponses, GetProviderConnectionCapabilitiesData, GetProviderConnectionCapabilitiesErrors, GetProviderConnectionCapabilitiesResponses, GetProviderConnectionData, GetProviderConnectionErrors, GetProviderConnectionHealthData, GetProviderConnectionHealthErrors, GetProviderConnectionHealthResponses, GetProviderConnectionResponses, GetProviderMappingMetadataData, GetProviderMappingMetadataErrors, GetProviderMappingMetadataResponses, GetProviderMappingUsageData, GetProviderMappingUsageErrors, GetProviderMappingUsageResponses, GetProviderReadinessData, GetProviderReadinessErrors, GetProviderReadinessResponses, GetQuarantineRecordData, GetQuarantineRecordErrors, GetQuarantineRecordResponses, GetReadinessData, GetReadinessErrors, GetReadinessResponses, GetSdkCommerceConfigurationData, GetSdkCommerceConfigurationErrors, GetSdkCommerceConfigurationResponses, GetSdkConfigurationData, GetSdkConfigurationErrors, GetSdkConfigurationResponses, GetSdkRestoreData, GetSdkRestoreErrors, GetSdkRestoreResponses, GetServerRestoreData, GetServerRestoreErrors, GetServerRestoreResponses, GetSessionData, GetSessionErrors, GetSessionResponses, GetStoreServerCredentialData, GetStoreServerCredentialErrors, GetStoreServerCredentialResponses, GetSubscriptionSnapshotData, GetSubscriptionSnapshotErrors, GetSubscriptionSnapshotResponses, GetWebhookDeliveryData, GetWebhookDeliveryErrors, GetWebhookDeliveryResponses, GetWebhookDestinationData, GetWebhookDestinationErrors, GetWebhookDestinationResponses, IdentifyBillingCustomerData, IdentifyBillingCustomerErrors, IdentifyBillingCustomerResponses, ImportProviderProductsData, ImportProviderProductsErrors, ImportProviderProductsResponses, IngestAnalyticsEventBatchData, IngestAnalyticsEventBatchErrors, IngestAnalyticsEventBatchResponses, IssueCustomerAccessTokenData, IssueCustomerAccessTokenErrors, IssueCustomerAccessTokenResponses, ListApiKeysData, ListApiKeysErrors, ListApiKeysResponses, ListApplicationsData, ListApplicationsErrors, ListApplicationsResponses, ListAssetsData, ListAssetsErrors, ListAssetsResponses, ListAuditEventsData, ListAuditEventsErrors, ListAuditEventsResponses, ListBillingCustomerAliasesData, ListBillingCustomerAliasesErrors, ListBillingCustomerAliasesResponses, ListBillingCustomersData, ListBillingCustomersErrors, ListBillingCustomersResponses, ListBillingCustomerSubscriptionsData, ListBillingCustomerSubscriptionsErrors, ListBillingCustomerSubscriptionsResponses, ListBillingIdentityConflictsData, ListBillingIdentityConflictsErrors, ListBillingIdentityConflictsResponses, ListBillingLedgerData, ListBillingLedgerErrors, ListBillingLedgerResponses, ListBillingQuarantineData, ListBillingQuarantineErrors, ListBillingQuarantineResponses, ListBillingRestoreJobsData, ListBillingRestoreJobsErrors, ListBillingRestoreJobsResponses, ListBillingSubscriptionTimelineData, ListBillingSubscriptionTimelineErrors, ListBillingSubscriptionTimelineResponses, ListConfigurationReleasesData, ListConfigurationReleasesErrors, ListConfigurationReleasesResponses, ListCustomerAccessTokensData, ListCustomerAccessTokensErrors, ListCustomerAccessTokensResponses, ListCustomerSubscriptionsData, ListCustomerSubscriptionsErrors, ListCustomerSubscriptionsResponses, ListEntitlementsData, ListEntitlementsErrors, ListEntitlementsResponses, ListEnvironmentsData, ListEnvironmentsErrors, ListEnvironmentsResponses, ListExperimentGroupsData, ListExperimentGroupsResponses, ListExperimentHistoryData, ListExperimentHistoryResponses, ListExperimentMetricDefinitionsData, ListExperimentMetricDefinitionsResponses, ListExperimentMutualExclusionGroupVersionsData, ListExperimentMutualExclusionGroupVersionsErrors, ListExperimentMutualExclusionGroupVersionsResponses, ListExperimentQaOverridesData, ListExperimentQaOverridesResponses, ListExperimentsData, ListExperimentsResponses, ListExperimentVersionsData, ListExperimentVersionsResponses, ListMembersData, ListMembersErrors, ListMembersResponses, ListOperatorBillingIdentityConflictsData, ListOperatorBillingIdentityConflictsErrors, ListOperatorBillingIdentityConflictsResponses, ListOrganizationsData, ListOrganizationsErrors, ListOrganizationsResponses, ListPaywallsData, ListPaywallsErrors, ListPaywallsResponses, ListPaywallVersionsData, ListPaywallVersionsErrors, ListPaywallVersionsResponses, ListPlacementAliasesData, ListPlacementAliasesResponses, ListPlacementAttributesData, ListPlacementAttributesErrors, ListPlacementAttributesResponses, ListPlacementQaOverridesData, ListPlacementQaOverridesResponses, ListPlacementRuleSetVersionsData, ListPlacementRuleSetVersionsResponses, ListPlacementsData, ListPlacementsErrors, ListPlacementsResponses, ListPlanProductsData, ListPlanProductsErrors, ListPlanProductsResponses, ListPlansData, ListPlansErrors, ListPlansResponses, ListProductEntitlementGrantVersionsData, ListProductEntitlementGrantVersionsErrors, ListProductEntitlementGrantVersionsResponses, ListProductEntitlementsData, ListProductEntitlementsErrors, ListProductEntitlementsResponses, ListProductsData, ListProductsErrors, ListProductsResponses, ListProjectsData, ListProjectsErrors, ListProjectsResponses, ListProviderConnectionDiagnosticsData, ListProviderConnectionDiagnosticsErrors, ListProviderConnectionDiagnosticsResponses, ListProviderConnectionsData, ListProviderConnectionsErrors, ListProviderConnectionsResponses, ListProviderMappingObservationsData, ListProviderMappingObservationsErrors, ListProviderMappingObservationsResponses, ListProviderMappingsData, ListProviderMappingsErrors, ListProviderMappingsResponses, ListProviderSyncRunsData, ListProviderSyncRunsErrors, ListProviderSyncRunsResponses, ListReconciliationRunsData, ListReconciliationRunsErrors, ListReconciliationRunsResponses, ListReplayJobsData, ListReplayJobsErrors, ListReplayJobsResponses, ListStoreServerCredentialsData, ListStoreServerCredentialsErrors, ListStoreServerCredentialsResponses, ListSubscriptionTimelineData, ListSubscriptionTimelineErrors, ListSubscriptionTimelineResponses, ListTransactionFactsData, ListTransactionFactsErrors, ListTransactionFactsResponses, ListValidationAttemptsData, ListValidationAttemptsErrors, ListValidationAttemptsResponses, ListWebhookDeliveriesData, ListWebhookDeliveriesErrors, ListWebhookDeliveriesResponses, ListWebhookDeliveryAttemptsData, ListWebhookDeliveryAttemptsErrors, ListWebhookDeliveryAttemptsResponses, ListWebhookDestinationsData, ListWebhookDestinationsErrors, ListWebhookDestinationsResponses, ListWebhookSigningSecretsData, ListWebhookSigningSecretsErrors, ListWebhookSigningSecretsResponses, LoginData, LoginErrors, LoginResponses, LogoutData, LogoutResponses, LookupBillingCustomerData, LookupBillingCustomerErrors, LookupBillingCustomerResponses, PreviewAnalyticsPrivacyRequestData, PreviewAnalyticsPrivacyRequestErrors, PreviewAnalyticsPrivacyRequestResponses, PreviewProductEntitlementGrantImpactData, PreviewProductEntitlementGrantImpactErrors, PreviewProductEntitlementGrantImpactResponses, PreviewProviderCatalogData, PreviewProviderCatalogErrors, PreviewProviderCatalogResponses, PublishConfigurationData, PublishConfigurationErrors, PublishConfigurationResponses, PublishExperimentData, PublishExperimentErrors, PublishExperimentResponses, PublishPlacementRuleSetData, PublishPlacementRuleSetErrors, PublishPlacementRuleSetResponses, PublishProductEntitlementGrantVersionData, PublishProductEntitlementGrantVersionErrors, PublishProductEntitlementGrantVersionResponses, ReceiveAppleStoreNotificationData, ReceiveAppleStoreNotificationErrors, ReceiveAppleStoreNotificationResponses, ReconnectProviderConnectionData, ReconnectProviderConnectionErrors, ReconnectProviderConnectionResponses, RemoveMemberData, RemoveMemberErrors, RemoveMemberResponses, RemovePlanProductData, RemovePlanProductErrors, RemovePlanProductResponses, RemoveProductEntitlementData, RemoveProductEntitlementErrors, RemoveProductEntitlementResponses, ReplaceProviderConnectionScopesData, ReplaceProviderConnectionScopesErrors, ReplaceProviderConnectionScopesResponses, ReplaceProviderMappingData, ReplaceProviderMappingErrors, ReplaceProviderMappingResponses, ReplayWebhookDeliveryData, ReplayWebhookDeliveryErrors, ReplayWebhookDeliveryResponses, RequestBillingCustomerSyncData, RequestBillingCustomerSyncErrors, RequestBillingCustomerSyncResponses, ResolveBillingIdentityConflictData, ResolveBillingIdentityConflictErrors, ResolveBillingIdentityConflictResponses, RestoreProductData, RestoreProductErrors, RestoreProductResponses, RestoreProjectData, RestoreProjectErrors, RestoreProjectResponses, RetireWebhookSigningSecretData, RetireWebhookSigningSecretErrors, RetireWebhookSigningSecretResponses, RetryQuarantinedInputData, RetryQuarantinedInputErrors, RetryQuarantinedInputResponses, RevokeApiKeyData, RevokeApiKeyErrors, RevokeApiKeyResponses, RevokeBillingCustomerAliasData, RevokeBillingCustomerAliasErrors, RevokeBillingCustomerAliasResponses, RevokeCustomerAccessTokenData, RevokeCustomerAccessTokenErrors, RevokeCustomerAccessTokenResponses, RevokeExperimentQaOverrideData, RevokeExperimentQaOverrideErrors, RevokeExperimentQaOverrideResponses, RevokePlacementQaOverrideData, RevokePlacementQaOverrideErrors, RevokePlacementQaOverrideResponses, RevokeProviderConnectionData, RevokeProviderConnectionErrors, RevokeProviderConnectionResponses, RevokeStoreServerCredentialData, RevokeStoreServerCredentialErrors, RevokeStoreServerCredentialResponses, RollbackConfigurationReleaseData, RollbackConfigurationReleaseErrors, RollbackConfigurationReleaseResponses, RotateApiKeyData, RotateApiKeyErrors, RotateApiKeyResponses, RotateProviderCredentialData, RotateProviderCredentialErrors, RotateProviderCredentialResponses, RotateStoreServerCredentialData, RotateStoreServerCredentialErrors, RotateStoreServerCredentialResponses, RotateWebhookSigningSecretData, RotateWebhookSigningSecretErrors, RotateWebhookSigningSecretResponses, SetActiveProviderAssignmentData, SetActiveProviderAssignmentErrors, SetActiveProviderAssignmentResponses, SetEnvironmentModeData, SetEnvironmentModeErrors, SetEnvironmentModeResponses, SetProductReplacementData, SetProductReplacementErrors, SetProductReplacementResponses, SetWebhookDestinationStatusData, SetWebhookDestinationStatusErrors, SetWebhookDestinationStatusResponses, SignUpData, SignUpErrors, SignUpResponses, SimulatePlacementDecisionData, SimulatePlacementDecisionErrors, SimulatePlacementDecisionResponses, SubmitSdkRestoreData, SubmitSdkRestoreErrors, SubmitSdkRestoreResponses, SubmitServerRestoreData, SubmitServerRestoreErrors, SubmitServerRestoreResponses, SubmitServerTransactionObservationData, SubmitServerTransactionObservationErrors, SubmitServerTransactionObservationResponses, SubmitTransactionObservationData, SubmitTransactionObservationErrors, SubmitTransactionObservationResponses, SyncCustomerEntitlementsData, SyncCustomerEntitlementsErrors, SyncCustomerEntitlementsResponses, SyncCustomerEntitlementsWithNegotiationData, SyncCustomerEntitlementsWithNegotiationErrors, SyncCustomerEntitlementsWithNegotiationResponses, TestProviderConnectionData, TestProviderConnectionErrors, TestProviderConnectionResponses, TestStoreServerCredentialData, TestStoreServerCredentialErrors, TestStoreServerCredentialResponses, TransitionExperimentLifecycleData, TransitionExperimentLifecycleErrors, TransitionExperimentLifecycleResponses, UpdateAnalyticsSettingsData, UpdateAnalyticsSettingsErrors, UpdateAnalyticsSettingsResponses, UpdateBillingSettingsData, UpdateBillingSettingsErrors, UpdateBillingSettingsResponses, UpdateEntitlementData, UpdateEntitlementErrors, UpdateEntitlementResponses, UpdateEnvironmentData, UpdateEnvironmentErrors, UpdateEnvironmentResponses, UpdateExperimentDraftData, UpdateExperimentDraftErrors, UpdateExperimentDraftResponses, UpdateMemberData, UpdateMemberErrors, UpdateMemberResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdatePaywallData, UpdatePaywallDraftData, UpdatePaywallDraftErrors, UpdatePaywallDraftResponses, UpdatePaywallErrors, UpdatePaywallResponses, UpdatePlacementData, UpdatePlacementErrors, UpdatePlacementResponses, UpdatePlacementRuleSetDraftData, UpdatePlacementRuleSetDraftErrors, UpdatePlacementRuleSetDraftResponses, UpdatePlanData, UpdatePlanErrors, UpdatePlanResponses, UpdateProductData, UpdateProductEntitlementGrantVersionData, UpdateProductEntitlementGrantVersionErrors, UpdateProductErrors, UpdateProductResponses, UpdateProjectData, UpdateProjectErrors, UpdateProjectResponses, UpdateWebhookDestinationData, UpdateWebhookDestinationErrors, UpdateWebhookDestinationResponses, UploadAssetData, UploadAssetErrors, UploadAssetResponses, ValidateExperimentDraftData, ValidateExperimentDraftResponses, ValidatePaywallDraftData, ValidatePaywallDraftErrors, ValidatePaywallDraftResponses, ValidatePlacementRuleSetData, ValidatePlacementRuleSetResponses } from './types.gen'; export type Options = Options2 & { /** @@ -2073,26 +2073,983 @@ export const submitServerTransactionObservation = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + key: 'CustomerAccessToken', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/sdk/billing/entitlements', + ...options +}); + +/** + * The conditional and negotiated form of the sync. The body is the contract's + * entitlementSyncRequest: supported contract versions, the snapshot version the caller + * already holds, its entity tag, and optionally the Entitlement keys to narrow to. + * + * billingCustomerId in the body is a hint only. The server derives the customer from the + * token and verifies the hint against it; a mismatch is refused rather than ignored, + * because silently ignoring it would let a client believe it had read a customer it had + * not. A caller can never widen access by asserting an identifier. + * + * Narrowing removes both entries and the sources no remaining entry references, so a + * narrowed snapshot never carries an orphan source. + * + * This is the ratified cross-SDK flow, and knownSnapshotVersion is the only conditional + * mechanism this surface has. A matching knownSnapshotVersion always answers 200 with the + * snapshotUnchanged record, never a bare 304, because the contract cannot guarantee + * freshness that lives only in undocumented headers. + * + */ +export const syncCustomerEntitlementsWithNegotiation = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + key: 'CustomerAccessToken', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/sdk/billing/entitlements', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Token metadata for one Billing Customer. No token value is ever returned, because Mosaic + * does not have one to return — only digests are stored. + * + */ +export const listCustomerAccessTokens = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/customer-tokens', + ...options +}); + +/** + * Mint a Customer Access Token for a customer the calling backend has already + * authenticated. This is the second consumer of secret server key authentication. + * + * The request carries no Project and no Environment: tenant scope comes entirely from the + * authenticated key, so a compromised or careless caller cannot mint a token into a tenant + * it does not own. + * + * The token is opaque — 256 bits of randomness behind an mcat_ prefix, with no claims and + * no parseable structure. Mosaic stores only its SHA-256 digest, so the value in this + * response is the only time it exists outside the caller's process: it is never logged, + * never stored, and never returned again. + * + * requestedTtlSeconds is a request, not an instruction. The default is one hour and the + * contract maximum is twenty-four; a caller may shorten a token's life and can never + * lengthen it past the maximum, which the schema enforces independently. + * + * Only the sdk_sync audience is issued in Phase 9B. server_check is declared by the + * contract so adding it later costs no contract version, and is refused with + * validation_failed until the surface it names exists. + * + */ +export const issueCustomerAccessToken = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/customer-tokens', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Revoke a token immediately. Revocation is one row update — the practical advantage of an + * opaque credential over a signed one is that there is nothing to wait out — and takes + * effect on the next presentation regardless of remaining lifetime. The revocation is + * audited. + * + * A token belonging to another tenant matches no row and is reported as not found rather + * than forbidden, so a caller cannot probe for the existence of another tenant's tokens. + * + */ +export const revokeCustomerAccessToken = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/customer-tokens/{tokenId}/revoke', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Report a completed native restore and the observations it produced. The public SDK key + * proves which Environment is asking and nothing more: the request names no customer, and + * `billingCustomerId` is dropped unconditionally on this surface. Identity is resolved + * server-side from validated store lineage, never from anything a client asserts. + * + * The body carries **observation submission ids**, not provider transaction references. A + * Google purchase-token digest is computable by anyone holding the token, so accepting + * caller-supplied digests would let a caller attach someone else's input to its own + * restore. + * + * `202` and the body already carries the honest current answer, which is what a caller + * polls against. `restored` is never reported until an accepted snapshot reflects it. + * + */ +export const submitSdkRestore = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/v1/sdk/billing/restores', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Poll one restore. The record carries two axes that are never merged: Mosaic's `outcome` + * and the native `providerOutcome`. A native restore that succeeded while Mosaic is still + * validating is `providerOutcome: completed` with `outcome: validation_pending`, which is + * the honest answer and the reason the two axes exist. + * + * Restore ids are 128-bit and server-minted. The read is Environment-scoped rather than + * customer-scoped, because a restore may have no customer yet. + * + */ +export const getSdkRestore = (options: Options): RequestResult => (options.client ?? client).get({ url: '/v1/sdk/billing/restores/{restoreId}', ...options }); + +/** + * The trusted equivalent of the SDK restore submission. This surface may name a + * `billingCustomerId`, because the caller is the application backend that already + * authenticated the user. + * + */ +export const submitServerRestore = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/restores', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const getServerRestore = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/restores/{restoreId}', + ...options +}); + +/** + * Create-or-get the Billing Customer for one of your users. This is one of exactly two ways + * a Billing Customer comes into existence (plan section 5a); the other is a validated + * purchase fact that needs somewhere to attach. SDK initialization and installation + * registration create nothing, which is what keeps Mosaic clear of the duplicate-customer + * trap that client-anchored systems fall into. + * + * The body accepts exactly one field, so a caller cannot smuggle an installation + * identifier, a Project, or an Environment into the creation path: the tenant comes from + * the authenticated secret key. Answers 201 when a customer was created and 200 when an + * existing one was returned. + * + */ +export const identifyBillingCustomer = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/identity/customers', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * A customer's alias history. No response in this family carries an alias value or an alias + * digest: a digest is still a stable per-person identifier and nothing on a server surface + * needs one. + * + */ +export const listBillingCustomerAliases = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/identity/customers/{customerId}/aliases', + ...options +}); + +/** + * Attach an application-user alias to an existing customer. Login attaches; it never merges + * (plan section 5a rule 3). When the alias already resolves to a different customer the + * result is 409 `identity_conflict`: an identity conflict is opened, the named customer is + * frozen, and an operator resolves it. Neither candidate is granted anything automatically + * (OD-10). + * + */ +export const attachBillingCustomerAlias = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/identity/customers/{customerId}/aliases', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * End-date an alias. The alias is the person-to-purchase link and therefore the erasable + * personal data in Mosaic Billing; the purchase evidence it pointed at is exempt and + * survives (see the privacy guide). Audited. + * + */ +export const revokeBillingCustomerAlias = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/identity/aliases/{aliasId}/revoke', + ...options +}); + +/** + * Schedule a projection for one customer. Deliberately not a read: the answer is "this has + * been queued", and a caller that needs the result reads the entitlement surfaces once the + * snapshot version moves. Triggers coalesce onto the customer scope, so a burst of requests + * produces one projection rather than a job storm. + * + */ +export const requestBillingCustomerSync = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/identity/customers/{customerId}/sync-requests', + ...options +}); + +/** + * Open identity conflicts awaiting operator resolution. A conflict freezes its disputed + * subject and grants neither candidate anything (OD-10). There is deliberately no automatic + * merge: automatic merge stays an ADR checkpoint, not something a heuristic reaches on its + * own. + * + */ +export const listBillingIdentityConflicts = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/identity/conflicts', + ...options +}); + +/** + * One conflict with the disputed Purchase Lineage, where the conflict is lineage-scoped. + * The disputed alias *type* is reported; the disputed alias digest is not rendered under + * any scope. + * + */ +export const getBillingIdentityConflict = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/identity/conflicts/{conflictId}', + ...options +}); + +/** + * Read one Billing Customer directly. The response carries no alias values: aliases are + * stored as SHA-256 digests and the digest is never a read-side field. + * + * `identified` distinguishes a customer an application backend has named from one anchored + * only to a purchase — the distinction an operator needs first when a customer list looks + * larger than the user base. + * + */ +export const getBillingCustomer = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/customers/{customerId}', + ...options +}); + +/** + * The customer's current Customer Entitlement Snapshot, as the contract record. The read is + * audited: an operator credential reading a named customer's entitlement state is exactly + * the access a later investigation needs to be able to reconstruct. + * + */ +export const getCustomerEntitlementSnapshot = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/customers/{customerId}/entitlements', + ...options +}); + +/** + * The focused multi-key access question, answered as the contract's entitlementCheckResult. + * + * The answer is never a bare boolean. Every requested key carries a state, a primary + * explanation, whether its end is known, and the contributing source count; the result + * carries the snapshot version, rule version, and asOf instant it was derived from. A + * caller that acts on the answer can say afterwards exactly which committed state it acted + * on, which a boolean makes impossible. + * + * This is the only surface on which `unavailable` is admissible for an Entitlement, and it + * means Mosaic could not answer — not that the customer lacks access. Billing being + * disabled for the Project is answered here with 200 and every key `unavailable`, rather + * than with an HTTP error, so a caller handles one shape. + * + */ +export const checkCustomerEntitlements = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/customers/{customerId}/entitlement-checks', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * The customer's projected Subscription Instances with their current four-axis state. + * Keyset-paginated; the cursor is opaque and carries one value. + * + */ +export const listCustomerSubscriptions = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/customers/{customerId}/subscriptions', + ...options +}); + +/** + * One Subscription Instance's current projected state as the contract's + * subscriptionSnapshot record: four state axes, every provider-derived effective + * timestamp, and a checksum over the canonical serialization. No provider status string is + * admissible anywhere in the record. + * + */ +export const getSubscriptionSnapshot = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/subscriptions/{instanceId}', + ...options +}); + +/** + * The append-only explanation history for one Subscription Instance. Entries restate what a + * provider said and when it took effect; detail passes the same ledger safety guard as the + * 9A ledger, so no provider payload fragment can appear here. + * + */ +export const listSubscriptionTimeline = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + key: 'ServerSecretKey', + scheme: 'bearer', + type: 'http' + }], + url: '/v1/billing/server/subscriptions/{instanceId}/timeline', + ...options +}); + +/** + * Read a Project's Mosaic Billing configuration. Owner or admin only. + * + * This is the first-class enablement read. Inferring enablement from the billing health + * endpoint answers a different question, costs several aggregate queries, and cannot + * distinguish "billing is off" from "billing is on and nothing has happened yet". + * + * `canDisable` and `activeCredentialCount` report the credential rule ahead of time, so a + * caller can explain why disabling is unavailable instead of discovering it through a 409. + * + */ +export const getBillingSettings = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/settings', + ...options +}); + /** * Enable or disable Mosaic Billing for a Project. Off by default. Owner or admin only. * - * Disabling is refused with `409 store_credentials_still_active` while any Store Server - * Credential is active. Disabling with a credential in place would not stop ingestion: Apple - * keeps posting to an endpoint whose intake token still resolves, and every refusal spends - * one of five non-renewable delivery attempts. Revoking the credential is what actually stops - * the store, so the switch requires it first and then means exactly what it says. + * Disabling is refused with `409 store_credentials_still_active` while any Store Server + * Credential is active. Disabling with a credential in place would not stop ingestion: Apple + * keeps posting to an endpoint whose intake token still resolves, and every refusal spends + * one of five non-renewable delivery attempts. Revoking the credential is what actually stops + * the store, so the switch requires it first and then means exactly what it says. + * + * While disabled, notification intake, the RTDN pull consumer, and the validation, + * reconciliation, and replay workers all skip the Project as defense in depth. + * + */ +export const updateBillingSettings = (options: Options): RequestResult => (options.client ?? client).put({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/settings', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Owner or admin only. Secret material is never returned, and neither is the notification endpoint URL. + */ +export const listStoreServerCredentials = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/store-credentials', + ...options +}); + +/** + * Store a new Store Server Credential. The secret (an Apple .p8 In-App Purchase key or a + * Google service-account JSON key) is parsed and validated before it is persisted, then + * sealed under the Mosaic keyring. For Apple, the response carries the full notification + * endpoint URL exactly once; it embeds an intake token stored only as SHA-256 and is + * never returned by any read. Owner or admin only. Audited. + * + */ +export const createStoreServerCredential = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/store-credentials', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const getStoreServerCredential = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}', + ...options +}); + +/** + * Replace the secret and mint a new intake token. The previous token stops resolving + * immediately, which is the point of rotating after a suspected compromise. The new + * endpoint URL is returned exactly once. Owner or admin only. Audited. + * + */ +export const rotateStoreServerCredential = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/rotate', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Stop the credential being used and clear its intake token so the notification endpoint + * stops resolving. Nothing already recorded is removed: the ledger is the evidence trail + * a revocation is usually part of investigating. Owner or admin only. Audited. + * + */ +export const revokeStoreServerCredential = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/revoke', + ...options +}); + +/** + * Prove the stored secret still authenticates against the store without changing any + * store state. Apple is tested with a one-minute Get Notification History window; Google + * with a zero-consumption Pub/Sub pull. Owner or admin only. Audited. + * + */ +export const testStoreServerCredential = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/test', + ...options +}); + +/** + * Validated, provider-independent Transaction Facts. A fact is never a subscription, an + * entitlement, or an access grant, and carries no customer identity, price, or currency. + * Mosaic Environment and Store Environment are always two separate values. + * + */ +export const listTransactionFacts = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/facts', + ...options +}); + +/** + * Append-only history of every validation try. Earlier attempts are never overwritten and no provider response body is ever stored. + */ +export const listValidationAttempts = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/validation-attempts', + ...options +}); + +/** + * Append-only operational Billing Event Ledger. There is deliberately no update endpoint. + */ +export const listBillingLedger = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/ledger', + ...options +}); + +/** + * Inputs and facts that cannot safely proceed. + */ +export const listBillingQuarantine = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/quarantine', + ...options +}); + +export const getBillingHealth = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/health', + ...options +}); + +/** + * Phase 9B projection health. A sibling of billing health rather than a field on it: billing + * health answers whether Mosaic can still turn store notifications into facts, while this + * answers whether the authoritative answer Mosaic gives about a customer's access is still + * current. Every value is a count or a timestamp; nothing here can carry a customer value, + * an alias digest, a provider token, or a secret. Owner or admin only. + * + */ +export const getBillingProjectionHealth = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/projection-health', + ...options +}); + +/** + * Recompute committed entitlement state from the immutable facts and report what moved. + * Replay is the operational expression of principle 2: a projection is derived state, so a + * corrupt checkpoint, a promoted rule version, or a mapping repair is answered by + * recomputing rather than by patching what was derived. It reuses the ordinary projection + * command, so replayed state goes through the same lock, compare-and-swap, and atomic + * commit as live projection, and prior snapshots are never deleted. + * + * The request must be **bounded** — one subscription instance, one customer, or a fact + * window. There is deliberately no "replay everything" member: an unbounded replay is a + * migration, and bulk migration tooling is out of Phase 9B. + * + * `projectionRuleVersion` selects the semantics. A version this build does not derive under + * is refused with 422 rather than recomputed under the active engine and labelled with the + * requested number, because a checksum produced by the wrong engine is indistinguishable + * from a genuine determinism result. + * + * Provider asymmetry, stated rather than hidden: Apple replay is input-sourced, because a + * stored Apple payload re-validates to the same transaction. Google replay is fact-sourced, + * because Google validation re-queries live provider state and a re-query today does not + * reproduce what the provider said last month. Owner or admin only. Audited. + * + */ +export const createBillingProjectionReplay = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/projection-replays', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * The Environment's Billing Customers, newest first. Owner or admin only, enforced + * server-side: this is the most sensitive read surface Mosaic has, because it names who + * bought what. + * + * `identified` and `purchaseAnchored` are separate booleans rather than one state, because + * the interesting customers are the ones where they disagree — a purchase-anchored customer + * who never identified is real revenue with no person attached, and an identified customer + * with no purchase is a person with no revenue. + * + * The Environment filter admits a customer holding a pointer or a lineage here, and + * additionally a customer holding a lineage in no Environment at all: a customer created by + * a trusted identify and not yet party to any purchase belongs to the Project and to no + * Environment, and hiding it everywhere would make a just-created customer invisible. + * + * No alias value and no alias digest appears anywhere in the response. + * + */ +export const listBillingCustomers = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customers', + ...options +}); + +/** + * Read-only typed-identifier search. It resolves at most one Billing Customer and is + * structurally incapable of creating one: the read model behind it declares no writer at + * all. That distinction matters — the trusted identify endpoint is create-or-get, and using + * it as a search would mint one Billing Customer per mistyped support query. + * + * `application_user_id` resolves through the active alias resolution; + * `installation_id` resolves through association evidence, because an installation + * identifier is evidence and never an anchor and therefore has no alias resolution to read. + * The submitted value is digested server-side and is never stored, never logged, and never + * echoed — which is also why this is a POST with a body rather than a GET with a query + * string that would reach access logs, proxy logs, and browser history. + * + * A miss answers `200` with `found: false` rather than `404`: "no customer holds this + * identifier" is a true answer to a support question. Rate limited. + * + */ +export const lookupBillingCustomer = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customer-lookups', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * One Billing Customer with everything the customer page shows: lifecycle, the Environment's + * current snapshot version and as-of instant, aliases as protected representations, purchase + * lineages, subscriptions, one-time purchases, identity conflicts, the current entitlement + * entries with their sources, and projection status. + * + * It is one read rather than eight so the page describes one instant. `currentSnapshot` is + * absent — not empty — when the customer has never been projected in this Environment: "no + * answer yet" and "no entitlements" are different states and stay different. + * + * Aliases carry `aliasId`, `aliasType`, authority, and validity dates. There is no value + * field and no digest field: the alias id is the protected representation, and an alias + * digest is still a stable per-person identifier. Owner or admin only. + * + */ +export const getOperatorBillingCustomer = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}', + ...options +}); + +/** + * The customer's current committed Customer Entitlement Snapshot in this Environment, read + * through the same repository the trusted-server access API reads through — so the dashboard + * and an application backend see one answer derived once. + * + * `snapshotVersion` is the per-customer monotonic cache-monotonicity key. The snapshot + * checksum is deliberately not on this surface: it is a determinism control the replay + * surface compares, and an operator reading it can only mistake it for a state. + * Owner or admin only. * - * While disabled, notification intake, the RTDN pull consumer, and the validation, - * reconciliation, and replay workers all skip the Project as defense in depth. + */ +export const getBillingCustomerEntitlementSnapshot = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}/entitlements', + ...options +}); + +/** + * The customer's projected Subscription Instances in this Environment, keyset-paginated. Owner or admin only. + */ +export const listBillingCustomerSubscriptions = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}/subscriptions', + ...options +}); + +/** + * Enqueue a recomputation of the customer's committed entitlement aggregate. It reaches the + * same enqueue the trusted-server surface does, so an operator's "sync now" and a backend's + * produce one job on one queue rather than two answers, and it computes nothing itself. + * + * It is deliberately not a restore: a restore needs a device to ask its store for purchases, + * which no operator can do on a customer's behalf, and a control claiming to would report a + * native outcome nobody produced. `202` — queued, not done. Rate limited. Audited. * */ -export const updateBillingSettings = (options: Options): RequestResult => (options.client ?? client).put({ +export const createBillingCustomerSyncRequest = (options: Options): RequestResult => (options.client ?? client).post({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/billing/settings', + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}/sync-requests', + ...options +}); + +/** + * One projected Subscription Instance. An instance belonging to another Environment is + * reported as absent rather than forbidden: a staging URL that happens to name a production + * instance must not confirm that the instance exists. Owner or admin only. + * + */ +export const getBillingSubscription = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/subscriptions/{instanceId}', + ...options +}); + +/** + * The append-only explanation history for one Subscription Instance, newest first. `detail` + * passes through the ledger guard function, which is what keeps a provider token or a raw + * payload fragment out of an explanation. Owner or admin only. + * + */ +export const listBillingSubscriptionTimeline = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/subscriptions/{instanceId}/timeline', + ...options +}); + +/** + * Restore and sync jobs in this Environment, newest first. Mosaic's `outcome` and the native + * `providerOutcome` are separate axes and are never merged: a completed native restore whose + * facts have not reached a snapshot is not restored access. Owner or admin only. + * + */ +export const listBillingRestoreJobs = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/restore-jobs', + ...options +}); + +/** + * One restore or sync job's status. Owner or admin only. + */ +export const getBillingRestoreJob = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/restore-jobs/{restoreId}', + ...options +}); + +/** + * Identity conflicts awaiting or holding an operator decision. They are Project-scoped, not + * Environment-scoped, and the route says so: a conflict is a dispute about who a person is, + * and identity in Mosaic belongs to the Project. Filing it under an Environment would imply + * it could be resolved differently in staging than in production. + * + * A conflict carries the disputed alias *family* and never the disputed alias digest. + * Owner or admin only. + * + */ +export const listOperatorBillingIdentityConflicts = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/identity-conflicts', + ...options +}); + +/** + * One conflict with the purchase lineage it disputes, which is what an operator needs before choosing a resolution. Owner or admin only. + */ +export const getOperatorBillingIdentityConflict = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/identity-conflicts/{conflictId}', + ...options +}); + +/** + * Apply an operator's decision and release the freeze. + * + * The three actions are the whole vocabulary. `keep_existing` awards the disputed subject to + * the incumbent; `reassign_to_candidate` awards it to the challenger the evidence proposed; + * `operator_split` awards it to neither — the operator has decided these are two people and + * the disputed link is removed rather than moved. There is deliberately no automatic-merge + * action: automatic merge remains an ADR checkpoint, not something a control reaches on its + * own. + * + * `reason` is required. Every action moves committed access for at least one paying + * customer, and the audit entry an investigation reads months later is worth nothing without + * the why. The reason is written to the conflict and to the audit event. + * + * Resolving unfreezes the disputed subject and reprojects **both** candidates, not only the + * assigned one: whichever customer loses the lineage is the one holding a committed snapshot + * that still grants it. `assignedBillingCustomerId` is optional and, when present, must name + * the party the action already implies — a resolution surface accepting an arbitrary + * customer would be an unaudited "give this purchase to anyone" control. + * + * Owner or admin only. Audited. + * + */ +export const resolveBillingIdentityConflict = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/identity-conflicts/{conflictId}/resolution', ...options, headers: { 'Content-Type': 'application/json', @@ -2101,33 +3058,66 @@ export const updateBillingSettings = (opti }); /** - * Owner or admin only. Secret material is never returned, and neither is the notification endpoint URL. + * The recorded history of what one Product grants, newest version first. + * + * Grant versions exist because the projection engine selects a version by the **purchase's + * own effective time**, not by "now". Reading the history is therefore how an operator + * answers "why is this customer entitled?" for a purchase made under a rule that has since + * been replaced. Any member of the owning organization may read it: an operator who can see + * a customer's entitlements but not the rule that produced them has been given a fact with + * no explanation. + * + * Intervals are half-open `[effectiveStart, effectiveEnd)` and abut exactly, so every + * instant is covered by at most one version per Entitlement. + * */ -export const listStoreServerCredentials = (options: Options): RequestResult => (options.client ?? client).get({ +export const listProductEntitlementGrantVersions = (options: Options): RequestResult => (options.client ?? client).get({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/billing/store-credentials', + url: '/v1/projects/{projectId}/billing/grant-versions', ...options }); /** - * Store a new Store Server Credential. The secret (an Apple .p8 In-App Purchase key or a - * Google service-account JSON key) is parsed and validated before it is persisted, then - * sealed under the Mosaic keyring. For Apple, the response carries the full notification - * endpoint URL exactly once; it embeds an intake token stored only as SHA-256 and is - * never returned by any read. Owner or admin only. Audited. + * Publish a new immutable grant version. This is the **only** call on this surface that + * changes what a Product grants, and it is deliberately separate from the impact preview: + * previewing is free and repeatable, publishing requires an actor, a `reason`, and an owner + * or admin role, and writes an audit event in the same transaction as the version. + * + * **Prospective by default (OD-8).** `effectiveStart` must be now or later. Publishing a + * change that silently applies to yesterday is the failure grant versioning exists to + * prevent, so backdating requires `retroactive: true` — and a retroactive version is then + * held to the additive-superset rule: it may add Entitlements or widen access policy, never + * remove or narrow either. Retroactive change is the one operation that can take access + * from a customer who did nothing wrong, so the only retroactive shape Mosaic accepts is the + * one that cannot. + * + * **Replacement, not edit.** Publishing closes the current version at exactly the new + * version's start, so the two intervals abut: never a gap (which would strand purchases made + * inside it with no applicable grant) and never an overlap (which would make the applicable + * version a function of row order). A proposal that reaches into an interval that has + * already closed is refused with `grant_interval_overlap`. + * + * **The change is applied, not just recorded.** The same transaction enqueues a reprojection + * for every Billing Customer whose current snapshot cites the Product. A grant version that + * is recorded but never applied is worse than one never published: every surface would + * report the new meaning while every customer kept the old access, and nothing would retry. + * + * `grantsInPaused` is accepted only so it can be refused: Google's pause never grants access + * and the policy is not overridable. `grantsInBillingRetry` contradicts both providers' + * documentation and requires an organization **owner**. * */ -export const createStoreServerCredential = (options: Options): RequestResult => (options.client ?? client).post({ +export const publishProductEntitlementGrantVersion = (options: Options): RequestResult => (options.client ?? client).post({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/billing/store-credentials', + url: '/v1/projects/{projectId}/billing/grant-versions', ...options, headers: { 'Content-Type': 'application/json', @@ -2135,29 +3125,108 @@ export const createStoreServerCredential = (options: Options): RequestResult => (options.client ?? client).get({ +/** + * Report what a proposed grant change would touch. **Nothing is written, including the audit + * trail**: an operator comparing three candidate policies before choosing one has not made + * three changes, and an audit trail suggesting otherwise would be worse than none. + * + * It takes the same body as the publish call, so an operator previews exactly what they are + * about to publish rather than something adjacent to it. + * + * Every count is from *current* committed state — the snapshot each customer's pointer names, + * not the whole snapshot history. Counting superseded snapshots would report a much larger + * number that no operator action can change, which is the worst possible combination for a + * confirmation dialog. `impactedEntitlements` and `impactedProducts` count everything a + * reprojection of the affected customers would re-derive, not only the pair being changed, + * because that is the actual blast radius of the confirmation being given. + * + * A preview never refuses a retroactive narrowing; it reports `additiveSuperset: false` with + * the `narrowingCode`, so the operator sees *why* the publish would be rejected before they + * attempt it. It is gated on the publish permission even though it writes nothing: the counts + * describe how much damage the change could do, and an actor who may not make the change has + * no reason to be shown the blast radius. + * + */ +export const previewProductEntitlementGrantImpact = (options: Options): RequestResult => (options.client ?? client).post({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}', + url: '/v1/projects/{projectId}/billing/grant-versions/impact-preview', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const getProductEntitlementGrantVersion = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/grant-versions/{grantVersionId}', ...options }); /** - * Replace the secret and mint a new intake token. The previous token stops resolving - * immediately, which is the point of rotating after a suspected compromise. The new - * endpoint URL is returned exactly once. Owner or admin only. Audited. + * Always answers `409 grant_version_immutable`. The route exists so the answer is a sentence + * an integrator can act on rather than a bare 405 that reads like a routing mistake. + * + * A published grant version is a historical fact: the projection engine selects it by the + * purchase's own effective time, so rewriting its policy or moving its boundary would change + * what a customer was entitled to at a moment that has already passed. The database enforces + * this too — the only permitted update to a grant version row is closing an open interval + * once — so no future repository method or migration can bypass it either. `PUT` and + * `DELETE` answer identically. * */ -export const rotateStoreServerCredential = (options: Options): RequestResult => (options.client ?? client).post({ +export const updateProductEntitlementGrantVersion = (options: Options): RequestResult => (options.client ?? client).patch({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/rotate', + url: '/v1/projects/{projectId}/billing/grant-versions/{grantVersionId}', + ...options +}); + +/** + * Application webhook destinations for one Environment. No response ever carries a signing secret. + */ +export const listWebhookDestinations = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/webhook-destinations', + ...options +}); + +/** + * Register a destination and mint its first signing secret. **The secret is in this + * response and in no other**: Mosaic keeps only the sealed form, so there is no read that + * returns it again. + * + * The URL is screened under the ADR-0024 SSRF policy at registration and again on every + * delivery attempt: HTTPS only, no redirects, and RFC1918, loopback, link-local (including + * the cloud metadata address), CGNAT, IPv6 unique-local, IPv4-mapped, and unspecified + * addresses refused against the *resolved* address. Operators running Mosaic and their + * application backend on one private network enable + * `MOSAIC_BILLING_WEBHOOK_ALLOW_PRIVATE_DESTINATIONS`; there is deliberately no + * per-destination override. + * + */ +export const createWebhookDestination = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/webhook-destinations', ...options, headers: { 'Content-Type': 'application/json', @@ -2166,99 +3235,172 @@ export const rotateStoreServerCredential = (options: Options): RequestResult => (options.client ?? client).post({ +export const deleteWebhookDestination = (options: Options): RequestResult => (options.client ?? client).delete({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/revoke', + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}', + ...options +}); + +export const getWebhookDestination = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}', ...options }); /** - * Prove the stored secret still authenticates against the store without changing any - * store state. Apple is tested with a one-minute Get Notification History window; Google - * with a zero-consumption Pub/Sub pull. Owner or admin only. Audited. + * Change the URL, the enabled event types, or the description. A changed URL is re-screened. + */ +export const updateWebhookDestination = (options: Options): RequestResult => (options.client ?? client).patch({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Pause, resume, or disable a destination. Mosaic also disables a destination automatically + * after a bounded run of consecutive exhausted deliveries; `autoDisableReason` distinguishes + * that from an operator's own action, so "did Mosaic disable this, or did a person?" is + * answerable without reading prose. * */ -export const testStoreServerCredential = (options: Options): RequestResult => (options.client ?? client).post({ +export const setWebhookDestinationStatus = (options: Options): RequestResult => (options.client ?? client).post({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/test', + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/status', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Signing-secret metadata. The secret values are sealed and are never returned. + */ +export const listWebhookSigningSecrets = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/secrets', ...options }); /** - * Validated, provider-independent Transaction Facts. A fact is never a subscription, an - * entitlement, or an access grant, and carries no customer identity, price, or currency. - * Mosaic Environment and Store Environment are always two separate values. + * Mint a new signing secret and start an overlap window. During the overlap every delivery + * carries one `v1` element per honoured secret, so a receiver that has adopted the new + * secret and one that has not both verify. `previousSecretHonoredUntil` is when the + * superseded secret stops signing, which is the whole information an integrator needs to + * schedule their own side. Without an overlap, rotating means a simultaneous change on both + * sides or a period of rejected deliveries, and an operator can achieve neither. + * + * The new secret is displayed once, here. Audited. * */ -export const listTransactionFacts = (options: Options): RequestResult => (options.client ?? client).get({ +export const rotateWebhookSigningSecret = (options: Options): RequestResult => (options.client ?? client).post({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/environments/{environmentId}/billing/facts', + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/secrets/rotate', ...options }); /** - * Append-only history of every validation try. Earlier attempts are never overwritten and no provider response body is ever stored. + * End a secret's overlap immediately — the response to a suspected compromise. Retiring the + * last secret that can still sign is refused: a destination with no signing secret would + * send unsigned deliveries, and an unsigned entitlement webhook is an unauthenticated + * instruction to grant access. Audited. + * */ -export const listValidationAttempts = (options: Options): RequestResult => (options.client ?? client).get({ +export const retireWebhookSigningSecret = (options: Options): RequestResult => (options.client ?? client).post({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/environments/{environmentId}/billing/validation-attempts', + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/secrets/{secretId}/retire', ...options }); /** - * Append-only operational Billing Event Ledger. There is deliberately no update endpoint. + * One delivery per (event, destination). A retry is a further attempt on the same delivery, never a new logical event, which is what keeps the event id stable for consumer deduplication. */ -export const listBillingLedger = (options: Options): RequestResult => (options.client ?? client).get({ +export const listWebhookDeliveries = (options: Options): RequestResult => (options.client ?? client).get({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/environments/{environmentId}/billing/ledger', + url: '/v1/projects/{projectId}/billing/webhook-deliveries', + ...options +}); + +export const getWebhookDelivery = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'mosaic_session', + type: 'apiKey' + }], + url: '/v1/projects/{projectId}/billing/webhook-deliveries/{deliveryId}', ...options }); /** - * Inputs and facts that cannot safely proceed. + * Append-only attempt history. It is API-facing only — it is never sent to a destination and + * a destination never sees another tenant's attempts — so it carries no URL and no secret. + * `responseExcerpt` is a bounded, control-character-free excerpt of the destination's own + * response, kept only so an integrator can see why their endpoint refused; it is never + * parsed and never influences Mosaic state. + * */ -export const listBillingQuarantine = (options: Options): RequestResult => (options.client ?? client).get({ +export const listWebhookDeliveryAttempts = (options: Options): RequestResult => (options.client ?? client).get({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/environments/{environmentId}/billing/quarantine', + url: '/v1/projects/{projectId}/billing/webhook-deliveries/{deliveryId}/attempts', ...options }); -export const getBillingHealth = (options: Options): RequestResult => (options.client ?? client).get({ +/** + * Re-queue an exhausted or failed delivery. It reuses the same delivery row and the same + * event id, appending a further attempt: a receiver deduplicating on event id still sees + * the change once. `202` — the delivery is queued, not performed; the worker owns the + * attempt. Audited. + * + */ +export const replayWebhookDelivery = (options: Options): RequestResult => (options.client ?? client).post({ security: [{ in: 'cookie', name: 'mosaic_session', type: 'apiKey' }], - url: '/v1/projects/{projectId}/environments/{environmentId}/billing/health', + url: '/v1/projects/{projectId}/billing/webhook-deliveries/{deliveryId}/replay', ...options }); diff --git a/apps/dashboard/src/generated/api/types.gen.ts b/apps/dashboard/src/generated/api/types.gen.ts index f1e98413..c06eb503 100644 --- a/apps/dashboard/src/generated/api/types.gen.ts +++ b/apps/dashboard/src/generated/api/types.gen.ts @@ -483,2361 +483,5193 @@ export type CreateReplayJobRequest = { validatorVersion?: number; }; -export type BillingHealth = { - environmentId?: string; - billingEnabled?: boolean; - credentialCount?: number; - unhealthyCredentials?: number; - queueDepth?: number; - oldestQueuedAgeSeconds?: number; - openQuarantineCount?: number; - factCount?: number; - lastFactRecordedAt?: string; - lastReconciliationAt?: string; +export type CustomerAccessTokenIssuanceRequest = { + customerAccessTokenContractVersion: '1'; + recordType: 'customerAccessTokenIssuanceRequest'; + payload: { + billingCustomerId: string; + /** + * Only sdk_sync is issued in Phase 9B. + */ + audience: 'sdk_sync' | 'server_check'; + scopes: Array<'entitlements.read' | 'entitlements.sync' | 'restore.request'>; + /** + * A request, not an instruction. Clamped to the contract maximum. + */ + requestedTtlSeconds?: number; + correlationId: string; + }; }; -export type CreateExperimentRequest = { - placementId: string; - name: string; - hypothesis?: string; +export type CustomerAccessTokenIssuanceResult = { + customerAccessTokenContractVersion: '1'; + recordType: 'customerAccessTokenIssuanceResult'; + payload: { + /** + * The opaque credential. Returned exactly once; Mosaic stores only its SHA-256 digest and can never reproduce it. + */ + token: string; + metadata: CustomerAccessTokenMetadata; + correlationId: string; + }; }; -export type ExperimentSchedule = { - startsAt: Timestamp; - endsAt?: Timestamp; +/** + * Everything Mosaic knows about a token, which is deliberately everything the token itself does not carry. + */ +export type CustomerAccessTokenMetadata = { + /** + * A stable public handle, safe to log and to name in an audit event. It is not the token and cannot be presented as one. + */ + tokenId: string; + projectId: string; + environmentId: string; + billingCustomerId: string; + audience: 'sdk_sync' | 'server_check'; + scopes: Array<'entitlements.read' | 'entitlements.sync' | 'restore.request'>; + issuer: string; + issuedAt: string; + expiresAt: string; + status: 'active' | 'expired' | 'revoked'; + revokedAt?: string; + revocationReason?: 'customer_signed_out' | 'identity_changed' | 'operator_revoked' | 'customer_deleted' | 'key_rotated' | 'suspected_compromise' | 'superseded_by_new_token'; + lastUsedAt?: string; + tokenPrefix: 'mcat_'; + digestAlgorithm: 'sha256'; }; -export type ExperimentVariantDraft = { - id?: string; - role: 'control' | 'treatment'; - name: string; - paywallId: string; - paywallVersionId: string; - allocationBasisPoints: number; +export type EntitlementSyncRequestRecord = { + authoritativeEntitlementContractVersion: '1'; + recordType: 'entitlementSyncRequest'; + payload: { + /** + * A hint only. Verified against the token; a mismatch is refused. + */ + billingCustomerId?: string; + knownSnapshotVersion?: number; + entityTag?: string; + supportedAuthoritativeEntitlementContracts: Array<'1'>; + requestedEntitlementKeys?: Array; + correlationId: string; + }; }; -export type ExperimentDraftDocument = { - variants: Array; - assignmentKeyPolicy: 'installation' | 'identified_user' | 'identified_user_or_installation'; - primaryMetricVersionId: string; - guardrailMetricVersionIds: Array; - schedule: ExperimentSchedule; - mutualExclusionGroupVersionId?: string; - qaPolicy: { - enabled: boolean; +/** + * The Authoritative Entitlement Contract v1 restoreRequest envelope. The normative shape is + * protocol/schema/authoritative-entitlement/v1/restore.schema.json; this declaration exists + * so the operation has a resolvable request schema and is deliberately not a second source + * of truth for the contract. + * + */ +export type RestoreRequestRecord = { + authoritativeEntitlementContractVersion: '1'; + recordType: 'restoreRequest'; + payload: { + storePlatform: 'apple_app_store' | 'google_play'; + /** + * What the native restore itself did. Recorded verbatim and never merged into Mosaic's own outcome. + */ + providerOutcome: 'completed' | 'no_purchases_found' | 'cancelled' | 'failed' | 'unsupported' | 'not_attempted'; + /** + * Observations already submitted for this restore. No provider transaction reference travels on this surface. + */ + observationSubmissionIds?: Array; + /** + * Honoured on the trusted surface only. The SDK surface drops it. + */ + billingCustomerId?: string; + correlationId: string; }; }; -export type UpdateExperimentDraftRequest = { - expectedRevision: number; - document: ExperimentDraftDocument; +/** + * The Authoritative Entitlement Contract v1 restoreResult envelope. Normative shape: + * protocol/schema/authoritative-entitlement/v1/restore.schema.json. + * + */ +export type RestoreResultRecord = { + authoritativeEntitlementContractVersion: '1'; + recordType: 'restoreResult'; + payload: { + restoreId?: string; + /** + * Absent while identity is unresolved + */ + billingCustomerId?: string; + projectId?: string; + environmentId?: string; + storePlatform?: 'apple_app_store' | 'google_play'; + /** + * Mosaic's own answer. `restored` is only ever written together with the accepted snapshot version that demonstrates it. + */ + outcome?: 'restored' | 'no_additional_purchases' | 'validation_pending' | 'identity_unresolved' | 'product_unresolved' | 'provider_unavailable' | 'failed'; + providerOutcome?: 'completed' | 'no_purchases_found' | 'cancelled' | 'failed' | 'unsupported' | 'not_attempted'; + /** + * The accepted snapshot that reflects the restore. Present only for `restored` + */ + snapshotVersion?: number; + /** + * Inputs actually linked + */ + observedTransactionCount?: number; + pendingValidationCount?: number; + uncertainty?: { + reason?: 'none' | 'provider_unavailable' | 'missing_fact' | 'identity_unresolved' | 'product_unresolved' | 'conflicting_facts' | 'projection_failed' | 'stale_validation' | 'unsupported_provider_state'; + since?: string; + diagnosticCode?: string; + }; + requestedAt?: string; + completedAt?: string; + correlationId?: string; + }; }; -export type PublishExperimentRequest = { - expectedRevision: number; +/** + * The Authoritative Entitlement Contract v1 customerEntitlementSnapshot envelope. The + * normative shape is protocol/schema/authoritative-entitlement/v1/snapshot.schema.json; + * this declaration exists so the operation has a resolvable response schema and is + * deliberately not a second source of truth for the contract. + * + */ +export type CustomerEntitlementSnapshotRecord = { + authoritativeEntitlementContractVersion: '1'; + recordType: 'customerEntitlementSnapshot' | 'snapshotUnchanged'; + payload: { + snapshotId?: string; + billingCustomerId?: string; + projectId?: string; + environmentId?: string; + /** + * Monotonic per (customer + */ + snapshotVersion?: number; + previousSnapshotVersion?: number; + projectionRuleVersion?: number; + issuedAt?: string; + asOf?: string; + refreshAfter?: string; + validUntil?: string; + staleGraceSeconds?: number; + entityTag?: string; + contentDigest?: string; + entries?: Array; + sources?: Array; + projectionStatus?: ProjectionStatus; + changeReason?: string; + correlationId?: string; + }; }; -export type ExperimentValidationIssue = { - code: string; - severity: 'error' | 'warning' | 'info'; - message: string; - resourceId?: string; - recoveryAction: string; +/** + * Product and Subscription Instance identity are deliberately absent; they live on the contributing source summaries, which sourceIds resolves against. + */ +export type EntitlementEntry = { + entitlementId: string; + entitlementKey: string; + /** + * `unavailable` is deliberately absent: it describes Mosaic's ability to answer, never the customer's access. + */ + state: 'active' | 'inactive' | 'unknown'; + effectiveStart?: string; + effectiveEnd?: string; + /** + * False means an active source has an uncertain end + */ + endKnown: boolean; + sourceIds: Array; + sourceCount: number; + primaryExplanation: PrimaryExplanation; + uncertainty?: Uncertainty; }; -export type ExperimentValidation = { - valid: boolean; - issues: Array; +export type EntitlementSourceSummary = { + /** + * Derived from (purchase lineage + */ + sourceId: string; + sourceType: 'active_subscription' | 'trial' | 'grace_period' | 'billing_retry' | 'one_time_non_consumable' | 'family_shared'; + subscriptionInstanceId?: string; + oneTimePurchaseInstanceId?: string; + mosaicProductId: string; + grantVersionId: string; + sourceSnapshotId: string; + storePlatform?: 'apple_app_store' | 'google_play'; + start: string; + /** + * Absent means this source has no finite end Mosaic can state. + */ + end?: string; + sourceState: 'granting' | 'not_granting' | 'unknown'; + uncertainty: Uncertainty; + explanationCode: string; + /** + * True for an Apple sandbox transaction or a Google Play license-tester purchase + */ + isTestSource: boolean; }; -export type ExperimentDraft = { - id: string; - revision: number; - status: 'active' | 'published' | 'superseded'; - document: ExperimentDraftDocument; - validation: ExperimentValidation; - updatedAt: Timestamp; +export type ProjectionStatus = { + state: 'current' | 'pending' | 'stale' | 'degraded' | 'failed'; + lastProjectedAt: string; + pendingFactCount?: number; + diagnosticCode?: string; }; -export type ExperimentVariantVersion = { - id: string; - role: 'control' | 'treatment'; - name: string; - paywallId: string; - paywallVersionId: string; - allocationStart: number; - allocationEnd: number; +/** + * Why a state is not definitive. reason `none` means the state is definitive, and a definitive state carries no since instant. + */ +export type Uncertainty = { + reason: 'none' | 'provider_unavailable' | 'missing_fact' | 'identity_unresolved' | 'product_unresolved' | 'conflicting_facts' | 'projection_failed' | 'stale_validation' | 'unsupported_provider_state'; + since?: string; + expectedResolution?: 'automatic_retry' | 'next_provider_notification' | 'next_projection_run' | 'operator_action' | 'customer_action' | 'none_expected'; + diagnosticCode?: string; }; -export type ExperimentVersion = { - id: string; - experimentId: string; - placementId: string; - versionNumber: number; - sourceRevision: number; - assignmentKeyPolicy: 'installation' | 'identified_user' | 'identified_user_or_installation'; - bucketingAlgorithm: 'experiment_sha256_length_prefixed_v1'; - allocationVersion: string; - variants: Array; - primaryMetricVersionId: string; - guardrailMetricVersionIds: Array; - schedule: ExperimentSchedule; - mutualExclusionGroupVersionId?: string; - publishedAt: Timestamp; +export type PrimaryExplanation = { + /** + * A member of the contract's closed explanation vocabulary. A reader may render its own copy for a code but must never invent one. + */ + code: string; + sourceId?: string; + safeSummary?: string; }; -export type Experiment = { - id: string; - projectId: string; - environmentId: string; - placementId: string; - name: string; - hypothesis?: string; - state: 'draft' | 'scheduled' | 'running' | 'paused' | 'stopped' | 'completed' | 'archived'; - currentDraft?: ExperimentDraft; - activeVersion?: ExperimentVersion; - role: 'owner' | 'admin' | 'member'; - permissions: Array<'read' | 'write' | 'publish' | 'lifecycle' | 'qa' | 'export'>; - createdAt: Timestamp; - updatedAt: Timestamp; - archivedAt?: Timestamp; +export type EntitlementCheckRequestRecord = { + authoritativeEntitlementContractVersion: '1'; + recordType: 'entitlementCheckRequest'; + payload: { + billingCustomerId: string; + entitlementKeys: Array; + expectedSnapshotVersion?: number; + supportedAuthoritativeEntitlementContracts: Array<'1'>; + correlationId: string; + }; }; -export type ExperimentMetricDefinition = { - id: string; - version: number; - name: string; - numeratorEvent: string; - denominatorEvent: string; - assignmentUnit: 'assignment_key'; - authority: string; - availability: 'available' | 'trusted_source_unavailable'; - eventFilter: { - 'payload.reason'?: 'provider_unavailable'; +export type EntitlementCheckResultRecord = { + authoritativeEntitlementContractVersion: '1'; + recordType: 'entitlementCheckResult'; + payload: { + billingCustomerId: string; + projectId: string; + environmentId: string; + /** + * Absent only when no snapshot could be read at all + */ + snapshotVersion?: number; + projectionRuleVersion?: number; + issuedAt: string; + asOf?: string; + results: Array<{ + entitlementKey: string; + /** + * This is the only place `unavailable` is admissible for an Entitlement: it says Mosaic could not answer, not that the customer lacks access. + */ + state: 'active' | 'inactive' | 'unknown' | 'unavailable'; + effectiveStart?: string; + effectiveEnd?: string; + endKnown: boolean; + sourceCount: number; + sourceIds?: Array; + primaryExplanation: PrimaryExplanation; + uncertainty?: Uncertainty; + isTestSource?: boolean; + }>; + projectionStatus?: ProjectionStatus; + correlationId: string; }; - attributionWindowSeconds: number; - freshnessSeconds: number; - definition: string; - primaryEligible: boolean; - guardrailEligible: boolean; }; -export type ExperimentGroup = { - id: string; - name: string; - status: 'active' | 'archived'; - activeVersionId?: string; - createdAt: Timestamp; +/** + * The Authoritative Entitlement Contract v1 subscriptionSnapshot envelope. The normative + * shape is protocol/schema/authoritative-entitlement/v1/subscription.schema.json. + * + */ +export type SubscriptionSnapshotRecord = { + authoritativeEntitlementContractVersion: '1'; + recordType: 'subscriptionSnapshot'; + payload: { + subscriptionSnapshotId?: string; + subscriptionInstanceId?: string; + purchaseLineageId?: string; + billingCustomerId?: string; + projectId?: string; + environmentId?: string; + projectionVersion?: number; + projectionRuleVersion?: number; + computedAt?: string; + asOf?: string; + storePlatform?: 'apple_app_store' | 'google_play'; + mosaicProductId?: string; + priorMosaicProductId?: string; + accessState?: 'active' | 'inactive' | 'unknown' | 'unavailable'; + lifecycleState?: 'trialing' | 'active' | 'grace_period' | 'billing_retry' | 'paused' | 'expired' | 'revoked' | 'refunded' | 'superseded' | 'unknown'; + renewalIntent?: 'auto_renew_enabled' | 'auto_renew_disabled' | 'provider_managed' | 'paused' | 'unknown'; + billingState?: 'current' | 'retrying' | 'grace' | 'failed' | 'refunded' | 'revoked' | 'unknown'; + uncertainty?: Uncertainty; + periodStart?: string; + periodEnd?: string; + gracePeriodEnd?: string; + billingRetryStart?: string; + pauseEffectiveAt?: string; + pauseResumeAt?: string; + /** + * When the provider recorded the cancellation. It changes renewalIntent; it does not end access. + */ + cancellationEffectiveAt?: string; + expirationEffectiveAt?: string; + revocationEffectiveAt?: string; + refundEffectiveAt?: string; + supersededBySubscriptionInstanceId?: string; + isTestSource?: boolean; + sourceFactCount?: number; + checksum?: string; + changeReason?: string; + explanationCode?: string; + correlationId?: string; + }; +}; + +export type SubscriptionSummary = { + subscriptionInstanceId?: string; + subscriptionSnapshotId?: string; + projectionVersion?: number; + accessState?: 'active' | 'inactive' | 'unknown'; + lifecycleState?: string; + renewalIntent?: string; + billingState?: string; + isTestSource?: boolean; + asOf?: string; +}; + +export type SubscriptionTimelineEntry = { + timelineEntryId?: string; + entryType?: string; + effectiveAt?: string; + observedAt?: string; + explanationCode?: string; + mosaicProductId?: string; + detail?: { + [key: string]: string; + }; }; -export type ExperimentGroupMemberInput = { +/** + * A Billing Customer as the trusted-server API reports it. Alias values never appear: aliases are stored as digests. + */ +export type BillingCustomer = { + billingCustomerId?: string; + projectId?: string; + status?: 'active' | 'frozen' | 'anonymized' | 'absorbed'; + diagnosticsStatus?: 'none' | 'identity_conflict' | 'projection_stale' | 'projection_failed'; + currentProjectionVersion?: number; + lastProjectedAt?: string; /** - * Stable Experiment root identifier. + * True when an application backend has named this customer. False means purchase-anchored but never identified. */ - experimentId: string; - allocationBasisPoints: number; -}; - -export type CreateExperimentGroupRequest = { - name: string; - assignmentKeyPolicy: 'installation' | 'identified_user' | 'identified_user_or_installation'; - members: Array; - holdoutBasisPoints: number; + identified?: boolean; + createdAt?: string; + updatedAt?: string; }; -export type CreateExperimentGroupVersionRequest = { - assignmentKeyPolicy: 'installation' | 'identified_user' | 'identified_user_or_installation'; - members: Array; - holdoutBasisPoints: number; +/** + * Per-Project Mosaic Billing configuration. Off by default. + */ +export type BillingSettings = { + projectId?: string; + billingEnabled?: boolean; + /** + * Active Store Server Credentials. Disabling is refused while this is above zero. + */ + activeCredentialCount?: number; + /** + * Whether a disable would be accepted right now. False while any Store Server Credential + * is active: revoking the credential is what actually stops the store delivering. + * + */ + canDisable?: boolean; + updatedAt?: string; }; -export type ExperimentGroupVersion = { - id: string; - groupId: string; - versionNumber: number; - assignmentKeyPolicy: string; - bucketingAlgorithm: string; - members: Array; - holdoutBasisPoints: number; - createdAt: Timestamp; +export type BillingHealth = { + environmentId?: string; + billingEnabled?: boolean; + credentialCount?: number; + unhealthyCredentials?: number; + queueDepth?: number; + oldestQueuedAgeSeconds?: number; + openQuarantineCount?: number; + factCount?: number; + lastFactRecordedAt?: string; + lastReconciliationAt?: string; }; -export type ExperimentGroupCreated = { - group: ExperimentGroup; - version: ExperimentGroupVersion; +export type IdentifyBillingCustomerRequest = { + /** + * Your own identifier for the user. Stored only as a domain-separated SHA-256 digest. + */ + applicationUserId: string; }; -export type ExperimentInterval = { - lower: number; - upper: number; +export type AttachBillingCustomerAliasRequest = { + applicationUserId: string; }; -export type ExperimentVariantResult = { - variantId: string; - role: string; - allocationBasisPoints: number; - uniqueExposures: number; - uniqueConversions: number; - estimate: number; - wilson95: ExperimentInterval; - rawExposureEvents: number; - fallbackPresentations: number; +export type BillingIdentityCustomer = { + billingCustomerId?: string; + projectId?: string; + status?: 'active' | 'frozen' | 'anonymized' | 'absorbed'; + diagnosticsStatus?: string; + currentProjectionVersion?: number; + createdAt?: string; + updatedAt?: string; + lastProjectedAt?: string; }; -export type ExperimentLift = { - treatmentVariantId: string; - absoluteLift: number; - newcombe95: ExperimentInterval; - relativeLift?: number; +/** + * An alias record. The alias digest is never a member of this shape. + */ +export type BillingCustomerAlias = { + aliasId?: string; + billingCustomerId?: string; + aliasType?: 'application_user_id' | 'installation_id' | 'apple_app_account_token' | 'google_obfuscated_account_id'; + sourceAuthority?: string; + verificationStatus?: string; + effectiveStart?: string; + effectiveEnd?: string; + createdAt?: string; }; -export type ExperimentSrm = { - status: 'insufficient_sample' | 'ok' | 'mismatch'; - severity: 'none' | 'warning' | 'critical'; - statistic: number; - degreesOfFreedom: number; - pValue: number; - cells: Array<{ - variantId: string; - observed: number; - expected: number; - observedShare: number; - expectedShare: number; - }>; - exclusions: Array; - explanation: string; - investigationSteps: Array; +export type BillingIdentityConflict = { + conflictId?: string; + projectId?: string; + scope?: 'lineage' | 'alias'; + status?: 'open' | 'resolved'; + /** + * The incumbent. + */ + firstCustomerId?: string; + /** + * The challenger. + */ + secondCustomerId?: string; + /** + * Present only for a lineage-scoped conflict. + */ + purchaseLineageId?: string; + /** + * Present only for an alias-scoped conflict. The digest is never reported. + */ + aliasType?: string; + diagnosticCode?: string; + openedAt?: string; + resolvedAt?: string; + resolutionAction?: 'assigned_first' | 'assigned_second' | 'detached_both'; }; -export type ExperimentGuardrailVariantResult = { - variantId: string; - role: 'control' | 'treatment'; - denominatorCount: number; - numeratorCount: number; - rate: number; +export type BillingIdentityConflictDetail = { + conflict?: BillingIdentityConflict; + /** + * The disputed Purchase Lineage, for a lineage-scoped conflict. + */ + lineage?: { + purchaseLineageId?: string; + environmentId?: string; + provider?: 'app_store' | 'google_play'; + storeEnvironment?: 'sandbox' | 'production'; + lineageType?: 'subscription' | 'one_time'; + projectionFrozen?: boolean; + diagnosticStatus?: string; + }; }; -export type ExperimentGuardrailMaturity = { - status: 'interim' | 'insufficient_sample' | 'mature'; - minimumVariantDenominator: number; - attributionWindowClosed: boolean; +export type BillingSyncRequest = { + billingCustomerId?: string; + projectId?: string; + environmentId?: string; + projectionScopeKey?: string; + triggerKind?: 'manual_sync'; + requestedAt?: string; + status?: 'queued'; }; /** - * Descriptive selected guardrail result. Warning means a mature Treatment rate is greater than Control; it never triggers an automatic action. + * Must be bounded by at least one of subscriptionInstanceId, billingCustomerId, or a complete window. */ -export type ExperimentGuardrailResult = { - metricVersionId: string; - name: string; - status: 'insufficient_data' | 'stale' | 'healthy' | 'warning'; - denominatorCount: number; - numeratorCount: number; - rate: number; - maturity: ExperimentGuardrailMaturity; - freshness?: Timestamp; - variants: Array; +export type CreateProjectionReplayRequest = { + subscriptionInstanceId?: string; + billingCustomerId?: string; + /** + * Bounds on facts rather than on lineage creation - a scope is in scope when it holds a fact whose effective or recorded time falls inside the window. + */ + windowStart?: string; + windowEnd?: string; + /** + * Zero selects the active version. A version this build does not derive under is refused rather than approximated. + */ + projectionRuleVersion?: number; + limit?: number; +}; + +export type ProjectionReplayResult = { + projectionRuleVersion?: number; + scopesReplayed?: number; + scopesChanged?: number; + outcomes?: Array<{ + projectionScopeKey?: string; + /** + * The whole point of a replay - proving determinism + */ + comparison?: 'unchanged' | 'changed'; + /** + * Whether a new snapshot was written. Materialization is changes-only + */ + materialized?: boolean; + changedEntitlementIds?: Array; + }>; }; /** - * Descriptive Experiment results. A winner, significance badge, and automatic action are intentionally absent. + * One immutable Product-to-Entitlement grant interval. Half-open - [effectiveStart, effectiveEnd). */ -export type ExperimentResults = { - experimentId: string; - experimentVersionId: string; - state: string; - interim: boolean; - variants: Array; - lifts: Array; - srm: ExperimentSrm; - freshness?: Timestamp; - warnings: Array; - guardrails: Array; +export type ProductEntitlementGrantVersion = { + grantVersionId?: string; + projectId?: string; + productId?: string; + productKey?: string; + entitlementId?: string; + entitlementKey?: string; + /** + * Monotonic per (Product + */ + version?: number; + /** + * The access-policy vocabulary this version was written under + */ + grantPolicyVersion?: number; + effectiveStart?: string; + /** + * Absent on the current version. Set once + */ + effectiveEnd?: string; + current?: boolean; + /** + * Derived from effectiveStart preceding createdAt - a version whose meaning began before it existed was backdated. + */ + retroactive?: boolean; + supportedPurchaseTypes?: Array<'auto_renewable_subscription' | 'non_consumable'>; + accessPolicy?: GrantAccessPolicy; + createdAt?: string; + createdByActorId?: string; + reason?: string; }; -export type ExperimentHistory = { - id: string; - fromState: string; - toState: string; - reason?: string; - releaseId?: string; - actorId: string; - createdAt: Timestamp; +/** + * Which subscription states this grant treats as granting access (plan policy version 1). + */ +export type GrantAccessPolicy = { + grantsInActive?: boolean; + grantsInTrial?: boolean; + /** + * Both providers grant access during grace + */ + grantsInGrace?: boolean; + /** + * Contradicts both providers' documentation. Closed by default and settable only by an organization owner. + */ + grantsInBillingRetry?: boolean; + /** + * Always false. Google's pause never grants access and the policy is not overridable. + */ + grantsInPaused?: boolean; + grantsInOneTimeOwnership?: boolean; }; -export type CreateExperimentQaOverrideRequest = { - experimentVersionId: string; - variantId: string; - identityType: 'installation' | 'identified_user'; - safeLabel: string; - expiresAt: Timestamp; +/** + * A proposed grant version. The same body is accepted by the impact preview and by publish, + * so what is previewed is what is published. Omitted policy flags take their documented + * defaults rather than false - a missing grantsInActive defaulting to false would publish a + * version granting nothing during an active subscription, the opposite of what an operator + * leaving the field out meant. + * + */ +export type PublishGrantVersionRequest = { + productId: string; + entitlementId: string; + /** + * Now or later unless retroactive is true. + */ + effectiveStart: string; + /** + * Backdate the change. Held to the additive-superset rule and confined to the currently open interval. + */ + retroactive?: boolean; + supportedPurchaseTypes?: Array<'auto_renewable_subscription' | 'non_consumable'>; + grantsInActive?: boolean; + grantsInTrial?: boolean; + grantsInGrace?: boolean; + grantsInBillingRetry?: boolean; + /** + * Accepted only so it can be refused with 422. + */ + grantsInPaused?: boolean; + grantsInOneTimeOwnership?: boolean; + /** + * Required to publish. It is what an investigation reads months later + */ + reason?: string; }; -export type CreateExperimentExportRequest = { - format: 'ndjson' | 'csv'; - includeIdentity: boolean; +/** + * Read-only counts of what a proposed grant change would touch, from current committed state. + */ +export type GrantVersionImpact = { + productId?: string; + entitlementId?: string; + /** + * Products a reprojection of the affected customers would re-derive. + */ + impactedProducts?: number; + /** + * Entitlements a reprojection of the affected customers would re-derive. + */ + impactedEntitlements?: number; + /** + * Billing Customers whose current snapshot cites this Product. + */ + impactedCustomers?: number; + /** + * How many of those citations are currently granting access - the number that answers how many people could lose access. + */ + impactedActiveSources?: number; + /** + * Purchase lineages resolved to this Product + */ + impactedLineages?: number; + retroactive?: boolean; + /** + * Whether the proposal passes the widen-only rule. Meaningful for a retroactive change; a preview reports it + */ + additiveSuperset?: boolean; + /** + * The first narrowing found when additiveSuperset is false + */ + narrowingCode?: string; + currentVersion?: ProductEntitlementGrantVersion; + observedAt?: string; }; -export type ExperimentQaOverride = { - id: string; - experimentVersionId: string; - variantId: string; - identityType: string; - safeLabel: string; - selectorDigest?: string; - status: 'active' | 'revoked' | 'expired'; - createdAt: Timestamp; - expiresAt: Timestamp; - revokedAt?: Timestamp; +export type WebhookDestination = { + id?: string; + projectId?: string; + environmentId?: string; + /** + * HTTPS only. Screened against the resolved address at registration and again on every delivery attempt. + */ + url?: string; + status?: 'active' | 'paused' | 'disabled'; + /** + * Phase 9B emits one event type. The other nine the contract declares are reserved names. + */ + eventTypes?: Array<'customer.entitlements.changed'>; + description?: string; + createdAt?: string; + updatedAt?: string; + secretLastRotatedAt?: string; + /** + * What an operator wrote when they disabled it by hand. + */ + disabledReason?: string; + /** + * Exhausted deliveries in a row. Any success resets it + */ + consecutiveFailureCount?: number; + autoDisabledAt?: string; + /** + * Set only by the automatic path + */ + autoDisableReason?: 'consecutive_exhausted_deliveries' | 'destination_refused'; }; -export type ExperimentQaOverrideCreated = { - override: ExperimentQaOverride; +export type WebhookDestinationWithSecret = { + destination?: WebhookDestination; /** - * Returned once and never persisted in plaintext. + * Displayed exactly once. Mosaic keeps only the sealed form */ - readonly token: string; + secret?: string; + secretId?: string; + /** + * Set by a rotation. Until this instant the superseded secret still signs. Absent on a create + */ + previousSecretHonoredUntil?: string; }; -export type ExperimentEnvelope = { - data: Experiment; +export type WebhookSigningSecretMetadata = { + id?: string; + status?: 'active' | 'retired'; + createdAt?: string; + retiredAt?: string; + /** + * A retired secret keeps signing until this instant + */ + honoredUntil?: string; }; -export type ExperimentDraftEnvelope = { - data: ExperimentDraft; +export type CreateWebhookDestinationRequest = { + url: string; + eventTypes?: Array<'customer.entitlements.changed'>; + description?: string; }; -export type ExperimentValidationEnvelope = { - data: ExperimentValidation; +export type UpdateWebhookDestinationRequest = { + url?: string; + eventTypes?: Array<'customer.entitlements.changed'>; + description?: string; }; -export type ExperimentVersionEnvelope = { - data: ExperimentVersion; +export type SetWebhookDestinationStatusRequest = { + status: 'active' | 'paused' | 'disabled'; + reason?: string; }; -export type ExperimentResultsEnvelope = { - data: ExperimentResults; +export type WebhookDelivery = { + id?: string; + projectId?: string; + environmentId?: string; + /** + * Stable across every attempt and every manual replay. It is the consumer's deduplication key. + */ + eventId?: string; + destinationId?: string; + status?: 'pending' | 'succeeded' | 'failed' | 'exhausted' | 'skipped'; + skippedReason?: 'destination_disabled' | 'event_type_not_enabled' | 'destination_deleted' | 'tenant_suspended'; + attemptCount?: number; + maxAttempts?: number; + nextAttemptAt?: string; + createdAt?: string; + updatedAt?: string; + completedAt?: string; }; -export type ExperimentListEnvelope = { - data: { - items: Array; - }; +/** + * One recorded try. Mirrors the Billing State Webhook Contract v1 webhookDeliveryAttempt + * record; the normative shape is protocol/schema/billing-state-webhook/v1/delivery.schema.json. + * + */ +export type WebhookDeliveryAttempt = { + id?: string; + deliveryId?: string; + eventId?: string; + destinationId?: string; + attempt?: number; + maxAttempts?: number; + outcome?: 'delivered' | 'retryable_failure' | 'permanent_failure' | 'exhausted' | 'skipped'; + responseStatusCode?: number; + /** + * Bounded and control-character-free. Never parsed + */ + responseExcerpt?: string; + errorCode?: string; + latencyMs?: number; + skippedReason?: 'destination_disabled' | 'event_type_not_enabled' | 'destination_deleted' | 'tenant_suspended'; + requestedAt?: string; + respondedAt?: string; + nextAttemptAt?: string; }; -export type ExperimentVersionListEnvelope = { - data: { - items: Array; - }; +export type BillingProjectionHealth = { + environmentId?: string; + billingEnabled?: boolean; + /** + * The rule version new projections are computed under. + */ + activeProjectionRuleVersion?: number; + /** + * A count above one with no replay in flight means a promotion was prepared and never run. + */ + projectionRuleVersionCount?: number; + projectionQueueDepth?: number; + /** + * The alerting signal. Depth alone cannot distinguish a busy queue from a stuck one. + */ + projectionOldestQueuedAgeSeconds?: number; + projectionFailedJobs?: number; + /** + * A rate signal the queue depth cannot give + */ + projectionFailuresLastHour?: number; + /** + * Customers whose committed state in this Environment is older than the staleness threshold. + */ + staleCustomers?: number; + neverProjectedCustomers?: number; + /** + * A spike here is a security-relevant signal + */ + openIdentityConflicts?: number; + frozenLineages?: number; + unresolvedLineages?: number; + /** + * Entries on current snapshots stating unknown - how often Mosaic is declining to answer. + */ + unknownEntitlementEntries?: number; + restoreBacklog?: number; + restoreFailedJobs?: number; + webhookDeliveryBacklog?: number; + webhookDeliveriesExhausted?: number; + activeWebhookDestinations?: number; + lastProjectionCommittedAt?: string; + observedAt?: string; }; -export type ExperimentHistoryListEnvelope = { - data: { - items: Array; - }; -}; - -export type ExperimentMetricListEnvelope = { - data: { - items: Array; - }; -}; - -export type ExperimentGroupListEnvelope = { - data: { - items: Array; - }; -}; - -export type ExperimentGroupCreatedEnvelope = { - data: ExperimentGroupCreated; -}; - -export type ExperimentGroupVersionEnvelope = { - data: ExperimentGroupVersion; +/** + * One Billing Customer as the operator list and the detail header report it. It carries no + * alias value and no alias digest, because Mosaic exposes neither on any surface. + * + */ +export type BillingCustomerSummary = { + billingCustomerId?: string; + projectId?: string; + environmentId?: string; + status?: 'active' | 'frozen' | 'anonymized' | 'absorbed'; + diagnosticsStatus?: 'none' | 'identity_conflict' | 'projection_stale' | 'projection_failed'; + /** + * An active application-user alias exists - a person is attached. + */ + identified?: boolean; + /** + * A purchase lineage exists in this Environment - revenue is attached. + */ + purchaseAnchored?: boolean; + hasOpenIdentityConflict?: boolean; + frozenLineageCount?: number; + currentProjectionVersion?: number; + lastProjectedAt?: string; + /** + * Absent when the customer has never been projected in this Environment, which is not the same as having no entitlements. + */ + snapshotVersion?: number; + snapshotUpdatedAt?: string; + createdAt?: string; + updatedAt?: string; }; -export type ExperimentGroupVersionListEnvelope = { - data: { - items: Array; - }; +export type BillingCustomerLookupRequest = { + identifierType: 'billing_customer_id' | 'application_user_id' | 'installation_id'; + /** + * The raw identifier. It is digested server-side and is never stored, never logged, and never echoed back. + */ + identifierValue: string; }; -export type ExperimentQaOverrideListEnvelope = { - data: { - items: Array; - }; +export type BillingCustomerLookupResult = { + found?: boolean; + customer?: BillingCustomerSummary; }; -export type ExperimentQaOverrideCreatedEnvelope = { - data: ExperimentQaOverrideCreated; +/** + * One accepted external identity bound to a customer, as a protected representation. There + * is no value field and no digest field: the alias id identifies the row for a revocation + * and reveals nothing about the person, whereas an alias digest is still a stable + * per-person identifier. + * + */ +export type OperatorBillingCustomerAlias = { + aliasId?: string; + aliasType?: 'application_user_id' | 'installation_id' | 'apple_app_account_token' | 'google_obfuscated_account_id'; + sourceAuthority?: 'trusted_server' | 'sdk_installation' | 'provider_payload' | 'operator' | 'restore'; + verificationStatus?: 'asserted' | 'verified'; + active?: boolean; + effectiveStart?: string; + effectiveEnd?: string; }; -export type AnalyticsEventBatch = { +/** + * One provider purchase chain. Supersession is an explicit edge; nothing is ever deleted. + */ +export type BillingPurchaseLineage = { + purchaseLineageId?: string; + environmentId?: string; + provider?: 'app_store' | 'google_play'; + storeEnvironment?: 'sandbox' | 'production'; + lineageType?: 'subscription' | 'one_time'; /** - * Must exactly equal every enclosed eventSchemaVersion. + * Set while an identity conflict is open - the projector skips it and the last committed state is preserved. */ - analyticsEventContractVersion: '1' | '2'; - batchId: string; - sentAt: Timestamp; - events: Array<{ - [key: string]: unknown; - }>; -}; - -export type AnalyticsEventResult = { - eventId: string; - status: 'accepted' | 'duplicate' | 'permanently_rejected' | 'retryable'; - code?: string; + projectionFrozen?: boolean; + diagnosticStatus?: 'none' | 'identity_unresolved' | 'identity_conflict' | 'product_unresolved'; + supersededByLineageId?: string; + createdAt?: string; + updatedAt?: string; }; -export type AnalyticsIngestionResult = { - analyticsEventContractVersion: '1' | '2'; - batchId: string; - receivedAt: Timestamp; - results: Array; +/** + * Validated ownership of a non-consumable. Consumables are excluded from Mosaic Billing. + */ +export type BillingOneTimePurchase = { + oneTimePurchaseInstanceId?: string; + purchaseLineageId?: string; + provider?: 'app_store' | 'google_play'; + mosaicProductId?: string; + providerProductIdentifier?: string; + acquiredAt?: string; + validityState?: 'owned' | 'refunded' | 'revoked' | 'unknown'; + refundEffectiveAt?: string; + revocationEffectiveAt?: string; }; -export type AnalyticsSettings = { - projectId: string; - environmentId: string; - collectionEnabled: boolean; - rawRetentionDays: number; - updatedAt: Timestamp; +export type BillingProjectionStatus = { + state?: 'current' | 'pending' | 'stale' | 'degraded' | 'failed'; + lastProjectedAt?: string; + pendingFactCount?: number; + diagnosticCode?: string; }; -export type UpdateAnalyticsSettingsRequest = { - collectionEnabled: boolean; - rawRetentionDays: number; +export type BillingEntitlementSnapshotEntry = { + entitlementId?: string; + entitlementKey?: string; + /** + * unavailable is a read-time service state and is never persisted on a snapshot. + */ + state?: 'active' | 'inactive' | 'unknown'; + effectiveStart?: string; + effectiveEnd?: string; + endKnown?: boolean; + sourceCount?: number; + uncertaintyReason?: 'none' | 'provider_unavailable' | 'missing_fact' | 'identity_unresolved' | 'product_unresolved' | 'conflicting_facts' | 'projection_failed' | 'stale_validation' | 'unsupported_provider_state'; + isTestSource?: boolean; + explanationCode?: string; + sourceIds?: Array; }; -export type AnalyticsFreshness = { - latestReceivedAt?: Timestamp; - latestAggregatedAt?: Timestamp; - lateEventPolicy: string; +/** + * One reason the customer holds, or may hold, an Entitlement. Source identity is + * (purchase lineage, Mosaic Product, grant version) and never a fact id, so multiple facts + * describing one purchase cannot double-grant. + * + */ +export type BillingEntitlementSource = { + sourceId?: string; + entitlementId?: string; + purchaseLineageId?: string; + mosaicProductId?: string; + grantVersionId?: string; + subscriptionInstanceId?: string; + oneTimePurchaseInstanceId?: string; + storePlatform?: string; + sourceType?: string; + sourceState?: string; + sourceStart?: string; + sourceEnd?: string; + endKnown?: boolean; + uncertaintyReason?: string; + isTestSource?: boolean; + explanationCode?: string; +}; + +export type BillingEntitlementSnapshot = { + snapshotId?: string; + /** + * Per-customer monotonic. It is the sole cache-monotonicity key; a no-change projection does not advance it. + */ + snapshotVersion?: number; + previousSnapshotVersion?: number; + projectionRuleVersion?: number; + computedAt?: string; + asOf?: string; + changeReason?: string; + entries?: Array; + sources?: Array; }; -export type AnalyticsMetric = { - id: string; - value?: number; - numerator: number; - denominator?: number; - basis: 'event_count'; - authority: 'client_observed' | 'trusted_server' | 'provider_confirmed'; - attributionWindow: '24h'; - timezone: string; - definition: string; - warnings?: Array; - dimensions?: { +/** + * One projected Subscription Instance. Access and lifecycle are separate axes on purpose: + * a cancelled subscription keeps access until its validated period end, so cancellation + * moves renewal intent and nothing else. + * + */ +export type BillingSubscriptionSnapshot = { + subscriptionInstanceId?: string; + purchaseLineageId?: string; + billingCustomerId?: string; + environmentId?: string; + storePlatform?: string; + mosaicProductId?: string; + priorMosaicProductId?: string; + /** + * unavailable is a read-time service state and is never persisted on a snapshot. + */ + accessState?: 'active' | 'inactive' | 'unknown'; + lifecycleState?: 'trialing' | 'active' | 'grace_period' | 'billing_retry' | 'paused' | 'expired' | 'revoked' | 'refunded' | 'superseded' | 'unknown'; + renewalIntent?: string; + billingState?: string; + uncertaintyReason?: string; + projectionVersion?: number; + projectionRuleVersion?: number; + computedAt?: string; + asOf?: string; + periodStart?: string; + periodEnd?: string; + gracePeriodEnd?: string; + billingRetryStart?: string; + pauseEffectiveAt?: string; + pauseResumeAt?: string; + cancellationEffectiveAt?: string; + expirationEffectiveAt?: string; + revocationEffectiveAt?: string; + refundEffectiveAt?: string; + supersededBySubscriptionInstanceId?: string; + isTestSource?: boolean; + sourceFactCount?: number; + changeReason?: string; + explanationCode?: string; +}; + +export type BillingTimelineEntry = { + timelineEntryId?: string; + entryType?: string; + effectiveAt?: string; + observedAt?: string; + subscriptionInstanceId?: string; + mosaicProductId?: string; + priorMosaicProductId?: string; + explanationCode?: string; + /** + * Passed through the ledger guard function, so no provider token or raw payload fragment can appear here. + */ + detail?: { [key: string]: string; }; }; -export type AnalyticsResult = { - metrics: Array; - freshness: AnalyticsFreshness; -}; - -export type CreateAnalyticsEventExportRequest = { - from: Timestamp; - to: Timestamp; - format: 'ndjson' | 'csv'; +export type BillingCustomerDetail = { + customer?: BillingCustomerSummary; + aliases?: Array; + purchaseLineages?: Array; + subscriptions?: Array; + oneTimePurchases?: Array; + identityConflicts?: Array; + currentSnapshot?: BillingEntitlementSnapshot; + projectionStatus?: BillingProjectionStatus; }; -export type AnalyticsIdentityRequest = { - kind: 'application_user' | 'installation'; +/** + * One disputed association held open for operator resolution. Access is granted to neither + * candidate while it is open: the disputed subject is frozen and the last committed state is + * preserved. The disputed alias digest is never part of this shape. + * + */ +export type OperatorBillingIdentityConflict = { + conflictId?: string; + projectId?: string; + scope?: 'lineage' | 'alias'; + status?: 'open' | 'resolved'; + purchaseLineageId?: string; + aliasType?: string; /** - * Opaque identifier. Application-user values accept non-control Unicode and punctuation but sensitive-shaped values are rejected. + * The incumbent. */ - identity: string; + firstCustomerId?: string; + /** + * The challenger the evidence proposed. + */ + secondCustomerId?: string; + diagnosticCode?: 'multiple_customers_claim_lineage' | 'reassignment_requires_operator_resolution' | 'application_user_alias_claims_two_customers'; + openedAt?: string; + resolvedAt?: string; + resolutionAction?: 'keep_existing' | 'reassign_to_candidate' | 'operator_split'; + resolutionReason?: string; }; -export type CreateAnalyticsPrivacyExportRequest = AnalyticsIdentityRequest & { - format: 'ndjson' | 'csv'; +export type OperatorBillingIdentityConflictDetail = { + conflict?: OperatorBillingIdentityConflict; + lineage?: BillingPurchaseLineage; }; -export type CreateAnalyticsPrivacyDeletionRequest = AnalyticsIdentityRequest & { - requestDigest: string; - confirm: true; +export type ResolveIdentityConflictRequest = { + action: 'keep_existing' | 'reassign_to_candidate' | 'operator_split'; + /** + * Optional. When present it must name the party the action already implies. + */ + assignedBillingCustomerId?: string; + /** + * Required. Recorded on the conflict and on the audit event. + */ + reason: string; }; -export type AnalyticsPrivacyPreview = { - kind: 'application_user' | 'installation'; - affectedEvents: number; - affectedSessions: number; - affectedEnvironmentIds: Array; - requestDigest: string; +export type OperatorBillingSyncRequest = { + billingCustomerId?: string; + projectId?: string; + environmentId?: string; + /** + * What the projection queue coalesces on. + */ + projectionScopeKey?: string; + triggerKind?: string; + requestedAt?: string; + status?: 'queued'; }; -export type AnalyticsJob = { - id: string; - kind: 'events' | 'application_user' | 'installation'; - status: 'queued' | 'leased' | 'recomputing' | 'completed' | 'failed' | 'expired'; - format?: 'ndjson' | 'csv'; - rowCount?: number; - byteLength?: number; - affectedEventCount?: number; - affectedSessionCount?: number; - expiresAt?: Timestamp; - createdAt: Timestamp; - updatedAt: Timestamp; +/** + * One restore or sync job. Mosaic's authoritative `outcome` and the native `providerOutcome` + * are separate axes and are never merged: a completed native restore whose facts have not + * reached a snapshot is not restored access. `restored` is admissible only together with the + * accepted `snapshotVersion` that demonstrates it. + * + */ +export type BillingRestoreJob = { + restoreId?: string; + environmentId?: string; + /** + * Empty until identity resolves - which is exactly the identity_unresolved outcome. + */ + billingCustomerId?: string; + storePlatform?: 'apple_app_store' | 'google_play'; + status?: 'queued' | 'leased' | 'completed' | 'failed'; + outcome?: 'restored' | 'no_additional_purchases' | 'validation_pending' | 'identity_unresolved' | 'product_unresolved' | 'provider_unavailable' | 'failed'; + providerOutcome?: 'completed' | 'no_purchases_found' | 'cancelled' | 'failed' | 'unsupported' | 'not_attempted'; + uncertaintyReason?: string; + observedTransactionCount?: number; + pendingValidationCount?: number; + baselineSnapshotVersion?: number; + snapshotVersion?: number; + attemptCount?: number; + maxAttempts?: number; + requestedAt?: string; + updatedAt?: string; + completedAt?: string; }; -export type AnalyticsSettingsEnvelope = { - data: AnalyticsSettings; +export type CreateExperimentRequest = { + placementId: string; + name: string; + hypothesis?: string; }; -export type AnalyticsResultEnvelope = { - data: AnalyticsResult; +export type ExperimentSchedule = { + startsAt: Timestamp; + endsAt?: Timestamp; }; -export type AnalyticsPrivacyPreviewEnvelope = { - data: AnalyticsPrivacyPreview; +export type ExperimentVariantDraft = { + id?: string; + role: 'control' | 'treatment'; + name: string; + paywallId: string; + paywallVersionId: string; + allocationBasisPoints: number; }; -export type AnalyticsJobEnvelope = { - data: AnalyticsJob; +export type ExperimentDraftDocument = { + variants: Array; + assignmentKeyPolicy: 'installation' | 'identified_user' | 'identified_user_or_installation'; + primaryMetricVersionId: string; + guardrailMetricVersionIds: Array; + schedule: ExperimentSchedule; + mutualExclusionGroupVersionId?: string; + qaPolicy: { + enabled: boolean; + }; }; -export type PlacementDecisionDocumentRequest = { - document: PlacementDecisionDocument; +export type UpdateExperimentDraftRequest = { + expectedRevision: number; + document: ExperimentDraftDocument; }; -/** - * Canonical Placement Decision v1 envelope; see protocol/schema/placement-decision/v1/decision.schema.json. - */ -export type PlacementDecisionDocument = { - placementDecisionVersion: '1'; - ruleSet: { - [key: string]: unknown; - }; - [key: string]: unknown; +export type PublishExperimentRequest = { + expectedRevision: number; }; -export type PlacementValidationIssue = { - severity: 'error' | 'warning'; +export type ExperimentValidationIssue = { code: string; - ruleId?: string; - conditionPath?: string; - resourceType?: string; + severity: 'error' | 'warning' | 'info'; + message: string; resourceId?: string; recoveryAction: string; }; -export type PlacementValidation = { +export type ExperimentValidation = { valid: boolean; - issues: Array; + issues: Array; }; -export type PlacementRuleSet = { +export type ExperimentDraft = { id: string; - projectId: string; - environmentId: string; - placementId: string; - contractVersion: '1'; - status: 'active' | 'archived'; - currentDraftId?: string; - currentPublishedVersionId?: string; - createdByActorId: string; - createdAt: string; - updatedAt: string; - archivedAt?: string; -}; - -export type PlacementRuleSetDraft = { - id: string; - ruleSetId: string; - projectId: string; - environmentId: string; - status: 'active' | 'published' | 'superseded'; revision: number; - sourceVersionId?: string; - createdByActorId: string; - updatedByActorId: string; - createdAt: string; - updatedAt: string; + status: 'active' | 'published' | 'superseded'; + document: ExperimentDraftDocument; + validation: ExperimentValidation; + updatedAt: Timestamp; }; -export type PlacementRuleSetDraftResource = { - ruleSet: PlacementRuleSet; - draft: PlacementRuleSetDraft; - document: PlacementDecisionDocument; - validation: PlacementValidation; +export type ExperimentVariantVersion = { + id: string; + role: 'control' | 'treatment'; + name: string; + paywallId: string; + paywallVersionId: string; + allocationStart: number; + allocationEnd: number; }; -export type PlacementRuleSetVersion = { +export type ExperimentVersion = { id: string; - ruleSetId: string; - projectId: string; - environmentId: string; + experimentId: string; placementId: string; versionNumber: number; - sourceDraftId: string; sourceRevision: number; - contractVersion: '1'; - document: PlacementDecisionDocument; - documentHash: string; - validation: PlacementValidation; - publishedByActorId: string; - publishedAt: string; + assignmentKeyPolicy: 'installation' | 'identified_user' | 'identified_user_or_installation'; + bucketingAlgorithm: 'experiment_sha256_length_prefixed_v1'; + allocationVersion: string; + variants: Array; + primaryMetricVersionId: string; + guardrailMetricVersionIds: Array; + schedule: ExperimentSchedule; + mutualExclusionGroupVersionId?: string; + publishedAt: Timestamp; }; -export type PlacementAttribute = { +export type Experiment = { id: string; projectId: string; - key: string; - type: 'string' | 'boolean' | 'number' | 'timestamp' | 'semantic_version' | 'string_list'; - description: string; - allowedOperators: Array; - sensitivity: 'standard' | 'sensitive'; - status: 'active' | 'archived'; - revision: number; - createdByActorId: string; - updatedByActorId: string; - createdAt: string; - updatedAt: string; - archivedAt?: string; + environmentId: string; + placementId: string; + name: string; + hypothesis?: string; + state: 'draft' | 'scheduled' | 'running' | 'paused' | 'stopped' | 'completed' | 'archived'; + currentDraft?: ExperimentDraft; + activeVersion?: ExperimentVersion; + role: 'owner' | 'admin' | 'member'; + permissions: Array<'read' | 'write' | 'publish' | 'lifecycle' | 'qa' | 'export'>; + createdAt: Timestamp; + updatedAt: Timestamp; + archivedAt?: Timestamp; }; -export type CreatePlacementAttributeRequest = { - key: string; - type: string; - description?: string; - allowedOperators: Array; - sensitivity: 'standard' | 'sensitive'; +export type ExperimentMetricDefinition = { + id: string; + version: number; + name: string; + numeratorEvent: string; + denominatorEvent: string; + assignmentUnit: 'assignment_key'; + authority: string; + availability: 'available' | 'trusted_source_unavailable'; + eventFilter: { + 'payload.reason'?: 'provider_unavailable'; + }; + attributionWindowSeconds: number; + freshnessSeconds: number; + definition: string; + primaryEligible: boolean; + guardrailEligible: boolean; }; -export type PlacementAlias = { +export type ExperimentGroup = { id: string; - projectId: string; - placementId: string; - key: string; + name: string; status: 'active' | 'archived'; - createdByActorId: string; - createdAt: string; - archivedAt?: string; -}; - -export type PlacementUsage = { - ruleSetCount: number; - aliasCount: number; - publishedRuleCount: number; + activeVersionId?: string; + createdAt: Timestamp; }; -export type PlacementOutcome = { - type: 'paywall' | 'no_paywall' | 'fallback' | 'unavailable'; - paywallVersionId?: string; - key?: string; - unavailableFallbackKey?: string; - reason?: string; +export type ExperimentGroupMemberInput = { + /** + * Stable Experiment root identifier. + */ + experimentId: string; + allocationBasisPoints: number; }; -export type QaOverride = { - id: string; - projectId: string; - environmentId: string; - placementId: string; - safeLabel: string; - outcome: PlacementOutcome; - status: 'active' | 'revoked' | 'expired'; - createdByActorId: string; - createdAt: string; - expiresAt: string; - revokedAt?: string; +export type CreateExperimentGroupRequest = { + name: string; + assignmentKeyPolicy: 'installation' | 'identified_user' | 'identified_user_or_installation'; + members: Array; + holdoutBasisPoints: number; }; -export type CreateQaOverrideRequest = { - safeLabel: string; - outcome: PlacementOutcome; - expiresAt: string; +export type CreateExperimentGroupVersionRequest = { + assignmentKeyPolicy: 'installation' | 'identified_user' | 'identified_user_or_installation'; + members: Array; + holdoutBasisPoints: number; }; -export type QaOverrideCreated = { - override: QaOverride; - /** - * Returned once and never persisted in plaintext. - */ - token: string; +export type ExperimentGroupVersion = { + id: string; + groupId: string; + versionNumber: number; + assignmentKeyPolicy: string; + bucketingAlgorithm: string; + members: Array; + holdoutBasisPoints: number; + createdAt: Timestamp; }; -export type PlacementSimulationRequest = { - platform?: 'ios' | 'android'; - osVersion?: string; - applicationVersion?: string; - locale?: string; - country?: string; - entitlements?: { - [key: string]: unknown; - }; - productAvailability?: { - [key: string]: unknown; - }; - productReadiness?: { - [key: string]: unknown; - }; - providerCapabilities?: { - [key: string]: unknown; - }; +export type ExperimentGroupCreated = { + group: ExperimentGroup; + version: ExperimentGroupVersion; }; -export type PlacementSimulationResult = { - winningRuleId?: string; - selectedOutcome: PlacementOutcome; - finalOutcome: PlacementOutcome; - fallbackPath: Array; - assignmentKeyType?: string; - rolloutBucket?: number; - trace: Array<{ - [key: string]: unknown; - }>; +export type ExperimentInterval = { + lower: number; + upper: number; }; -export type PlacementRuleSetDraftEnvelope = { - data: PlacementRuleSetDraftResource; +export type ExperimentVariantResult = { + variantId: string; + role: string; + allocationBasisPoints: number; + uniqueExposures: number; + uniqueConversions: number; + estimate: number; + wilson95: ExperimentInterval; + rawExposureEvents: number; + fallbackPresentations: number; }; -export type PlacementValidationEnvelope = { - data: PlacementValidation; +export type ExperimentLift = { + treatmentVariantId: string; + absoluteLift: number; + newcombe95: ExperimentInterval; + relativeLift?: number; }; -export type PlacementRuleSetVersionEnvelope = { - data: PlacementRuleSetVersion; +export type ExperimentSrm = { + status: 'insufficient_sample' | 'ok' | 'mismatch'; + severity: 'none' | 'warning' | 'critical'; + statistic: number; + degreesOfFreedom: number; + pValue: number; + cells: Array<{ + variantId: string; + observed: number; + expected: number; + observedShare: number; + expectedShare: number; + }>; + exclusions: Array; + explanation: string; + investigationSteps: Array; }; -export type PlacementRuleSetVersionListEnvelope = { - data: { - items: Array; - }; +export type ExperimentGuardrailVariantResult = { + variantId: string; + role: 'control' | 'treatment'; + denominatorCount: number; + numeratorCount: number; + rate: number; }; -export type PlacementAttributeEnvelope = { - data: PlacementAttribute; +export type ExperimentGuardrailMaturity = { + status: 'interim' | 'insufficient_sample' | 'mature'; + minimumVariantDenominator: number; + attributionWindowClosed: boolean; }; -export type PlacementAttributeListEnvelope = { - data: { - items: Array; - }; +/** + * Descriptive selected guardrail result. Warning means a mature Treatment rate is greater than Control; it never triggers an automatic action. + */ +export type ExperimentGuardrailResult = { + metricVersionId: string; + name: string; + status: 'insufficient_data' | 'stale' | 'healthy' | 'warning'; + denominatorCount: number; + numeratorCount: number; + rate: number; + maturity: ExperimentGuardrailMaturity; + freshness?: Timestamp; + variants: Array; }; -export type PlacementAliasEnvelope = { - data: PlacementAlias; +/** + * Descriptive Experiment results. A winner, significance badge, and automatic action are intentionally absent. + */ +export type ExperimentResults = { + experimentId: string; + experimentVersionId: string; + state: string; + interim: boolean; + variants: Array; + lifts: Array; + srm: ExperimentSrm; + freshness?: Timestamp; + warnings: Array; + guardrails: Array; }; -export type PlacementAliasListEnvelope = { - data: { - items: Array; - }; +export type ExperimentHistory = { + id: string; + fromState: string; + toState: string; + reason?: string; + releaseId?: string; + actorId: string; + createdAt: Timestamp; }; -export type PlacementUsageEnvelope = { - data: PlacementUsage; +export type CreateExperimentQaOverrideRequest = { + experimentVersionId: string; + variantId: string; + identityType: 'installation' | 'identified_user'; + safeLabel: string; + expiresAt: Timestamp; }; -export type QaOverrideCreatedEnvelope = { - data: QaOverrideCreated; +export type CreateExperimentExportRequest = { + format: 'ndjson' | 'csv'; + includeIdentity: boolean; }; -export type QaOverrideListEnvelope = { - data: { - items: Array; - }; +export type ExperimentQaOverride = { + id: string; + experimentVersionId: string; + variantId: string; + identityType: string; + safeLabel: string; + selectorDigest?: string; + status: 'active' | 'revoked' | 'expired'; + createdAt: Timestamp; + expiresAt: Timestamp; + revokedAt?: Timestamp; }; -export type PlacementSimulationEnvelope = { - data: PlacementSimulationResult; +export type ExperimentQaOverrideCreated = { + override: ExperimentQaOverride; + /** + * Returned once and never persisted in plaintext. + */ + readonly token: string; }; -export type Timestamp = string; - -export type Page = { - nextCursor?: string; +export type ExperimentEnvelope = { + data: Experiment; }; -export type Role = 'owner' | 'admin' | 'member'; - -export type ProjectStatus = 'active' | 'archived'; - -export type Platform = 'ios' | 'android'; - -export type ApiKeyKind = 'public_sdk' | 'secret_server'; - -export type ProductType = 'subscription' | 'one_time_non_consumable'; - -export type ProductStatus = 'draft' | 'connected' | 'attention_required' | 'archived'; - -export type MetadataSource = 'mock' | 'provider'; - -export type ProviderKind = 'revenuecat' | 'app_store' | 'google_play' | 'custom'; - -export type ProviderActivationKind = 'provider_connection' | 'native_store'; - -export type ProviderConnectionKind = 'revenuecat' | 'custom'; - -export type ProviderIntegrationMode = 'server_connected' | 'sdk_only'; - -export type ProviderConnectionMode = 'sandbox' | 'production'; - -export type ProviderConnectionStatus = 'pending' | 'active' | 'revoked'; - -export type ProviderHealthStatus = 'untested' | 'healthy' | 'degraded' | 'unavailable' | 'revoked'; - -export type EnvironmentMode = 'development' | 'staging' | 'production'; - -export type ProviderMappingStatus = 'placeholder' | 'draft' | 'active' | 'attention_required' | 'archived'; - -export type ProviderAvailability = 'unknown' | 'available' | 'unavailable'; - -export type ProviderSyncState = 'never_synced' | 'current' | 'stale' | 'failed'; - -export type ProviderErrorCode = 'credentialInvalid' | 'credentialExpired' | 'permissionDenied' | 'connectionRevoked' | 'scopeMismatch' | 'modeMismatch' | 'rateLimited' | 'timeout' | 'providerUnavailable' | 'invalidResponse' | 'productNotFound' | 'productUnavailable' | 'mappingMissing' | 'mappingAmbiguous' | 'syncInProgress' | 'syncPartial' | 'syncFailed' | 'metadataStale' | 'basePlanMissing' | 'observationMissing' | 'observationStale' | 'idempotencyConflict'; - -export type SignUpRequest = { - email: string; - name: string; - password: string; +export type ExperimentDraftEnvelope = { + data: ExperimentDraft; }; -export type LoginRequest = { - email: string; - password: string; +export type ExperimentValidationEnvelope = { + data: ExperimentValidation; }; -export type CreatePaywallRequest = { - key: string; - name: string; +export type ExperimentVersionEnvelope = { + data: ExperimentVersion; }; -export type UpdatePaywallRequest = { - name: string; +export type ExperimentResultsEnvelope = { + data: ExperimentResults; }; -export type CreateDraftRequest = { - environmentId: string; - sourceVersionId?: string; - document: { - [key: string]: unknown; +export type ExperimentListEnvelope = { + data: { + items: Array; }; }; -export type UpdateDraftRequest = { - document: { - [key: string]: unknown; +export type ExperimentVersionListEnvelope = { + data: { + items: Array; }; }; -export type CreatePlacementRequest = { - key: string; - name: string; - description?: string; -}; - -export type UpdatePlacementRequest = { - name: string; - description?: string; +export type ExperimentHistoryListEnvelope = { + data: { + items: Array; + }; }; -export type BindPlacementRequest = { - paywallId: string; +export type ExperimentMetricListEnvelope = { + data: { + items: Array; + }; }; -export type PublishRequest = { - draftId: string; - expectedRevision: number; - acknowledgeMockProducts: boolean; +export type ExperimentGroupListEnvelope = { + data: { + items: Array; + }; }; -export type CreateOrganizationRequest = { - name: string; +export type ExperimentGroupCreatedEnvelope = { + data: ExperimentGroupCreated; }; -export type UpdateOrganizationRequest = CreateOrganizationRequest; - -export type UpdateNameRequest = CreateOrganizationRequest; - -export type AddMemberRequest = { - actorId: string; - role: Role; +export type ExperimentGroupVersionEnvelope = { + data: ExperimentGroupVersion; }; -export type UpdateMemberRequest = { - role: Role; +export type ExperimentGroupVersionListEnvelope = { + data: { + items: Array; + }; }; -export type CreateProjectRequest = { - organizationId: string; - key: string; - name: string; +export type ExperimentQaOverrideListEnvelope = { + data: { + items: Array; + }; }; -export type CreateApplicationRequest = { - name: string; - platform: Platform; - identifier: string; +export type ExperimentQaOverrideCreatedEnvelope = { + data: ExperimentQaOverrideCreated; }; -export type CreateApiKeyRequest = { - kind: ApiKeyKind; +export type AnalyticsEventBatch = { /** - * Required for public_sdk keys and forbidden for secret_server keys. + * Must exactly equal every enclosed eventSchemaVersion. */ - applicationId?: string; + analyticsEventContractVersion: '1' | '2'; + batchId: string; + sentAt: Timestamp; + events: Array<{ + [key: string]: unknown; + }>; }; -export type CreateCatalogResourceRequest = { - key: string; - name: string; - description?: string; +export type AnalyticsEventResult = { + eventId: string; + status: 'accepted' | 'duplicate' | 'permanently_rejected' | 'retryable'; + code?: string; }; -export type CreateProductRequest = { - key: string; - internalName: string; - description?: string; - type: ProductType; +export type AnalyticsIngestionResult = { + analyticsEventContractVersion: '1' | '2'; + batchId: string; + receivedAt: Timestamp; + results: Array; }; -export type ProductReferenceRequest = { - productId: string; +export type AnalyticsSettings = { + projectId: string; + environmentId: string; + collectionEnabled: boolean; + rawRetentionDays: number; + updatedAt: Timestamp; }; -export type EntitlementReferenceRequest = { - entitlementId: string; +export type UpdateAnalyticsSettingsRequest = { + collectionEnabled: boolean; + rawRetentionDays: number; }; -export type CreateProviderMappingRequest = { - applicationId: string; - provider: ProviderKind; - providerProductIdentifier: string; +export type AnalyticsFreshness = { + latestReceivedAt?: Timestamp; + latestAggregatedAt?: Timestamp; + lateEventPolicy: string; }; -export type SetEnvironmentModeRequest = { - mode: EnvironmentMode; +export type AnalyticsMetric = { + id: string; + value?: number; + numerator: number; + denominator?: number; + basis: 'event_count'; + authority: 'client_observed' | 'trusted_server' | 'provider_confirmed'; + attributionWindow: '24h'; + timezone: string; + definition: string; + warnings?: Array; + dimensions?: { + [key: string]: string; + }; }; -export type CreateProviderConnectionRequest = unknown & { - name: string; - provider: ProviderConnectionKind; - integrationMode: ProviderIntegrationMode; - mode: ProviderConnectionMode; - /** - * Required RevenueCat v2 Project resource ID. - */ - externalProjectId?: string; - environmentIds: Array; - applicationIds: Array; +export type AnalyticsResult = { + metrics: Array; + freshness: AnalyticsFreshness; }; -export type ProviderCredentialRequest = { - credential: string; +export type CreateAnalyticsEventExportRequest = { + from: Timestamp; + to: Timestamp; + format: 'ndjson' | 'csv'; }; -export type ReplaceProviderMappingRequest = { - providerProductIdentifier: string; - /** - * RevenueCat v2 Package ID or SDK lookup key; persisted as the verified lookup key. - */ - providerPackageIdentifier?: string; - /** - * RevenueCat v2 Offering ID or SDK lookup key; persisted as the verified lookup key. - */ - providerOfferingIdentifier?: string; - /** - * Required exact Google base-plan ID for subscriptions. - */ - providerBasePlanIdentifier?: string; +export type AnalyticsIdentityRequest = { + kind: 'application_user' | 'installation'; /** - * Optional explicitly selected Google offer ID. Offer tokens are never persisted. + * Opaque identifier. Application-user values accept non-control Unicode and punctuation but sensitive-shaped values are rejected. */ - providerOfferIdentifier?: string; + identity: string; }; -export type ProviderEntitlementImportRequest = { - providerIdentifier: string; - existingEntitlementId?: string; - key?: string; - name?: string; +export type CreateAnalyticsPrivacyExportRequest = AnalyticsIdentityRequest & { + format: 'ndjson' | 'csv'; }; -export type ProviderProductImportItemRequest = { - providerProductIdentifier: string; - providerPackageIdentifier?: string; - providerOfferingIdentifier?: string; - existingProductId?: string; - key?: string; - internalName?: string; - environmentId: string; - applicationId: string; - entitlements: Array; +export type CreateAnalyticsPrivacyDeletionRequest = AnalyticsIdentityRequest & { + requestDigest: string; + confirm: true; }; -export type ProviderImportRequest = { - connectionId: string; - items: Array; +export type AnalyticsPrivacyPreview = { + kind: 'application_user' | 'installation'; + affectedEvents: number; + affectedSessions: number; + affectedEnvironmentIds: Array; + requestDigest: string; }; -export type ReplaceProviderConnectionScopesRequest = { - environmentIds: Array; - applicationIds: Array; +export type AnalyticsJob = { + id: string; + kind: 'events' | 'application_user' | 'installation'; + status: 'queued' | 'leased' | 'recomputing' | 'completed' | 'failed' | 'expired'; + format?: 'ndjson' | 'csv'; + rowCount?: number; + byteLength?: number; + affectedEventCount?: number; + affectedSessionCount?: number; + expiresAt?: Timestamp; + createdAt: Timestamp; + updatedAt: Timestamp; }; -export type SetProviderAssignmentRequest = { - provider?: ProviderKind; - activationKind?: ProviderActivationKind; - connectionId?: string; - /** - * Required when explicitly assigning a production connection outside a production Environment. - */ - acknowledgeProductionConnectionUse?: boolean; +export type AnalyticsSettingsEnvelope = { + data: AnalyticsSettings; }; -/** - * Creates an unverified draft only. RevenueCat Product, Package, and Offering - * identifiers are server-side catalog references and never become the SDK - * providerProductReference until verified and normalized. - * - */ -export type CreateProviderMappingDraftRequest = { - connectionId?: string; - provider?: ProviderKind; - environmentId: string; - applicationId: string; - /** - * Opaque provider catalog resource identifier. - */ - providerProductIdentifier: string; - /** - * RevenueCat-only metadata; requires providerOfferingIdentifier. - */ - providerPackageIdentifier?: string; - /** - * RevenueCat-only metadata; requires providerPackageIdentifier. - */ - providerOfferingIdentifier?: string; - expectedStoreProductId?: string; - /** - * Exact Google base-plan ID; required by service validation for subscriptions. - */ - providerBasePlanIdentifier?: string; - /** - * Optional exact Google offer ID; requires a base plan. Runtime offer tokens are forbidden. - */ - providerOfferIdentifier?: string; +export type AnalyticsResultEnvelope = { + data: AnalyticsResult; }; -export type CreateProviderMappingObservationRequest = { - adapterVersion: string; - storeContext: 'storekitConfiguration' | 'appleSandbox' | 'googlePlayTest' | 'production' | 'unknown'; - result: 'available' | 'unavailable' | 'failed'; - /** - * Safe Mosaic code only; raw provider messages are forbidden. - */ - diagnosticCode?: string; - correlationId: string; - metadata?: ProviderMappingObservationMetadata; - observedAt: Timestamp; - expiresAt?: Timestamp; +export type AnalyticsPrivacyPreviewEnvelope = { + data: AnalyticsPrivacyPreview; }; -export type Organization = { - id: string; - name: string; - createdAt: Timestamp; - updatedAt: Timestamp; +export type AnalyticsJobEnvelope = { + data: AnalyticsJob; }; -export type Membership = { - organizationId: string; - actorId: string; - role: Role; - createdAt: Timestamp; - updatedAt: Timestamp; +export type PlacementDecisionDocumentRequest = { + document: PlacementDecisionDocument; }; -export type Project = { - id: string; - organizationId: string; - key: string; - name: string; - status: ProjectStatus; - archivedAt?: Timestamp; - createdAt: Timestamp; - updatedAt: Timestamp; +/** + * Canonical Placement Decision v1 envelope; see protocol/schema/placement-decision/v1/decision.schema.json. + */ +export type PlacementDecisionDocument = { + placementDecisionVersion: '1'; + ruleSet: { + [key: string]: unknown; + }; + [key: string]: unknown; }; -export type Application = { - id: string; - projectId: string; - name: string; - platform: Platform; - identifier: string; - createdAt: Timestamp; - updatedAt: Timestamp; +export type PlacementValidationIssue = { + severity: 'error' | 'warning'; + code: string; + ruleId?: string; + conditionPath?: string; + resourceType?: string; + resourceId?: string; + recoveryAction: string; }; -export type Environment = { +export type PlacementValidation = { + valid: boolean; + issues: Array; +}; + +export type PlacementRuleSet = { id: string; projectId: string; - key: string; - name: string; - mode: EnvironmentMode; - createdAt: Timestamp; - updatedAt: Timestamp; + environmentId: string; + placementId: string; + contractVersion: '1'; + status: 'active' | 'archived'; + currentDraftId?: string; + currentPublishedVersionId?: string; + createdByActorId: string; + createdAt: string; + updatedAt: string; + archivedAt?: string; }; -/** - * Contains no provider credential, token, secret, nonce, or ciphertext. - */ -export type ProviderConnection = { +export type PlacementRuleSetDraft = { id: string; + ruleSetId: string; projectId: string; - name: string; - provider: ProviderConnectionKind; - integrationMode: ProviderIntegrationMode; - mode: ProviderConnectionMode; - status: ProviderConnectionStatus; - healthStatus: ProviderHealthStatus; - externalProjectId?: string; - environmentIds: Array; - applicationIds: Array; - lastSuccessfulTestAt?: Timestamp; - lastSuccessfulSyncAt?: Timestamp; - lastErrorCode?: ProviderErrorCode; - credential?: ProviderCredential; - revokedAt?: Timestamp; - createdAt: Timestamp; - updatedAt: Timestamp; + environmentId: string; + status: 'active' | 'published' | 'superseded'; + revision: number; + sourceVersionId?: string; + createdByActorId: string; + updatedByActorId: string; + createdAt: string; + updatedAt: string; }; -/** - * Safe credential metadata only; contains no plaintext, ciphertext, nonce, or authorization material. - */ -export type ProviderCredential = { - class: 'serverSecret'; - fingerprint: string; - keyId: string; - envelopeVersion: number; - createdAt: Timestamp; - rotatedAt?: Timestamp; - revokedAt?: Timestamp; +export type PlacementRuleSetDraftResource = { + ruleSet: PlacementRuleSet; + draft: PlacementRuleSetDraft; + document: PlacementDecisionDocument; + validation: PlacementValidation; }; -export type ProviderCapability = { - name: string; - support: 'supported' | 'conditional' | 'unsupported'; - reasonCode?: string; +export type PlacementRuleSetVersion = { + id: string; + ruleSetId: string; + projectId: string; + environmentId: string; + placementId: string; + versionNumber: number; + sourceDraftId: string; + sourceRevision: number; + contractVersion: '1'; + document: PlacementDecisionDocument; + documentHash: string; + validation: PlacementValidation; + publishedByActorId: string; + publishedAt: string; }; -export type ProviderConnectionHealth = { - connectionId: string; - status: ProviderHealthStatus; - lastSuccessfulAt?: Timestamp; - lastErrorCode?: ProviderErrorCode; - capabilities: Array; - requiredPermissions: Array; +export type PlacementAttribute = { + id: string; + projectId: string; + key: string; + type: 'string' | 'boolean' | 'number' | 'timestamp' | 'semantic_version' | 'string_list'; + description: string; + allowedOperators: Array; + sensitivity: 'standard' | 'sensitive'; + status: 'active' | 'archived'; + revision: number; + createdByActorId: string; + updatedByActorId: string; + createdAt: string; + updatedAt: string; + archivedAt?: string; }; -export type ProviderConnectionCapabilities = { - connectionId: string; - capabilities: Array; - requiredPermissions: Array; +export type CreatePlacementAttributeRequest = { + key: string; + type: string; + description?: string; + allowedOperators: Array; + sensitivity: 'standard' | 'sensitive'; }; -export type ProviderDiagnostic = { +export type PlacementAlias = { id: string; projectId: string; - connectionId: string; - operation: string; - code: ProviderErrorCode; - retryable: boolean; - retryAfterSeconds?: number; - correlationId: string; - occurredAt: Timestamp; + placementId: string; + key: string; + status: 'active' | 'archived'; + createdByActorId: string; + createdAt: string; + archivedAt?: string; }; -export type ProviderCatalogApplication = { - id: string; - name: string; - platform: 'app_store' | 'play_store'; - identifier?: string; +export type PlacementUsage = { + ruleSetCount: number; + aliasCount: number; + publishedRuleCount: number; }; -export type ProviderCatalogProduct = { - id: string; - applicationId: string; - storeIdentifier: string; - displayName?: string; - type: 'subscription' | 'non_consumable' | 'consumable' | 'unknown'; - state: string; - importable: boolean; +export type PlacementOutcome = { + type: 'paywall' | 'no_paywall' | 'fallback' | 'unavailable'; + paywallVersionId?: string; + key?: string; + unavailableFallbackKey?: string; + reason?: string; }; -export type ProviderCatalogEntitlement = { +export type QaOverride = { id: string; - lookupKey: string; - displayName: string; - state: string; + projectId: string; + environmentId: string; + placementId: string; + safeLabel: string; + outcome: PlacementOutcome; + status: 'active' | 'revoked' | 'expired'; + createdByActorId: string; + createdAt: string; + expiresAt: string; + revokedAt?: string; }; -export type ProviderCatalogPackage = { - id: string; - lookupKey: string; - displayName: string; - productIds: Array; +export type CreateQaOverrideRequest = { + safeLabel: string; + outcome: PlacementOutcome; + expiresAt: string; }; -export type ProviderCatalogOffering = { - id: string; - lookupKey: string; - displayName: string; - state: string; - isCurrent: boolean; - packages: Array; +export type QaOverrideCreated = { + override: QaOverride; + /** + * Returned once and never persisted in plaintext. + */ + token: string; }; -export type ProviderCatalogPreview = { - connectionId: string; - observedAt: Timestamp; - applications: Array; - products: Array; - entitlements: Array; - offerings: Array; +export type PlacementSimulationRequest = { + platform?: 'ios' | 'android'; + osVersion?: string; + applicationVersion?: string; + locale?: string; + country?: string; + entitlements?: { + [key: string]: unknown; + }; + productAvailability?: { + [key: string]: unknown; + }; + productReadiness?: { + [key: string]: unknown; + }; + providerCapabilities?: { + [key: string]: unknown; + }; }; -export type ProviderImport = { - id: string; - projectId: string; - connectionId: string; - status: 'in_progress' | 'completed' | 'partial'; - createdByActorId: string; - createdAt: Timestamp; - completedAt?: Timestamp; +export type PlacementSimulationResult = { + winningRuleId?: string; + selectedOutcome: PlacementOutcome; + finalOutcome: PlacementOutcome; + fallbackPath: Array; + assignmentKeyType?: string; + rolloutBucket?: number; + trace: Array<{ + [key: string]: unknown; + }>; }; -export type ProviderImportItem = { - importId: string; - projectId: string; - providerProductIdentifier: string; - mosaicProductId?: string; - mappingId?: string; - status: 'imported' | 'failed'; - errorCode?: ProviderErrorCode; - createdAt: Timestamp; +export type PlacementRuleSetDraftEnvelope = { + data: PlacementRuleSetDraftResource; }; -export type ProviderImportResult = { - import: ProviderImport; - items: Array; +export type PlacementValidationEnvelope = { + data: PlacementValidation; }; -export type ProviderSyncJob = { - id: string; - projectId: string; - connectionId: string; - status: 'queued' | 'leased' | 'completed' | 'failed'; - attemptCount: number; - maxAttempts: number; - availableAt: Timestamp; - requestedByActorId: string; - createdAt: Timestamp; - updatedAt: Timestamp; +export type PlacementRuleSetVersionEnvelope = { + data: PlacementRuleSetVersion; }; -export type ProviderSyncRun = { - id: string; - projectId: string; - connectionId: string; - jobId: string; - status: 'running' | 'completed' | 'partial' | 'failed'; - itemCount: number; - successCount: number; - failureCount: number; - startedAt: Timestamp; - completedAt?: Timestamp; - createdAt: Timestamp; +export type PlacementRuleSetVersionListEnvelope = { + data: { + items: Array; + }; }; -export type ActiveProviderAssignment = { - projectId: string; - environmentId: string; - applicationId: string; - platform: Platform; - provider: ProviderKind; - activationKind: ProviderActivationKind; - connectionId?: string; - productionConnectionUseAcknowledged: boolean; - createdByActorId: string; - createdAt: Timestamp; - updatedAt: Timestamp; +export type PlacementAttributeEnvelope = { + data: PlacementAttribute; }; -export type ApiKey = { - id: string; +export type PlacementAttributeListEnvelope = { + data: { + items: Array; + }; +}; + +export type PlacementAliasEnvelope = { + data: PlacementAlias; +}; + +export type PlacementAliasListEnvelope = { + data: { + items: Array; + }; +}; + +export type PlacementUsageEnvelope = { + data: PlacementUsage; +}; + +export type QaOverrideCreatedEnvelope = { + data: QaOverrideCreated; +}; + +export type QaOverrideListEnvelope = { + data: { + items: Array; + }; +}; + +export type PlacementSimulationEnvelope = { + data: PlacementSimulationResult; +}; + +export type Timestamp = string; + +export type Page = { + nextCursor?: string; +}; + +export type Role = 'owner' | 'admin' | 'member'; + +export type ProjectStatus = 'active' | 'archived'; + +export type Platform = 'ios' | 'android'; + +export type ApiKeyKind = 'public_sdk' | 'secret_server'; + +export type ProductType = 'subscription' | 'one_time_non_consumable'; + +export type ProductStatus = 'draft' | 'connected' | 'attention_required' | 'archived'; + +export type MetadataSource = 'mock' | 'provider'; + +export type ProviderKind = 'revenuecat' | 'app_store' | 'google_play' | 'custom'; + +export type ProviderActivationKind = 'provider_connection' | 'native_store'; + +export type ProviderConnectionKind = 'revenuecat' | 'custom'; + +export type ProviderIntegrationMode = 'server_connected' | 'sdk_only'; + +export type ProviderConnectionMode = 'sandbox' | 'production'; + +export type ProviderConnectionStatus = 'pending' | 'active' | 'revoked'; + +export type ProviderHealthStatus = 'untested' | 'healthy' | 'degraded' | 'unavailable' | 'revoked'; + +export type EnvironmentMode = 'development' | 'staging' | 'production'; + +export type ProviderMappingStatus = 'placeholder' | 'draft' | 'active' | 'attention_required' | 'archived'; + +export type ProviderAvailability = 'unknown' | 'available' | 'unavailable'; + +export type ProviderSyncState = 'never_synced' | 'current' | 'stale' | 'failed'; + +export type ProviderErrorCode = 'credentialInvalid' | 'credentialExpired' | 'permissionDenied' | 'connectionRevoked' | 'scopeMismatch' | 'modeMismatch' | 'rateLimited' | 'timeout' | 'providerUnavailable' | 'invalidResponse' | 'productNotFound' | 'productUnavailable' | 'mappingMissing' | 'mappingAmbiguous' | 'syncInProgress' | 'syncPartial' | 'syncFailed' | 'metadataStale' | 'basePlanMissing' | 'observationMissing' | 'observationStale' | 'idempotencyConflict'; + +export type SignUpRequest = { + email: string; + name: string; + password: string; +}; + +export type LoginRequest = { + email: string; + password: string; +}; + +export type CreatePaywallRequest = { + key: string; + name: string; +}; + +export type UpdatePaywallRequest = { + name: string; +}; + +export type CreateDraftRequest = { environmentId: string; - /** - * Trusted Application binding for public SDK analytics ingestion. - */ - applicationId?: string; - kind: ApiKeyKind; - prefix: string; - createdByActorId: string; - createdAt: Timestamp; - rotatedAt?: Timestamp; - revokedAt?: Timestamp; - lastUsedAt?: Timestamp; + sourceVersionId?: string; + document: { + [key: string]: unknown; + }; }; -export type ApiKeySecretResult = { - apiKey: ApiKey; - /** - * Returned only by create and rotate operations and never available again. - */ - secret: string; +export type UpdateDraftRequest = { + document: { + [key: string]: unknown; + }; }; -export type Plan = { - id: string; - projectId: string; +export type CreatePlacementRequest = { key: string; name: string; description?: string; - createdAt: Timestamp; - updatedAt: Timestamp; }; -/** - * Legacy catalog lifecycle summary embedded in Product responses; never sufficient for publication. - */ -export type ProductReadiness = { - ready: boolean; - reasons: Array; - metadataSource: MetadataSource; +export type UpdatePlacementRequest = { + name: string; + description?: string; }; -export type Product = { - id: string; - projectId: string; +export type BindPlacementRequest = { + paywallId: string; +}; + +export type PublishRequest = { + draftId: string; + expectedRevision: number; + acknowledgeMockProducts: boolean; +}; + +export type CreateOrganizationRequest = { + name: string; +}; + +export type UpdateOrganizationRequest = CreateOrganizationRequest; + +export type UpdateNameRequest = CreateOrganizationRequest; + +export type AddMemberRequest = { + actorId: string; + role: Role; +}; + +export type UpdateMemberRequest = { + role: Role; +}; + +export type CreateProjectRequest = { + organizationId: string; key: string; - internalName: string; - description?: string; - type: ProductType; - status: ProductStatus; - metadataSource: MetadataSource; - readiness: ProductReadiness; - replacementProductId?: string; - archivedAt?: Timestamp; - createdAt: Timestamp; - updatedAt: Timestamp; + name: string; }; -export type Entitlement = { - id: string; - projectId: string; +export type CreateApplicationRequest = { + name: string; + platform: Platform; + identifier: string; +}; + +export type CreateApiKeyRequest = { + kind: ApiKeyKind; + /** + * Required for public_sdk keys and forbidden for secret_server keys. + */ + applicationId?: string; +}; + +export type CreateCatalogResourceRequest = { key: string; name: string; description?: string; - createdAt: Timestamp; - updatedAt: Timestamp; }; -export type PlanProduct = { - planId: string; - productId: string; - createdAt: Timestamp; +export type CreateProductRequest = { + key: string; + internalName: string; + description?: string; + type: ProductType; }; -export type ProductEntitlementGrant = { +export type ProductReferenceRequest = { productId: string; +}; + +export type EntitlementReferenceRequest = { entitlementId: string; - createdAt: Timestamp; }; -export type ProviderProductMapping = { - id: string; - projectId: string; - productId: string; - connectionId?: string; - environmentId?: string; +export type CreateProviderMappingRequest = { applicationId: string; - platform: Platform; provider: ProviderKind; + providerProductIdentifier: string; +}; + +export type SetEnvironmentModeRequest = { + mode: EnvironmentMode; +}; + +export type CreateProviderConnectionRequest = unknown & { + name: string; + provider: ProviderConnectionKind; + integrationMode: ProviderIntegrationMode; + mode: ProviderConnectionMode; /** - * RevenueCat v2 Product resource identifier used only by server-side catalog synchronization. + * Required RevenueCat v2 Project resource ID. */ + externalProjectId?: string; + environmentIds: Array; + applicationIds: Array; +}; + +export type ProviderCredentialRequest = { + credential: string; +}; + +export type ReplaceProviderMappingRequest = { providerProductIdentifier: string; /** - * Optional RevenueCat Package metadata; present only with providerOfferingIdentifier. + * RevenueCat v2 Package ID or SDK lookup key; persisted as the verified lookup key. */ providerPackageIdentifier?: string; /** - * Optional RevenueCat Offering metadata; present only with providerPackageIdentifier. + * RevenueCat v2 Offering ID or SDK lookup key; persisted as the verified lookup key. */ providerOfferingIdentifier?: string; - expectedStoreProductId?: string; + /** + * Required exact Google base-plan ID for subscriptions. + */ providerBasePlanIdentifier?: string; - providerOfferIdentifier?: string; - replacesMappingId?: string; - status: ProviderMappingStatus; - availability: ProviderAvailability; - syncState: ProviderSyncState; - currentSnapshotId?: string; - lastErrorCode?: ProviderErrorCode; - archivedAt?: Timestamp; - createdAt: Timestamp; - updatedAt: Timestamp; -}; - -/** - * Immutable safe metadata evidence; raw provider responses and credentials are excluded. - */ -export type ProviderProductMetadataSnapshot = { - id: string; - projectId: string; - mappingId: string; - source: 'provider' | 'sdk_snapshot' | 'manual'; - digest: string; - availability: ProviderAvailability; - observedAt: Timestamp; - syncedAt: Timestamp; - staleAt: Timestamp; - expiresAt?: Timestamp; - lastErrorCode?: ProviderErrorCode; /** - * Normalized safe metadata only; raw provider responses are never persisted. + * Optional explicitly selected Google offer ID. Offer tokens are never persisted. */ - metadata: { - [key: string]: unknown; - }; - createdAt: Timestamp; + providerOfferIdentifier?: string; }; -export type ProviderReadinessIssue = { - code: ProviderErrorCode; - resourceType: string; - resourceId: string; - recoveryAction: string; +export type ProviderEntitlementImportRequest = { + providerIdentifier: string; + existingEntitlementId?: string; + key?: string; + name?: string; }; -export type ProviderReadiness = { - state: 'configured' | 'verifiedInTest' | 'attentionRequired' | 'unavailable' | 'archived'; - productId: string; +export type ProviderProductImportItemRequest = { + providerProductIdentifier: string; + providerPackageIdentifier?: string; + providerOfferingIdentifier?: string; + existingProductId?: string; + key?: string; + internalName?: string; environmentId: string; applicationId: string; - platform: Platform; - connectionId?: string; + entitlements: Array; +}; + +export type ProviderImportRequest = { + connectionId: string; + items: Array; +}; + +export type ReplaceProviderConnectionScopesRequest = { + environmentIds: Array; + applicationIds: Array; +}; + +export type SetProviderAssignmentRequest = { provider?: ProviderKind; - mappingId?: string; - observation?: ProviderMappingObservation; - blockers: Array; - warnings: Array; - evaluatedAt: Timestamp; + activationKind?: ProviderActivationKind; + connectionId?: string; + /** + * Required when explicitly assigning a production connection outside a production Environment. + */ + acknowledgeProductionConnectionUse?: boolean; }; -export type ProviderMappingObservation = { - id: string; - projectId: string; - mappingId: string; +/** + * Creates an unverified draft only. RevenueCat Product, Package, and Offering + * identifiers are server-side catalog references and never become the SDK + * providerProductReference until verified and normalized. + * + */ +export type CreateProviderMappingDraftRequest = { + connectionId?: string; + provider?: ProviderKind; environmentId: string; applicationId: string; - platform: Platform; - provider: ProviderKind; + /** + * Opaque provider catalog resource identifier. + */ + providerProductIdentifier: string; + /** + * RevenueCat-only metadata; requires providerOfferingIdentifier. + */ + providerPackageIdentifier?: string; + /** + * RevenueCat-only metadata; requires providerPackageIdentifier. + */ + providerOfferingIdentifier?: string; + expectedStoreProductId?: string; + /** + * Exact Google base-plan ID; required by service validation for subscriptions. + */ + providerBasePlanIdentifier?: string; + /** + * Optional exact Google offer ID; requires a base plan. Runtime offer tokens are forbidden. + */ + providerOfferIdentifier?: string; +}; + +export type CreateProviderMappingObservationRequest = { adapterVersion: string; storeContext: 'storekitConfiguration' | 'appleSandbox' | 'googlePlayTest' | 'production' | 'unknown'; result: 'available' | 'unavailable' | 'failed'; + /** + * Safe Mosaic code only; raw provider messages are forbidden. + */ diagnosticCode?: string; correlationId: string; - metadata: ProviderMappingObservationMetadata; + metadata?: ProviderMappingObservationMetadata; observedAt: Timestamp; expiresAt?: Timestamp; - receivedAt: Timestamp; - createdByActorId: string; -}; - -/** - * Closed operational metadata. Unknown fields and receipt, token, credential, customer, account, authorization, or secret-like material are rejected. - */ -export type ProviderMappingObservationMetadata = { - clientPlatform?: 'ios' | 'android' | 'flutter'; - clientVersion?: string; - applicationVersion?: string; - osVersion?: string; - configurationSource?: 'bundled' | 'remote' | 'local' | 'unknown'; - storefrontCountryCode?: string; - testScenario?: 'productLoad' | 'configurationAcceptance' | 'purchasePresentation' | 'restore'; -}; - -export type ProviderMappingUsage = { - mapping: ProviderProductMapping; - product: Product; - usage: ProductUsage; }; -export type ProviderProfile = { - provider: ProviderKind; - displayName: string; - platform: Platform; - adapterVersion: string; - capabilities: Array; +export type Organization = { + id: string; + name: string; + createdAt: Timestamp; + updatedAt: Timestamp; }; -export type ProductUsage = { - productId: string; - plans: Array; - entitlements: Array; - providerMappings: Array; - historicalReferences: Array; +export type Membership = { + organizationId: string; + actorId: string; + role: Role; + createdAt: Timestamp; + updatedAt: Timestamp; }; -export type AuditEvent = { +export type Project = { id: string; - actorId: string; organizationId: string; - projectId?: string; - environmentId?: string; - action: string; - resourceType: string; - resourceId: string; - metadata: { - [key: string]: string; - }; + key: string; + name: string; + status: ProjectStatus; + archivedAt?: Timestamp; createdAt: Timestamp; + updatedAt: Timestamp; }; -export type ValidationSummary = { - errors: Array; - warnings: Array; +export type Application = { + id: string; + projectId: string; + name: string; + platform: Platform; + identifier: string; + createdAt: Timestamp; + updatedAt: Timestamp; }; -export type Paywall = { +export type Environment = { id: string; projectId: string; key: string; name: string; - status: 'active' | 'archived'; - archivedAt?: Timestamp; - createdByActorId: string; + mode: EnvironmentMode; createdAt: Timestamp; updatedAt: Timestamp; }; -export type Draft = { +/** + * Contains no provider credential, token, secret, nonce, or ciphertext. + */ +export type ProviderConnection = { id: string; projectId: string; - paywallId: string; - environmentId: string; - status: 'active' | 'published' | 'archived'; - revision: number; - sourceVersionId?: string; - protocolVersion: '0.2'; - validationStatus: 'valid' | 'invalid'; - validation: ValidationSummary; - createdByActorId: string; - updatedByActorId: string; + name: string; + provider: ProviderConnectionKind; + integrationMode: ProviderIntegrationMode; + mode: ProviderConnectionMode; + status: ProviderConnectionStatus; + healthStatus: ProviderHealthStatus; + externalProjectId?: string; + environmentIds: Array; + applicationIds: Array; + lastSuccessfulTestAt?: Timestamp; + lastSuccessfulSyncAt?: Timestamp; + lastErrorCode?: ProviderErrorCode; + credential?: ProviderCredential; + revokedAt?: Timestamp; createdAt: Timestamp; updatedAt: Timestamp; }; -export type DraftResource = { - draft: Draft; - document: { - [key: string]: unknown; +/** + * Safe credential metadata only; contains no plaintext, ciphertext, nonce, or authorization material. + */ +export type ProviderCredential = { + class: 'serverSecret'; + fingerprint: string; + keyId: string; + envelopeVersion: number; + createdAt: Timestamp; + rotatedAt?: Timestamp; + revokedAt?: Timestamp; +}; + +export type ProviderCapability = { + name: string; + support: 'supported' | 'conditional' | 'unsupported'; + reasonCode?: string; +}; + +export type ProviderConnectionHealth = { + connectionId: string; + status: ProviderHealthStatus; + lastSuccessfulAt?: Timestamp; + lastErrorCode?: ProviderErrorCode; + capabilities: Array; + requiredPermissions: Array; +}; + +export type ProviderConnectionCapabilities = { + connectionId: string; + capabilities: Array; + requiredPermissions: Array; +}; + +export type ProviderDiagnostic = { + id: string; + projectId: string; + connectionId: string; + operation: string; + code: ProviderErrorCode; + retryable: boolean; + retryAfterSeconds?: number; + correlationId: string; + occurredAt: Timestamp; +}; + +export type ProviderCatalogApplication = { + id: string; + name: string; + platform: 'app_store' | 'play_store'; + identifier?: string; +}; + +export type ProviderCatalogProduct = { + id: string; + applicationId: string; + storeIdentifier: string; + displayName?: string; + type: 'subscription' | 'non_consumable' | 'consumable' | 'unknown'; + state: string; + importable: boolean; +}; + +export type ProviderCatalogEntitlement = { + id: string; + lookupKey: string; + displayName: string; + state: string; +}; + +export type ProviderCatalogPackage = { + id: string; + lookupKey: string; + displayName: string; + productIds: Array; +}; + +export type ProviderCatalogOffering = { + id: string; + lookupKey: string; + displayName: string; + state: string; + isCurrent: boolean; + packages: Array; +}; + +export type ProviderCatalogPreview = { + connectionId: string; + observedAt: Timestamp; + applications: Array; + products: Array; + entitlements: Array; + offerings: Array; +}; + +export type ProviderImport = { + id: string; + projectId: string; + connectionId: string; + status: 'in_progress' | 'completed' | 'partial'; + createdByActorId: string; + createdAt: Timestamp; + completedAt?: Timestamp; +}; + +export type ProviderImportItem = { + importId: string; + projectId: string; + providerProductIdentifier: string; + mosaicProductId?: string; + mappingId?: string; + status: 'imported' | 'failed'; + errorCode?: ProviderErrorCode; + createdAt: Timestamp; +}; + +export type ProviderImportResult = { + import: ProviderImport; + items: Array; +}; + +export type ProviderSyncJob = { + id: string; + projectId: string; + connectionId: string; + status: 'queued' | 'leased' | 'completed' | 'failed'; + attemptCount: number; + maxAttempts: number; + availableAt: Timestamp; + requestedByActorId: string; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +export type ProviderSyncRun = { + id: string; + projectId: string; + connectionId: string; + jobId: string; + status: 'running' | 'completed' | 'partial' | 'failed'; + itemCount: number; + successCount: number; + failureCount: number; + startedAt: Timestamp; + completedAt?: Timestamp; + createdAt: Timestamp; +}; + +export type ActiveProviderAssignment = { + projectId: string; + environmentId: string; + applicationId: string; + platform: Platform; + provider: ProviderKind; + activationKind: ProviderActivationKind; + connectionId?: string; + productionConnectionUseAcknowledged: boolean; + createdByActorId: string; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +export type ApiKey = { + id: string; + environmentId: string; + /** + * Trusted Application binding for public SDK analytics ingestion. + */ + applicationId?: string; + kind: ApiKeyKind; + prefix: string; + createdByActorId: string; + createdAt: Timestamp; + rotatedAt?: Timestamp; + revokedAt?: Timestamp; + lastUsedAt?: Timestamp; +}; + +export type ApiKeySecretResult = { + apiKey: ApiKey; + /** + * Returned only by create and rotate operations and never available again. + */ + secret: string; +}; + +export type Plan = { + id: string; + projectId: string; + key: string; + name: string; + description?: string; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +/** + * Legacy catalog lifecycle summary embedded in Product responses; never sufficient for publication. + */ +export type ProductReadiness = { + ready: boolean; + reasons: Array; + metadataSource: MetadataSource; +}; + +export type Product = { + id: string; + projectId: string; + key: string; + internalName: string; + description?: string; + type: ProductType; + status: ProductStatus; + metadataSource: MetadataSource; + readiness: ProductReadiness; + replacementProductId?: string; + archivedAt?: Timestamp; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +export type Entitlement = { + id: string; + projectId: string; + key: string; + name: string; + description?: string; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +export type PlanProduct = { + planId: string; + productId: string; + createdAt: Timestamp; +}; + +export type ProductEntitlementGrant = { + productId: string; + entitlementId: string; + createdAt: Timestamp; +}; + +export type ProviderProductMapping = { + id: string; + projectId: string; + productId: string; + connectionId?: string; + environmentId?: string; + applicationId: string; + platform: Platform; + provider: ProviderKind; + /** + * RevenueCat v2 Product resource identifier used only by server-side catalog synchronization. + */ + providerProductIdentifier: string; + /** + * Optional RevenueCat Package metadata; present only with providerOfferingIdentifier. + */ + providerPackageIdentifier?: string; + /** + * Optional RevenueCat Offering metadata; present only with providerPackageIdentifier. + */ + providerOfferingIdentifier?: string; + expectedStoreProductId?: string; + providerBasePlanIdentifier?: string; + providerOfferIdentifier?: string; + replacesMappingId?: string; + status: ProviderMappingStatus; + availability: ProviderAvailability; + syncState: ProviderSyncState; + currentSnapshotId?: string; + lastErrorCode?: ProviderErrorCode; + archivedAt?: Timestamp; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +/** + * Immutable safe metadata evidence; raw provider responses and credentials are excluded. + */ +export type ProviderProductMetadataSnapshot = { + id: string; + projectId: string; + mappingId: string; + source: 'provider' | 'sdk_snapshot' | 'manual'; + digest: string; + availability: ProviderAvailability; + observedAt: Timestamp; + syncedAt: Timestamp; + staleAt: Timestamp; + expiresAt?: Timestamp; + lastErrorCode?: ProviderErrorCode; + /** + * Normalized safe metadata only; raw provider responses are never persisted. + */ + metadata: { + [key: string]: unknown; + }; + createdAt: Timestamp; +}; + +export type ProviderReadinessIssue = { + code: ProviderErrorCode; + resourceType: string; + resourceId: string; + recoveryAction: string; +}; + +export type ProviderReadiness = { + state: 'configured' | 'verifiedInTest' | 'attentionRequired' | 'unavailable' | 'archived'; + productId: string; + environmentId: string; + applicationId: string; + platform: Platform; + connectionId?: string; + provider?: ProviderKind; + mappingId?: string; + observation?: ProviderMappingObservation; + blockers: Array; + warnings: Array; + evaluatedAt: Timestamp; +}; + +export type ProviderMappingObservation = { + id: string; + projectId: string; + mappingId: string; + environmentId: string; + applicationId: string; + platform: Platform; + provider: ProviderKind; + adapterVersion: string; + storeContext: 'storekitConfiguration' | 'appleSandbox' | 'googlePlayTest' | 'production' | 'unknown'; + result: 'available' | 'unavailable' | 'failed'; + diagnosticCode?: string; + correlationId: string; + metadata: ProviderMappingObservationMetadata; + observedAt: Timestamp; + expiresAt?: Timestamp; + receivedAt: Timestamp; + createdByActorId: string; +}; + +/** + * Closed operational metadata. Unknown fields and receipt, token, credential, customer, account, authorization, or secret-like material are rejected. + */ +export type ProviderMappingObservationMetadata = { + clientPlatform?: 'ios' | 'android' | 'flutter'; + clientVersion?: string; + applicationVersion?: string; + osVersion?: string; + configurationSource?: 'bundled' | 'remote' | 'local' | 'unknown'; + storefrontCountryCode?: string; + testScenario?: 'productLoad' | 'configurationAcceptance' | 'purchasePresentation' | 'restore'; +}; + +export type ProviderMappingUsage = { + mapping: ProviderProductMapping; + product: Product; + usage: ProductUsage; +}; + +export type ProviderProfile = { + provider: ProviderKind; + displayName: string; + platform: Platform; + adapterVersion: string; + capabilities: Array; +}; + +export type ProductUsage = { + productId: string; + plans: Array; + entitlements: Array; + providerMappings: Array; + historicalReferences: Array; +}; + +export type AuditEvent = { + id: string; + actorId: string; + organizationId: string; + projectId?: string; + environmentId?: string; + action: string; + resourceType: string; + resourceId: string; + metadata: { + [key: string]: string; + }; + createdAt: Timestamp; +}; + +export type ValidationSummary = { + errors: Array; + warnings: Array; +}; + +export type Paywall = { + id: string; + projectId: string; + key: string; + name: string; + status: 'active' | 'archived'; + archivedAt?: Timestamp; + createdByActorId: string; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +export type Draft = { + id: string; + projectId: string; + paywallId: string; + environmentId: string; + status: 'active' | 'published' | 'archived'; + revision: number; + sourceVersionId?: string; + protocolVersion: '0.2'; + validationStatus: 'valid' | 'invalid'; + validation: ValidationSummary; + createdByActorId: string; + updatedByActorId: string; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +export type DraftResource = { + draft: Draft; + document: { + [key: string]: unknown; + }; +}; + +export type PaywallVersion = { + id: string; + projectId: string; + paywallId: string; + environmentId: string; + versionNumber: number; + sourceDraftId: string; + sourceRevision: number; + protocolVersion: '0.2'; + document: { + [key: string]: unknown; + }; + documentHash: string; + validation: ValidationSummary; + createdByActorId: string; + createdAt: Timestamp; + productIds: Array; +}; + +export type Placement = { + id: string; + projectId: string; + key: string; + name: string; + description?: string; + status: 'active' | 'archived'; + archivedAt?: Timestamp; + createdByActorId: string; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +export type PlacementBinding = { + projectId: string; + environmentId: string; + placementId: string; + paywallId: string; + updatedByActorId: string; + updatedAt: Timestamp; +}; + +export type User = { + id: string; + email: string; + name: string; + createdAt: Timestamp; +}; + +export type Asset = { + id: string; + projectId: string; + kind: 'image' | 'video'; + originalFilename: string; + mediaType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'video/mp4'; + byteLength: number; + contentDigest: string; + url: string; + status: 'pending' | 'ready' | 'failed' | 'archived' | 'deleted'; + createdByActorId: string; + archivedAt?: Timestamp; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +export type AssetUsage = { + draftReferences: number; + versionReferences: number; + releaseReferences: number; +}; + +export type ConfigurationRelease = { + id: string; + projectId: string; + environmentId: string; + releaseNumber: number; + deliveryContractVersion: '1'; + contentHash: string; + sourceReleaseId?: string; + rollbackSourceReleaseId?: string; + publishedByActorId: string; + publishedAt: Timestamp; +}; + +export type PublishResult = { + release: ConfigurationRelease; + warnings: Array; +}; + +export type ErrorEnvelope = { + error: { + code: string; + message: string; + fields?: { + [key: string]: Array; + }; + details?: { + [key: string]: unknown; + }; + requestId?: string; + }; +}; + +export type HealthEnvelope = { + data: { + status: 'ok' | 'ready'; + version: string; + commit?: string; + built?: string; + }; +}; + +export type OrganizationEnvelope = { + data: Organization; +}; + +export type MembershipEnvelope = { + data: Membership; +}; + +export type ProjectEnvelope = { + data: Project; +}; + +export type ApplicationEnvelope = { + data: Application; +}; + +export type EnvironmentEnvelope = { + data: Environment; +}; + +export type ProviderConnectionEnvelope = { + data: ProviderConnection; +}; + +export type ProviderAssignmentEnvelope = { + data: ActiveProviderAssignment; +}; + +export type ProviderReadinessEnvelope = { + data: ProviderReadiness; +}; + +export type ProviderMappingUsageEnvelope = { + data: ProviderMappingUsage; +}; + +export type ProviderMappingObservationEnvelope = { + data: ProviderMappingObservation; +}; + +export type ProviderProfileEnvelope = { + data: ProviderProfile; +}; + +export type ApiKeyEnvelope = { + data: ApiKey; +}; + +export type ApiKeySecretEnvelope = { + data: ApiKeySecretResult; +}; + +export type PlanEnvelope = { + data: Plan; +}; + +export type ProductEnvelope = { + data: Product; +}; + +export type ProductReadinessEnvelope = { + data: ProductReadiness; +}; + +export type EntitlementEnvelope = { + data: Entitlement; +}; + +export type PlanProductEnvelope = { + data: PlanProduct; +}; + +export type ProductEntitlementGrantEnvelope = { + data: ProductEntitlementGrant; +}; + +export type ProviderMappingEnvelope = { + data: ProviderProductMapping; +}; + +export type ProductUsageEnvelope = { + data: ProductUsage; +}; + +export type OrganizationListEnvelope = OrganizationList; + +export type MembershipListEnvelope = MembershipList; + +export type ProjectListEnvelope = ProjectList; + +export type ApplicationListEnvelope = ApplicationList; + +export type EnvironmentListEnvelope = EnvironmentList; + +export type ProviderConnectionListEnvelope = ProviderConnectionList; + +export type ApiKeyListEnvelope = ApiKeyList; + +export type PlanListEnvelope = PlanList; + +export type ProductListEnvelope = ProductList; + +export type EntitlementListEnvelope = EntitlementList; + +export type ProviderMappingListEnvelope = ProviderMappingList; + +export type AuditEventListEnvelope = AuditEventList; + +export type PaywallEnvelope = { + data: Paywall; +}; + +export type DraftEnvelope = { + data: DraftResource; +}; + +export type ValidationSummaryEnvelope = { + data: ValidationSummary; +}; + +export type PaywallVersionEnvelope = { + data: PaywallVersion; +}; + +export type PlacementEnvelope = { + data: Placement; +}; + +export type PlacementBindingEnvelope = { + data: PlacementBinding; +}; + +export type AssetEnvelope = { + data: Asset; +}; + +export type AssetUsageEnvelope = { + data: AssetUsage; +}; + +export type AssetListEnvelope = { + data: { + items: Array; + page: Page; + }; +}; + +export type UserEnvelope = { + data: User; +}; + +export type ReleaseEnvelope = { + data: ConfigurationRelease; +}; + +export type PublishResultEnvelope = { + data: PublishResult; +}; + +export type PaywallListEnvelope = { + data: { + items: Array; + page: Page; + }; +}; + +export type PaywallVersionListEnvelope = { + data: { + items: Array; + page: Page; + }; +}; + +export type PlacementListEnvelope = { + data: { + items: Array; + page: Page; + }; +}; + +export type ReleaseListEnvelope = { + data: { + items: Array; + page: Page; + }; +}; + +export type OrganizationList = { + data: { + items: Array; + page: Page; + }; +}; + +export type MembershipList = { + data: { + items: Array; + page: Page; + }; +}; + +export type ProjectList = { + data: { + items: Array; + page: Page; + }; +}; + +export type ApplicationList = { + data: { + items: Array; + page: Page; + }; +}; + +export type EnvironmentList = { + data: { + items: Array; + page: Page; + }; +}; + +export type ProviderConnectionList = { + data: { + items: Array; + page: Page; + }; +}; + +export type ApiKeyList = { + data: { + items: Array; + page: Page; + }; +}; + +export type PlanList = { + data: { + items: Array; + page: Page; + }; +}; + +export type ProductList = { + data: { + items: Array; + page: Page; + }; +}; + +export type EntitlementList = { + data: { + items: Array; + page: Page; + }; +}; + +export type ProviderMappingList = { + data: { + items: Array; + page: Page; + }; +}; + +export type AuditEventList = { + data: { + items: Array; + page: Page; + }; +}; + +export type ExperimentQaOverrideCreatedWritable = { + override: ExperimentQaOverride; +}; + +export type ExperimentQaOverrideCreatedEnvelopeWritable = { + data: ExperimentQaOverrideCreatedWritable; +}; + +export type CreateQaOverrideRequestWritable = { + safeLabel: string; + selector: string; + outcome: PlacementOutcome; + expiresAt: string; +}; + +export type PlacementSimulationRequestWritable = { + platform?: 'ios' | 'android'; + osVersion?: string; + applicationVersion?: string; + locale?: string; + country?: string; + installationId?: string; + userId?: string; + attributes?: { + [key: string]: unknown; + }; + entitlements?: { + [key: string]: unknown; + }; + productAvailability?: { + [key: string]: unknown; + }; + productReadiness?: { + [key: string]: unknown; + }; + providerCapabilities?: { + [key: string]: unknown; + }; + overrideToken?: string; +}; + +export type CreateProviderConnectionRequestWritable = unknown & { + name: string; + provider: ProviderConnectionKind; + integrationMode: ProviderIntegrationMode; + mode: ProviderConnectionMode; + /** + * Required RevenueCat v2 Project resource ID. + */ + externalProjectId?: string; + /** + * One-time RevenueCat v2 least-privilege secret key. Never returned or logged. + */ + credential?: string; + environmentIds: Array; + applicationIds: Array; +}; + +/** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ +export type Cursor = string; + +export type Limit = number; + +export type OrganizationId = string; + +export type ActorId = string; + +export type ProjectId = string; + +export type WebhookDestinationId = string; + +export type WebhookDeliveryId = string; + +export type EnvironmentId = string; + +export type BillingCustomerId = string; + +export type SubscriptionInstanceId = string; + +export type RestoreJobId = string; + +export type IdentityConflictId = string; + +export type ApplicationId = string; + +export type ProviderConnectionId = string; + +export type ProviderMappingId = string; + +/** + * Opaque cursor from the immediately preceding list response. Forward it unchanged; do not + * construct or parse one — it encodes both the ordering timestamp and the row id, because + * billing identifiers are not time-ordered and an id alone cannot express a position in a + * timestamp ordering. + * + * A malformed or stale value starts from the first page rather than erroring, so a mangled + * cursor cannot silently truncate a list. Omit it for the first page; a response with no + * `nextCursor` is the last page. + * + */ +export type BillingCursor = string; + +export type StoreCredentialId = string; + +export type QuarantineRecordId = string; + +export type BillingProviderFilter = 'app_store' | 'google_play'; + +export type BillingFrom = string; + +export type BillingTo = string; + +export type ApiKeyId = string; + +export type PlanId = string; + +export type ProductId = string; + +export type EntitlementId = string; + +export type PaywallId = string; + +export type DraftId = string; + +export type VersionId = string; + +export type PlacementId = string; + +export type RuleSetId = string; + +export type ExperimentId = string; + +export type AssetId = string; + +export type ReleaseId = string; + +export type IdempotencyKey = string; + +export type IfMatch = string; + +export type AnalyticsFrom = Timestamp; + +export type AnalyticsTo = Timestamp; + +export type AnalyticsTimezone = string; + +export type AnalyticsMetricBasis = 'event_count'; + +export type AnalyticsPlatform = 'ios' | 'android'; + +export type AnalyticsLocale = string; + +export type AnalyticsApplicationVersion = string; + +export type GetHealthData = { + body?: never; + path?: never; + query?: never; + url: '/health/live'; +}; + +export type GetHealthResponses = { + /** + * Process liveness. + */ + 200: HealthEnvelope; +}; + +export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]; + +export type GetReadinessData = { + body?: never; + path?: never; + query?: never; + url: '/health/ready'; +}; + +export type GetReadinessErrors = { + /** + * PostgreSQL is unavailable. + */ + 503: ErrorEnvelope; +}; + +export type GetReadinessError = GetReadinessErrors[keyof GetReadinessErrors]; + +export type GetReadinessResponses = { + /** + * PostgreSQL is reachable and the API is ready to serve traffic. + */ + 200: HealthEnvelope; +}; + +export type GetReadinessResponse = GetReadinessResponses[keyof GetReadinessResponses]; + +export type SignUpData = { + body: SignUpRequest; + path?: never; + query?: never; + url: '/v1/auth/signup'; +}; + +export type SignUpErrors = { + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; +}; + +export type SignUpError = SignUpErrors[keyof SignUpErrors]; + +export type SignUpResponses = { + /** + * Authenticated browser user. Login and signup also set the session cookie. + */ + 201: UserEnvelope; +}; + +export type SignUpResponse = SignUpResponses[keyof SignUpResponses]; + +export type LoginData = { + body: LoginRequest; + path?: never; + query?: never; + url: '/v1/auth/login'; +}; + +export type LoginErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; +}; + +export type LoginError = LoginErrors[keyof LoginErrors]; + +export type LoginResponses = { + /** + * Authenticated browser user. Login and signup also set the session cookie. + */ + 200: UserEnvelope; +}; + +export type LoginResponse = LoginResponses[keyof LoginResponses]; + +export type LogoutData = { + body?: never; + path?: never; + query?: never; + url: '/v1/auth/logout'; +}; + +export type LogoutResponses = { + /** + * Session revoked and cookie cleared. + */ + 204: void; +}; + +export type LogoutResponse = LogoutResponses[keyof LogoutResponses]; + +export type GetSessionData = { + body?: never; + path?: never; + query?: never; + url: '/v1/auth/session'; +}; + +export type GetSessionErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; +}; + +export type GetSessionError = GetSessionErrors[keyof GetSessionErrors]; + +export type GetSessionResponses = { + /** + * Authenticated browser user. Login and signup also set the session cookie. + */ + 200: UserEnvelope; +}; + +export type GetSessionResponse = GetSessionResponses[keyof GetSessionResponses]; + +export type ListOrganizationsData = { + body?: never; + path?: never; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + }; + url: '/v1/organizations'; +}; + +export type ListOrganizationsErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; +}; + +export type ListOrganizationsError = ListOrganizationsErrors[keyof ListOrganizationsErrors]; + +export type ListOrganizationsResponses = { + /** + * Organizations + */ + 200: OrganizationList; +}; + +export type ListOrganizationsResponse = ListOrganizationsResponses[keyof ListOrganizationsResponses]; + +export type CreateOrganizationData = { + body: CreateOrganizationRequest; + path?: never; + query?: never; + url: '/v1/organizations'; +}; + +export type CreateOrganizationErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; +}; + +export type CreateOrganizationError = CreateOrganizationErrors[keyof CreateOrganizationErrors]; + +export type CreateOrganizationResponses = { + /** + * Organization + */ + 201: OrganizationEnvelope; +}; + +export type CreateOrganizationResponse = CreateOrganizationResponses[keyof CreateOrganizationResponses]; + +export type GetOrganizationData = { + body?: never; + path: { + organizationId: string; + }; + query?: never; + url: '/v1/organizations/{organizationId}'; +}; + +export type GetOrganizationErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; +}; + +export type GetOrganizationError = GetOrganizationErrors[keyof GetOrganizationErrors]; + +export type GetOrganizationResponses = { + /** + * Organization + */ + 200: OrganizationEnvelope; +}; + +export type GetOrganizationResponse = GetOrganizationResponses[keyof GetOrganizationResponses]; + +export type UpdateOrganizationData = { + body: CreateOrganizationRequest; + path: { + organizationId: string; + }; + query?: never; + url: '/v1/organizations/{organizationId}'; +}; + +export type UpdateOrganizationErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; +}; + +export type UpdateOrganizationError = UpdateOrganizationErrors[keyof UpdateOrganizationErrors]; + +export type UpdateOrganizationResponses = { + /** + * Organization + */ + 200: OrganizationEnvelope; +}; + +export type UpdateOrganizationResponse = UpdateOrganizationResponses[keyof UpdateOrganizationResponses]; + +export type ListMembersData = { + body?: never; + path: { + organizationId: string; + }; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + }; + url: '/v1/organizations/{organizationId}/members'; +}; + +export type ListMembersErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; +}; + +export type ListMembersError = ListMembersErrors[keyof ListMembersErrors]; + +export type ListMembersResponses = { + /** + * Memberships + */ + 200: MembershipList; +}; + +export type ListMembersResponse = ListMembersResponses[keyof ListMembersResponses]; + +export type AddMemberData = { + body: AddMemberRequest; + path: { + organizationId: string; + }; + query?: never; + url: '/v1/organizations/{organizationId}/members'; +}; + +export type AddMemberErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; +}; + +export type AddMemberError = AddMemberErrors[keyof AddMemberErrors]; + +export type AddMemberResponses = { + /** + * Membership + */ + 201: MembershipEnvelope; +}; + +export type AddMemberResponse = AddMemberResponses[keyof AddMemberResponses]; + +export type RemoveMemberData = { + body?: never; + path: { + organizationId: string; + actorId: string; + }; + query?: never; + url: '/v1/organizations/{organizationId}/members/{actorId}'; +}; + +export type RemoveMemberErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; +}; + +export type RemoveMemberError = RemoveMemberErrors[keyof RemoveMemberErrors]; + +export type RemoveMemberResponses = { + /** + * Member removed. + */ + 204: void; +}; + +export type RemoveMemberResponse = RemoveMemberResponses[keyof RemoveMemberResponses]; + +export type UpdateMemberData = { + body: UpdateMemberRequest; + path: { + organizationId: string; + actorId: string; + }; + query?: never; + url: '/v1/organizations/{organizationId}/members/{actorId}'; +}; + +export type UpdateMemberErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; +}; + +export type UpdateMemberError = UpdateMemberErrors[keyof UpdateMemberErrors]; + +export type UpdateMemberResponses = { + /** + * Membership + */ + 200: MembershipEnvelope; +}; + +export type UpdateMemberResponse = UpdateMemberResponses[keyof UpdateMemberResponses]; + +export type ListAuditEventsData = { + body?: never; + path: { + organizationId: string; + }; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + projectId?: string; + action?: string; + }; + url: '/v1/organizations/{organizationId}/audit-events'; +}; + +export type ListAuditEventsErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; +}; + +export type ListAuditEventsError = ListAuditEventsErrors[keyof ListAuditEventsErrors]; + +export type ListAuditEventsResponses = { + /** + * Audit events + */ + 200: AuditEventList; +}; + +export type ListAuditEventsResponse = ListAuditEventsResponses[keyof ListAuditEventsResponses]; + +export type ListProjectsData = { + body?: never; + path?: never; + query: { + organizationId: string; + status?: ProjectStatus; + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + }; + url: '/v1/projects'; +}; + +export type ListProjectsErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; +}; + +export type ListProjectsError = ListProjectsErrors[keyof ListProjectsErrors]; + +export type ListProjectsResponses = { + /** + * Projects + */ + 200: ProjectList; +}; + +export type ListProjectsResponse = ListProjectsResponses[keyof ListProjectsResponses]; + +export type CreateProjectData = { + body: CreateProjectRequest; + path?: never; + query?: never; + url: '/v1/projects'; +}; + +export type CreateProjectErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; +}; + +export type CreateProjectError = CreateProjectErrors[keyof CreateProjectErrors]; + +export type CreateProjectResponses = { + /** + * Project + */ + 201: ProjectEnvelope; +}; + +export type CreateProjectResponse = CreateProjectResponses[keyof CreateProjectResponses]; + +export type GetProjectData = { + body?: never; + path: { + projectId: string; + }; + query?: never; + url: '/v1/projects/{projectId}'; +}; + +export type GetProjectErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; +}; + +export type GetProjectError = GetProjectErrors[keyof GetProjectErrors]; + +export type GetProjectResponses = { + /** + * Project + */ + 200: ProjectEnvelope; +}; + +export type GetProjectResponse = GetProjectResponses[keyof GetProjectResponses]; + +export type UpdateProjectData = { + body: CreateOrganizationRequest; + path: { + projectId: string; + }; + query?: never; + url: '/v1/projects/{projectId}'; +}; + +export type UpdateProjectErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; + +export type UpdateProjectError = UpdateProjectErrors[keyof UpdateProjectErrors]; + +export type UpdateProjectResponses = { + /** + * Project + */ + 200: ProjectEnvelope; +}; + +export type UpdateProjectResponse = UpdateProjectResponses[keyof UpdateProjectResponses]; + +export type ArchiveProjectData = { + body?: never; + path: { + projectId: string; + }; + query?: never; + url: '/v1/projects/{projectId}/archive'; +}; + +export type ArchiveProjectErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; + +export type ArchiveProjectError = ArchiveProjectErrors[keyof ArchiveProjectErrors]; + +export type ArchiveProjectResponses = { + /** + * Project + */ + 200: ProjectEnvelope; +}; + +export type ArchiveProjectResponse = ArchiveProjectResponses[keyof ArchiveProjectResponses]; + +export type RestoreProjectData = { + body?: never; + path: { + projectId: string; + }; + query?: never; + url: '/v1/projects/{projectId}/restore'; +}; + +export type RestoreProjectErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; + +export type RestoreProjectError = RestoreProjectErrors[keyof RestoreProjectErrors]; + +export type RestoreProjectResponses = { + /** + * Project + */ + 200: ProjectEnvelope; +}; + +export type RestoreProjectResponse = RestoreProjectResponses[keyof RestoreProjectResponses]; + +export type ListApplicationsData = { + body?: never; + path: { + projectId: string; + }; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + }; + url: '/v1/projects/{projectId}/applications'; +}; + +export type ListApplicationsErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; + +export type ListApplicationsError = ListApplicationsErrors[keyof ListApplicationsErrors]; + +export type ListApplicationsResponses = { + /** + * Applications + */ + 200: ApplicationList; +}; + +export type ListApplicationsResponse = ListApplicationsResponses[keyof ListApplicationsResponses]; + +export type CreateApplicationData = { + body: CreateApplicationRequest; + path: { + projectId: string; + }; + query?: never; + url: '/v1/projects/{projectId}/applications'; +}; + +export type CreateApplicationErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; + +export type CreateApplicationError = CreateApplicationErrors[keyof CreateApplicationErrors]; + +export type CreateApplicationResponses = { + /** + * Application + */ + 201: ApplicationEnvelope; +}; + +export type CreateApplicationResponse = CreateApplicationResponses[keyof CreateApplicationResponses]; + +export type ListEnvironmentsData = { + body?: never; + path: { + projectId: string; + }; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + }; + url: '/v1/projects/{projectId}/environments'; +}; + +export type ListEnvironmentsErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; + +export type ListEnvironmentsError = ListEnvironmentsErrors[keyof ListEnvironmentsErrors]; + +export type ListEnvironmentsResponses = { + /** + * Environments + */ + 200: EnvironmentList; +}; + +export type ListEnvironmentsResponse = ListEnvironmentsResponses[keyof ListEnvironmentsResponses]; + +export type ListProviderConnectionsData = { + body?: never; + path: { + projectId: string; + }; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + }; + url: '/v1/projects/{projectId}/provider-connections'; +}; + +export type ListProviderConnectionsErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; + +export type ListProviderConnectionsError = ListProviderConnectionsErrors[keyof ListProviderConnectionsErrors]; + +export type ListProviderConnectionsResponses = { + /** + * Non-secret Provider Connections + */ + 200: ProviderConnectionList; +}; + +export type ListProviderConnectionsResponse = ListProviderConnectionsResponses[keyof ListProviderConnectionsResponses]; + +export type CreateProviderConnectionData = { + body: CreateProviderConnectionRequestWritable; + path: { + projectId: string; + }; + query?: never; + url: '/v1/projects/{projectId}/provider-connections'; +}; + +export type CreateProviderConnectionErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; + +export type CreateProviderConnectionError = CreateProviderConnectionErrors[keyof CreateProviderConnectionErrors]; + +export type CreateProviderConnectionResponses = { + /** + * Non-secret Provider Connection metadata + */ + 201: ProviderConnectionEnvelope; +}; + +export type CreateProviderConnectionResponse = CreateProviderConnectionResponses[keyof CreateProviderConnectionResponses]; + +export type ImportProviderProductsData = { + body: ProviderImportRequest; + headers: { + 'Idempotency-Key': string; + }; + path: { + projectId: string; + }; + query?: never; + url: '/v1/projects/{projectId}/provider-imports'; +}; + +export type ImportProviderProductsErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; + +export type ImportProviderProductsError = ImportProviderProductsErrors[keyof ImportProviderProductsErrors]; + +export type ImportProviderProductsResponses = { + /** + * Idempotent selected-import result with item-level outcomes. + */ + 200: { + data: ProviderImportResult; + }; +}; + +export type ImportProviderProductsResponse = ImportProviderProductsResponses[keyof ImportProviderProductsResponses]; + +export type ListApiKeysData = { + body?: never; + path: { + environmentId: string; + }; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + kind?: ApiKeyKind; + state?: 'active' | 'revoked'; }; + url: '/v1/environments/{environmentId}/api-keys'; }; -export type PaywallVersion = { - id: string; - projectId: string; - paywallId: string; - environmentId: string; - versionNumber: number; - sourceDraftId: string; - sourceRevision: number; - protocolVersion: '0.2'; - document: { - [key: string]: unknown; - }; - documentHash: string; - validation: ValidationSummary; - createdByActorId: string; - createdAt: Timestamp; - productIds: Array; +export type ListApiKeysErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type Placement = { - id: string; - projectId: string; - key: string; - name: string; - description?: string; - status: 'active' | 'archived'; - archivedAt?: Timestamp; - createdByActorId: string; - createdAt: Timestamp; - updatedAt: Timestamp; +export type ListApiKeysError = ListApiKeysErrors[keyof ListApiKeysErrors]; + +export type ListApiKeysResponses = { + /** + * API keys without secrets + */ + 200: ApiKeyList; }; -export type PlacementBinding = { - projectId: string; - environmentId: string; - placementId: string; - paywallId: string; - updatedByActorId: string; - updatedAt: Timestamp; +export type ListApiKeysResponse = ListApiKeysResponses[keyof ListApiKeysResponses]; + +export type CreateApiKeyData = { + body: CreateApiKeyRequest; + path: { + environmentId: string; + }; + query?: never; + url: '/v1/environments/{environmentId}/api-keys'; }; -export type User = { - id: string; - email: string; - name: string; - createdAt: Timestamp; +export type CreateApiKeyErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type Asset = { - id: string; - projectId: string; - kind: 'image' | 'video'; - originalFilename: string; - mediaType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'video/mp4'; - byteLength: number; - contentDigest: string; - url: string; - status: 'pending' | 'ready' | 'failed' | 'archived' | 'deleted'; - createdByActorId: string; - archivedAt?: Timestamp; - createdAt: Timestamp; - updatedAt: Timestamp; +export type CreateApiKeyError = CreateApiKeyErrors[keyof CreateApiKeyErrors]; + +export type CreateApiKeyResponses = { + /** + * One-time secret result + */ + 201: ApiKeySecretEnvelope; }; -export type AssetUsage = { - draftReferences: number; - versionReferences: number; - releaseReferences: number; +export type CreateApiKeyResponse = CreateApiKeyResponses[keyof CreateApiKeyResponses]; + +export type UpdateEnvironmentData = { + body: CreateOrganizationRequest; + path: { + environmentId: string; + }; + query?: never; + url: '/v1/environments/{environmentId}'; }; -export type ConfigurationRelease = { - id: string; - projectId: string; - environmentId: string; - releaseNumber: number; - deliveryContractVersion: '1'; - contentHash: string; - sourceReleaseId?: string; - rollbackSourceReleaseId?: string; - publishedByActorId: string; - publishedAt: Timestamp; +export type UpdateEnvironmentErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type PublishResult = { - release: ConfigurationRelease; - warnings: Array; +export type UpdateEnvironmentError = UpdateEnvironmentErrors[keyof UpdateEnvironmentErrors]; + +export type UpdateEnvironmentResponses = { + /** + * Environment + */ + 200: EnvironmentEnvelope; }; -export type ErrorEnvelope = { - error: { - code: string; - message: string; - fields?: { - [key: string]: Array; - }; - details?: { - [key: string]: unknown; - }; - requestId?: string; +export type UpdateEnvironmentResponse = UpdateEnvironmentResponses[keyof UpdateEnvironmentResponses]; + +export type SetEnvironmentModeData = { + body: SetEnvironmentModeRequest; + path: { + environmentId: string; }; + query?: never; + url: '/v1/environments/{environmentId}/mode'; }; -export type HealthEnvelope = { - data: { - status: 'ok' | 'ready'; - version: string; - commit?: string; - built?: string; - }; +export type SetEnvironmentModeErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type OrganizationEnvelope = { - data: Organization; +export type SetEnvironmentModeError = SetEnvironmentModeErrors[keyof SetEnvironmentModeErrors]; + +export type SetEnvironmentModeResponses = { + /** + * Environment + */ + 200: EnvironmentEnvelope; }; -export type MembershipEnvelope = { - data: Membership; +export type SetEnvironmentModeResponse = SetEnvironmentModeResponses[keyof SetEnvironmentModeResponses]; + +export type ClearActiveProviderAssignmentData = { + body?: never; + path: { + environmentId: string; + applicationId: string; + }; + query?: never; + url: '/v1/environments/{environmentId}/applications/{applicationId}/active-provider'; }; -export type ProjectEnvelope = { - data: Project; +export type ClearActiveProviderAssignmentErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type ApplicationEnvelope = { - data: Application; +export type ClearActiveProviderAssignmentError = ClearActiveProviderAssignmentErrors[keyof ClearActiveProviderAssignmentErrors]; + +export type ClearActiveProviderAssignmentResponses = { + /** + * The current assignment was cleared; its audit history remains immutable. + */ + 204: void; }; -export type EnvironmentEnvelope = { - data: Environment; +export type ClearActiveProviderAssignmentResponse = ClearActiveProviderAssignmentResponses[keyof ClearActiveProviderAssignmentResponses]; + +export type GetActiveProviderAssignmentData = { + body?: never; + path: { + environmentId: string; + applicationId: string; + }; + query?: never; + url: '/v1/environments/{environmentId}/applications/{applicationId}/active-provider'; }; -export type ProviderConnectionEnvelope = { - data: ProviderConnection; +export type GetActiveProviderAssignmentErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type ProviderAssignmentEnvelope = { - data: ActiveProviderAssignment; +export type GetActiveProviderAssignmentError = GetActiveProviderAssignmentErrors[keyof GetActiveProviderAssignmentErrors]; + +export type GetActiveProviderAssignmentResponses = { + /** + * Active provider assignment + */ + 200: ProviderAssignmentEnvelope; }; -export type ProviderReadinessEnvelope = { - data: ProviderReadiness; +export type GetActiveProviderAssignmentResponse = GetActiveProviderAssignmentResponses[keyof GetActiveProviderAssignmentResponses]; + +export type SetActiveProviderAssignmentData = { + body: SetProviderAssignmentRequest; + path: { + environmentId: string; + applicationId: string; + }; + query?: never; + url: '/v1/environments/{environmentId}/applications/{applicationId}/active-provider'; }; -export type ProviderMappingUsageEnvelope = { - data: ProviderMappingUsage; +export type SetActiveProviderAssignmentErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type ProviderMappingObservationEnvelope = { - data: ProviderMappingObservation; -}; +export type SetActiveProviderAssignmentError = SetActiveProviderAssignmentErrors[keyof SetActiveProviderAssignmentErrors]; -export type ProviderProfileEnvelope = { - data: ProviderProfile; +export type SetActiveProviderAssignmentResponses = { + /** + * Active provider assignment + */ + 200: ProviderAssignmentEnvelope; }; -export type ApiKeyEnvelope = { - data: ApiKey; -}; +export type SetActiveProviderAssignmentResponse = SetActiveProviderAssignmentResponses[keyof SetActiveProviderAssignmentResponses]; -export type ApiKeySecretEnvelope = { - data: ApiKeySecretResult; +export type GetProviderConnectionData = { + body?: never; + path: { + connectionId: string; + }; + query?: never; + url: '/v1/provider-connections/{connectionId}'; }; -export type PlanEnvelope = { - data: Plan; +export type GetProviderConnectionErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type ProductEnvelope = { - data: Product; -}; +export type GetProviderConnectionError = GetProviderConnectionErrors[keyof GetProviderConnectionErrors]; -export type ProductReadinessEnvelope = { - data: ProductReadiness; +export type GetProviderConnectionResponses = { + /** + * Non-secret Provider Connection metadata + */ + 200: ProviderConnectionEnvelope; }; -export type EntitlementEnvelope = { - data: Entitlement; -}; +export type GetProviderConnectionResponse = GetProviderConnectionResponses[keyof GetProviderConnectionResponses]; -export type PlanProductEnvelope = { - data: PlanProduct; +export type ReplaceProviderConnectionScopesData = { + body: ReplaceProviderConnectionScopesRequest; + path: { + connectionId: string; + }; + query?: never; + url: '/v1/provider-connections/{connectionId}/scopes'; }; -export type ProductEntitlementGrantEnvelope = { - data: ProductEntitlementGrant; +export type ReplaceProviderConnectionScopesErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type ProviderMappingEnvelope = { - data: ProviderProductMapping; -}; +export type ReplaceProviderConnectionScopesError = ReplaceProviderConnectionScopesErrors[keyof ReplaceProviderConnectionScopesErrors]; -export type ProductUsageEnvelope = { - data: ProductUsage; +export type ReplaceProviderConnectionScopesResponses = { + /** + * Non-secret Provider Connection metadata + */ + 200: ProviderConnectionEnvelope; }; -export type OrganizationListEnvelope = OrganizationList; - -export type MembershipListEnvelope = MembershipList; +export type ReplaceProviderConnectionScopesResponse = ReplaceProviderConnectionScopesResponses[keyof ReplaceProviderConnectionScopesResponses]; -export type ProjectListEnvelope = ProjectList; +export type RevokeProviderConnectionData = { + body?: never; + path: { + connectionId: string; + }; + query?: never; + url: '/v1/provider-connections/{connectionId}/revoke'; +}; -export type ApplicationListEnvelope = ApplicationList; +export type RevokeProviderConnectionErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; -export type EnvironmentListEnvelope = EnvironmentList; +export type RevokeProviderConnectionError = RevokeProviderConnectionErrors[keyof RevokeProviderConnectionErrors]; -export type ProviderConnectionListEnvelope = ProviderConnectionList; +export type RevokeProviderConnectionResponses = { + /** + * Non-secret Provider Connection metadata + */ + 200: ProviderConnectionEnvelope; +}; -export type ApiKeyListEnvelope = ApiKeyList; +export type RevokeProviderConnectionResponse = RevokeProviderConnectionResponses[keyof RevokeProviderConnectionResponses]; -export type PlanListEnvelope = PlanList; +export type TestProviderConnectionData = { + body?: never; + path: { + connectionId: string; + }; + query?: never; + url: '/v1/provider-connections/{connectionId}/test'; +}; -export type ProductListEnvelope = ProductList; +export type TestProviderConnectionErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; -export type EntitlementListEnvelope = EntitlementList; +export type TestProviderConnectionError = TestProviderConnectionErrors[keyof TestProviderConnectionErrors]; -export type ProviderMappingListEnvelope = ProviderMappingList; +export type TestProviderConnectionResponses = { + /** + * Safe connection health and capability summary. + */ + 200: { + data: ProviderConnectionHealth; + }; +}; -export type AuditEventListEnvelope = AuditEventList; +export type TestProviderConnectionResponse = TestProviderConnectionResponses[keyof TestProviderConnectionResponses]; -export type PaywallEnvelope = { - data: Paywall; +export type GetProviderConnectionHealthData = { + body?: never; + path: { + connectionId: string; + }; + query?: never; + url: '/v1/provider-connections/{connectionId}/health'; }; -export type DraftEnvelope = { - data: DraftResource; +export type GetProviderConnectionHealthErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type ValidationSummaryEnvelope = { - data: ValidationSummary; -}; +export type GetProviderConnectionHealthError = GetProviderConnectionHealthErrors[keyof GetProviderConnectionHealthErrors]; -export type PaywallVersionEnvelope = { - data: PaywallVersion; +export type GetProviderConnectionHealthResponses = { + /** + * Safe connection health and capability summary. + */ + 200: { + data: ProviderConnectionHealth; + }; }; -export type PlacementEnvelope = { - data: Placement; -}; +export type GetProviderConnectionHealthResponse = GetProviderConnectionHealthResponses[keyof GetProviderConnectionHealthResponses]; -export type PlacementBindingEnvelope = { - data: PlacementBinding; +export type GetProviderConnectionCapabilitiesData = { + body?: never; + path: { + connectionId: string; + }; + query?: never; + url: '/v1/provider-connections/{connectionId}/capabilities'; }; -export type AssetEnvelope = { - data: Asset; +export type GetProviderConnectionCapabilitiesErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type AssetUsageEnvelope = { - data: AssetUsage; -}; +export type GetProviderConnectionCapabilitiesError = GetProviderConnectionCapabilitiesErrors[keyof GetProviderConnectionCapabilitiesErrors]; -export type AssetListEnvelope = { - data: { - items: Array; - page: Page; +export type GetProviderConnectionCapabilitiesResponses = { + /** + * Closed provider capability matrix and required least-privilege permissions. + */ + 200: { + data: ProviderConnectionCapabilities; }; }; -export type UserEnvelope = { - data: User; -}; +export type GetProviderConnectionCapabilitiesResponse = GetProviderConnectionCapabilitiesResponses[keyof GetProviderConnectionCapabilitiesResponses]; -export type ReleaseEnvelope = { - data: ConfigurationRelease; +export type ListProviderConnectionDiagnosticsData = { + body?: never; + path: { + connectionId: string; + }; + query?: never; + url: '/v1/provider-connections/{connectionId}/diagnostics'; }; -export type PublishResultEnvelope = { - data: PublishResult; +export type ListProviderConnectionDiagnosticsErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type PaywallListEnvelope = { - data: { - items: Array; - page: Page; +export type ListProviderConnectionDiagnosticsError = ListProviderConnectionDiagnosticsErrors[keyof ListProviderConnectionDiagnosticsErrors]; + +export type ListProviderConnectionDiagnosticsResponses = { + /** + * Safe provider diagnostics without provider response bodies or secrets. + */ + 200: { + data: { + items: Array; + }; }; }; -export type PaywallVersionListEnvelope = { - data: { - items: Array; - page: Page; +export type ListProviderConnectionDiagnosticsResponse = ListProviderConnectionDiagnosticsResponses[keyof ListProviderConnectionDiagnosticsResponses]; + +export type PreviewProviderCatalogData = { + body?: never; + path: { + connectionId: string; }; + query?: never; + url: '/v1/provider-connections/{connectionId}/catalog-preview'; +}; + +export type PreviewProviderCatalogErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type PlacementListEnvelope = { - data: { - items: Array; - page: Page; +export type PreviewProviderCatalogError = PreviewProviderCatalogErrors[keyof PreviewProviderCatalogErrors]; + +export type PreviewProviderCatalogResponses = { + /** + * Normalized live provider catalog preview. + */ + 200: { + data: ProviderCatalogPreview; }; }; -export type ReleaseListEnvelope = { - data: { - items: Array; - page: Page; +export type PreviewProviderCatalogResponse = PreviewProviderCatalogResponses[keyof PreviewProviderCatalogResponses]; + +export type RotateProviderCredentialData = { + body: ProviderCredentialRequest; + path: { + connectionId: string; }; + query?: never; + url: '/v1/provider-connections/{connectionId}/rotate-credential'; }; -export type OrganizationList = { - data: { - items: Array; - page: Page; - }; +export type RotateProviderCredentialErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type MembershipList = { - data: { - items: Array; - page: Page; - }; +export type RotateProviderCredentialError = RotateProviderCredentialErrors[keyof RotateProviderCredentialErrors]; + +export type RotateProviderCredentialResponses = { + /** + * Non-secret Provider Connection metadata + */ + 200: ProviderConnectionEnvelope; }; -export type ProjectList = { - data: { - items: Array; - page: Page; +export type RotateProviderCredentialResponse = RotateProviderCredentialResponses[keyof RotateProviderCredentialResponses]; + +export type ReconnectProviderConnectionData = { + body: ProviderCredentialRequest; + path: { + connectionId: string; }; + query?: never; + url: '/v1/provider-connections/{connectionId}/reconnect'; }; -export type ApplicationList = { - data: { - items: Array; - page: Page; - }; +export type ReconnectProviderConnectionErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type EnvironmentList = { - data: { - items: Array; - page: Page; - }; +export type ReconnectProviderConnectionError = ReconnectProviderConnectionErrors[keyof ReconnectProviderConnectionErrors]; + +export type ReconnectProviderConnectionResponses = { + /** + * Non-secret Provider Connection metadata + */ + 200: ProviderConnectionEnvelope; }; -export type ProviderConnectionList = { - data: { - items: Array; - page: Page; +export type ReconnectProviderConnectionResponse = ReconnectProviderConnectionResponses[keyof ReconnectProviderConnectionResponses]; + +export type EnqueueProviderSyncData = { + body?: never; + path: { + connectionId: string; }; + query?: never; + url: '/v1/provider-connections/{connectionId}/sync'; }; -export type ApiKeyList = { - data: { - items: Array; - page: Page; - }; +export type EnqueueProviderSyncErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type PlanList = { - data: { - items: Array; - page: Page; +export type EnqueueProviderSyncError = EnqueueProviderSyncErrors[keyof EnqueueProviderSyncErrors]; + +export type EnqueueProviderSyncResponses = { + /** + * Accepted provider synchronization job. + */ + 202: { + data: ProviderSyncJob; }; }; -export type ProductList = { - data: { - items: Array; - page: Page; +export type EnqueueProviderSyncResponse = EnqueueProviderSyncResponses[keyof EnqueueProviderSyncResponses]; + +export type ListProviderSyncRunsData = { + body?: never; + path: { + connectionId: string; }; + query?: never; + url: '/v1/provider-connections/{connectionId}/sync-runs'; }; -export type EntitlementList = { - data: { - items: Array; - page: Page; - }; +export type ListProviderSyncRunsErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type ProviderMappingList = { - data: { - items: Array; - page: Page; +export type ListProviderSyncRunsError = ListProviderSyncRunsErrors[keyof ListProviderSyncRunsErrors]; + +export type ListProviderSyncRunsResponses = { + /** + * Provider synchronization run history. + */ + 200: { + data: { + items: Array; + }; }; }; -export type AuditEventList = { - data: { - items: Array; - page: Page; +export type ListProviderSyncRunsResponse = ListProviderSyncRunsResponses[keyof ListProviderSyncRunsResponses]; + +export type RotateApiKeyData = { + body?: never; + path: { + apiKeyId: string; }; + query?: never; + url: '/v1/api-keys/{apiKeyId}/rotate'; }; -export type ExperimentQaOverrideCreatedWritable = { - override: ExperimentQaOverride; +export type RotateApiKeyErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; }; -export type ExperimentQaOverrideCreatedEnvelopeWritable = { - data: ExperimentQaOverrideCreatedWritable; -}; +export type RotateApiKeyError = RotateApiKeyErrors[keyof RotateApiKeyErrors]; -export type CreateQaOverrideRequestWritable = { - safeLabel: string; - selector: string; - outcome: PlacementOutcome; - expiresAt: string; +export type RotateApiKeyResponses = { + /** + * One-time secret result + */ + 200: ApiKeySecretEnvelope; }; -export type PlacementSimulationRequestWritable = { - platform?: 'ios' | 'android'; - osVersion?: string; - applicationVersion?: string; - locale?: string; - country?: string; - installationId?: string; - userId?: string; - attributes?: { - [key: string]: unknown; - }; - entitlements?: { - [key: string]: unknown; - }; - productAvailability?: { - [key: string]: unknown; - }; - productReadiness?: { - [key: string]: unknown; - }; - providerCapabilities?: { - [key: string]: unknown; +export type RotateApiKeyResponse = RotateApiKeyResponses[keyof RotateApiKeyResponses]; + +export type RevokeApiKeyData = { + body?: never; + path: { + apiKeyId: string; }; - overrideToken?: string; + query?: never; + url: '/v1/api-keys/{apiKeyId}/revoke'; }; -export type CreateProviderConnectionRequestWritable = unknown & { - name: string; - provider: ProviderConnectionKind; - integrationMode: ProviderIntegrationMode; - mode: ProviderConnectionMode; +export type RevokeApiKeyErrors = { /** - * Required RevenueCat v2 Project resource ID. + * Stable machine-readable failure. */ - externalProjectId?: string; + default: ErrorEnvelope; +}; + +export type RevokeApiKeyError = RevokeApiKeyErrors[keyof RevokeApiKeyErrors]; + +export type RevokeApiKeyResponses = { /** - * One-time RevenueCat v2 least-privilege secret key. Never returned or logged. + * API key metadata */ - credential?: string; - environmentIds: Array; - applicationIds: Array; + 200: ApiKeyEnvelope; }; -/** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ -export type Cursor = string; +export type RevokeApiKeyResponse = RevokeApiKeyResponses[keyof RevokeApiKeyResponses]; -export type Limit = number; +export type ListPlansData = { + body?: never; + path: { + projectId: string; + }; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + }; + url: '/v1/projects/{projectId}/plans'; +}; + +export type ListPlansErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; -export type OrganizationId = string; +export type ListPlansError = ListPlansErrors[keyof ListPlansErrors]; -export type ActorId = string; +export type ListPlansResponses = { + /** + * Plans + */ + 200: PlanList; +}; -export type ProjectId = string; +export type ListPlansResponse = ListPlansResponses[keyof ListPlansResponses]; -export type EnvironmentId = string; +export type CreatePlanData = { + body: CreateCatalogResourceRequest; + path: { + projectId: string; + }; + query?: never; + url: '/v1/projects/{projectId}/plans'; +}; -export type ApplicationId = string; +export type CreatePlanErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; -export type ProviderConnectionId = string; +export type CreatePlanError = CreatePlanErrors[keyof CreatePlanErrors]; -export type ProviderMappingId = string; +export type CreatePlanResponses = { + /** + * Plan + */ + 201: PlanEnvelope; +}; -export type StoreCredentialId = string; +export type CreatePlanResponse = CreatePlanResponses[keyof CreatePlanResponses]; -export type QuarantineRecordId = string; +export type GetPlanData = { + body?: never; + path: { + planId: string; + }; + query?: never; + url: '/v1/plans/{planId}'; +}; -export type BillingProviderFilter = 'app_store' | 'google_play'; +export type GetPlanErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; -export type BillingFrom = string; +export type GetPlanError = GetPlanErrors[keyof GetPlanErrors]; -export type BillingTo = string; +export type GetPlanResponses = { + /** + * Plan + */ + 200: PlanEnvelope; +}; -export type ApiKeyId = string; +export type GetPlanResponse = GetPlanResponses[keyof GetPlanResponses]; -export type PlanId = string; +export type UpdatePlanData = { + body: CreateCatalogResourceRequest; + path: { + planId: string; + }; + query?: never; + url: '/v1/plans/{planId}'; +}; -export type ProductId = string; +export type UpdatePlanErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; -export type EntitlementId = string; +export type UpdatePlanError = UpdatePlanErrors[keyof UpdatePlanErrors]; -export type PaywallId = string; +export type UpdatePlanResponses = { + /** + * Plan + */ + 200: PlanEnvelope; +}; -export type DraftId = string; +export type UpdatePlanResponse = UpdatePlanResponses[keyof UpdatePlanResponses]; -export type VersionId = string; +export type ListPlanProductsData = { + body?: never; + path: { + planId: string; + }; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + }; + url: '/v1/plans/{planId}/products'; +}; -export type PlacementId = string; +export type ListPlanProductsErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; -export type RuleSetId = string; +export type ListPlanProductsError = ListPlanProductsErrors[keyof ListPlanProductsErrors]; -export type ExperimentId = string; +export type ListPlanProductsResponses = { + /** + * Products + */ + 200: ProductList; +}; -export type AssetId = string; +export type ListPlanProductsResponse = ListPlanProductsResponses[keyof ListPlanProductsResponses]; -export type ReleaseId = string; +export type AddPlanProductData = { + body: ProductReferenceRequest; + path: { + planId: string; + }; + query?: never; + url: '/v1/plans/{planId}/products'; +}; -export type IdempotencyKey = string; +export type AddPlanProductErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; -export type IfMatch = string; +export type AddPlanProductError = AddPlanProductErrors[keyof AddPlanProductErrors]; -export type AnalyticsFrom = Timestamp; +export type AddPlanProductResponses = { + /** + * Plan membership + */ + 201: PlanProductEnvelope; +}; -export type AnalyticsTo = Timestamp; +export type AddPlanProductResponse = AddPlanProductResponses[keyof AddPlanProductResponses]; -export type AnalyticsTimezone = string; +export type RemovePlanProductData = { + body?: never; + path: { + planId: string; + productId: string; + }; + query?: never; + url: '/v1/plans/{planId}/products/{productId}'; +}; -export type AnalyticsMetricBasis = 'event_count'; +export type RemovePlanProductErrors = { + /** + * Stable machine-readable failure. + */ + default: ErrorEnvelope; +}; -export type AnalyticsPlatform = 'ios' | 'android'; +export type RemovePlanProductError = RemovePlanProductErrors[keyof RemovePlanProductErrors]; -export type AnalyticsLocale = string; +export type RemovePlanProductResponses = { + /** + * Product removed from Plan. + */ + 204: void; +}; -export type AnalyticsApplicationVersion = string; +export type RemovePlanProductResponse = RemovePlanProductResponses[keyof RemovePlanProductResponses]; -export type GetHealthData = { +export type ListProductsData = { body?: never; - path?: never; - query?: never; - url: '/health/live'; + path: { + projectId: string; + }; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + status?: ProductStatus; + type?: ProductType; + search?: string; + }; + url: '/v1/projects/{projectId}/products'; }; -export type GetHealthResponses = { +export type ListProductsErrors = { /** - * Process liveness. + * Stable machine-readable failure. */ - 200: HealthEnvelope; + default: ErrorEnvelope; }; -export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]; +export type ListProductsError = ListProductsErrors[keyof ListProductsErrors]; -export type GetReadinessData = { - body?: never; - path?: never; +export type ListProductsResponses = { + /** + * Products + */ + 200: ProductList; +}; + +export type ListProductsResponse = ListProductsResponses[keyof ListProductsResponses]; + +export type CreateProductData = { + body: CreateProductRequest; + path: { + projectId: string; + }; query?: never; - url: '/health/ready'; + url: '/v1/projects/{projectId}/products'; }; -export type GetReadinessErrors = { +export type CreateProductErrors = { /** - * PostgreSQL is unavailable. + * Stable machine-readable failure. */ - 503: ErrorEnvelope; + default: ErrorEnvelope; }; -export type GetReadinessError = GetReadinessErrors[keyof GetReadinessErrors]; +export type CreateProductError = CreateProductErrors[keyof CreateProductErrors]; -export type GetReadinessResponses = { +export type CreateProductResponses = { /** - * PostgreSQL is reachable and the API is ready to serve traffic. + * Product */ - 200: HealthEnvelope; + 201: ProductEnvelope; }; -export type GetReadinessResponse = GetReadinessResponses[keyof GetReadinessResponses]; +export type CreateProductResponse = CreateProductResponses[keyof CreateProductResponses]; -export type SignUpData = { - body: SignUpRequest; - path?: never; +export type DeleteProductData = { + body?: never; + path: { + productId: string; + }; query?: never; - url: '/v1/auth/signup'; + url: '/v1/products/{productId}'; }; -export type SignUpErrors = { - /** - * Stable machine-readable failure. - */ - 409: ErrorEnvelope; +export type DeleteProductErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + default: ErrorEnvelope; }; -export type SignUpError = SignUpErrors[keyof SignUpErrors]; +export type DeleteProductError = DeleteProductErrors[keyof DeleteProductErrors]; -export type SignUpResponses = { +export type DeleteProductResponses = { /** - * Authenticated browser user. Login and signup also set the session cookie. + * Unreferenced Product deleted. */ - 201: UserEnvelope; + 204: void; }; -export type SignUpResponse = SignUpResponses[keyof SignUpResponses]; +export type DeleteProductResponse = DeleteProductResponses[keyof DeleteProductResponses]; -export type LoginData = { - body: LoginRequest; - path?: never; +export type GetProductData = { + body?: never; + path: { + productId: string; + }; query?: never; - url: '/v1/auth/login'; + url: '/v1/products/{productId}'; }; -export type LoginErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; +export type GetProductErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + default: ErrorEnvelope; }; -export type LoginError = LoginErrors[keyof LoginErrors]; +export type GetProductError = GetProductErrors[keyof GetProductErrors]; -export type LoginResponses = { +export type GetProductResponses = { /** - * Authenticated browser user. Login and signup also set the session cookie. + * Product */ - 200: UserEnvelope; + 200: ProductEnvelope; }; -export type LoginResponse = LoginResponses[keyof LoginResponses]; +export type GetProductResponse = GetProductResponses[keyof GetProductResponses]; -export type LogoutData = { - body?: never; - path?: never; +export type UpdateProductData = { + body: CreateProductRequest; + path: { + productId: string; + }; query?: never; - url: '/v1/auth/logout'; + url: '/v1/products/{productId}'; }; -export type LogoutResponses = { +export type UpdateProductErrors = { /** - * Session revoked and cookie cleared. + * Stable machine-readable failure. */ - 204: void; + default: ErrorEnvelope; }; -export type LogoutResponse = LogoutResponses[keyof LogoutResponses]; +export type UpdateProductError = UpdateProductErrors[keyof UpdateProductErrors]; -export type GetSessionData = { +export type UpdateProductResponses = { + /** + * Product + */ + 200: ProductEnvelope; +}; + +export type UpdateProductResponse = UpdateProductResponses[keyof UpdateProductResponses]; + +export type ArchiveProductData = { body?: never; - path?: never; + path: { + productId: string; + }; query?: never; - url: '/v1/auth/session'; + url: '/v1/products/{productId}/archive'; }; -export type GetSessionErrors = { +export type ArchiveProductErrors = { /** * Stable machine-readable failure. */ - 401: ErrorEnvelope; + default: ErrorEnvelope; }; -export type GetSessionError = GetSessionErrors[keyof GetSessionErrors]; +export type ArchiveProductError = ArchiveProductErrors[keyof ArchiveProductErrors]; -export type GetSessionResponses = { +export type ArchiveProductResponses = { /** - * Authenticated browser user. Login and signup also set the session cookie. + * Product */ - 200: UserEnvelope; + 200: ProductEnvelope; }; -export type GetSessionResponse = GetSessionResponses[keyof GetSessionResponses]; +export type ArchiveProductResponse = ArchiveProductResponses[keyof ArchiveProductResponses]; -export type ListOrganizationsData = { +export type RestoreProductData = { body?: never; - path?: never; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; + path: { + productId: string; }; - url: '/v1/organizations'; + query?: never; + url: '/v1/products/{productId}/restore'; }; -export type ListOrganizationsErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; +export type RestoreProductErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + default: ErrorEnvelope; }; -export type ListOrganizationsError = ListOrganizationsErrors[keyof ListOrganizationsErrors]; +export type RestoreProductError = RestoreProductErrors[keyof RestoreProductErrors]; -export type ListOrganizationsResponses = { +export type RestoreProductResponses = { /** - * Organizations + * Product */ - 200: OrganizationList; + 200: ProductEnvelope; }; -export type ListOrganizationsResponse = ListOrganizationsResponses[keyof ListOrganizationsResponses]; +export type RestoreProductResponse = RestoreProductResponses[keyof RestoreProductResponses]; -export type CreateOrganizationData = { - body: CreateOrganizationRequest; - path?: never; +export type SetProductReplacementData = { + body: ProductReferenceRequest; + path: { + productId: string; + }; query?: never; - url: '/v1/organizations'; + url: '/v1/products/{productId}/replacement'; }; -export type CreateOrganizationErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; +export type SetProductReplacementErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + default: ErrorEnvelope; }; -export type CreateOrganizationError = CreateOrganizationErrors[keyof CreateOrganizationErrors]; +export type SetProductReplacementError = SetProductReplacementErrors[keyof SetProductReplacementErrors]; -export type CreateOrganizationResponses = { +export type SetProductReplacementResponses = { /** - * Organization + * Product */ - 201: OrganizationEnvelope; + 200: ProductEnvelope; }; -export type CreateOrganizationResponse = CreateOrganizationResponses[keyof CreateOrganizationResponses]; +export type SetProductReplacementResponse = SetProductReplacementResponses[keyof SetProductReplacementResponses]; -export type GetOrganizationData = { +export type GetProductUsageData = { body?: never; path: { - organizationId: string; + productId: string; }; query?: never; - url: '/v1/organizations/{organizationId}'; + url: '/v1/products/{productId}/usage'; }; -export type GetOrganizationErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 403: ErrorEnvelope; +export type GetProductUsageErrors = { /** * Stable machine-readable failure. */ - 404: ErrorEnvelope; + default: ErrorEnvelope; }; -export type GetOrganizationError = GetOrganizationErrors[keyof GetOrganizationErrors]; +export type GetProductUsageError = GetProductUsageErrors[keyof GetProductUsageErrors]; -export type GetOrganizationResponses = { +export type GetProductUsageResponses = { /** - * Organization + * Product usage */ - 200: OrganizationEnvelope; + 200: ProductUsageEnvelope; }; -export type GetOrganizationResponse = GetOrganizationResponses[keyof GetOrganizationResponses]; +export type GetProductUsageResponse = GetProductUsageResponses[keyof GetProductUsageResponses]; -export type UpdateOrganizationData = { - body: CreateOrganizationRequest; +export type GetProductReadinessData = { + body?: never; path: { - organizationId: string; + productId: string; }; - query?: never; - url: '/v1/organizations/{organizationId}'; + query: { + environmentId: string; + applicationId: string; + }; + url: '/v1/products/{productId}/readiness'; }; -export type UpdateOrganizationErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 403: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 404: ErrorEnvelope; +export type GetProductReadinessErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + default: ErrorEnvelope; }; -export type UpdateOrganizationError = UpdateOrganizationErrors[keyof UpdateOrganizationErrors]; +export type GetProductReadinessError = GetProductReadinessErrors[keyof GetProductReadinessErrors]; -export type UpdateOrganizationResponses = { +export type GetProductReadinessResponses = { /** - * Organization + * Scoped provider readiness and stable recovery codes */ - 200: OrganizationEnvelope; + 200: ProviderReadinessEnvelope; }; -export type UpdateOrganizationResponse = UpdateOrganizationResponses[keyof UpdateOrganizationResponses]; +export type GetProductReadinessResponse = GetProductReadinessResponses[keyof GetProductReadinessResponses]; -export type ListMembersData = { +export type ListProviderMappingsData = { body?: never; path: { - organizationId: string; + productId: string; }; query?: { /** @@ -2846,455 +5678,424 @@ export type ListMembersData = { cursor?: string; limit?: number; }; - url: '/v1/organizations/{organizationId}/members'; + url: '/v1/products/{productId}/provider-mappings'; }; -export type ListMembersErrors = { +export type ListProviderMappingsErrors = { /** * Stable machine-readable failure. */ - 401: ErrorEnvelope; + default: ErrorEnvelope; +}; + +export type ListProviderMappingsError = ListProviderMappingsErrors[keyof ListProviderMappingsErrors]; + +export type ListProviderMappingsResponses = { /** - * Stable machine-readable failure. + * Placeholder mappings */ - 403: ErrorEnvelope; + 200: ProviderMappingList; +}; + +export type ListProviderMappingsResponse = ListProviderMappingsResponses[keyof ListProviderMappingsResponses]; + +export type CreateProviderMappingData = { + body: CreateProviderMappingRequest; + path: { + productId: string; + }; + query?: never; + url: '/v1/products/{productId}/provider-mappings'; +}; + +export type CreateProviderMappingErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + default: ErrorEnvelope; }; -export type ListMembersError = ListMembersErrors[keyof ListMembersErrors]; +export type CreateProviderMappingError = CreateProviderMappingErrors[keyof CreateProviderMappingErrors]; -export type ListMembersResponses = { +export type CreateProviderMappingResponses = { /** - * Memberships + * Placeholder mapping */ - 200: MembershipList; + 201: ProviderMappingEnvelope; }; -export type ListMembersResponse = ListMembersResponses[keyof ListMembersResponses]; +export type CreateProviderMappingResponse = CreateProviderMappingResponses[keyof CreateProviderMappingResponses]; -export type AddMemberData = { - body: AddMemberRequest; +export type CreateProviderMappingDraftData = { + body: CreateProviderMappingDraftRequest; path: { - organizationId: string; + productId: string; }; query?: never; - url: '/v1/organizations/{organizationId}/members'; + url: '/v1/products/{productId}/provider-mapping-drafts'; }; -export type AddMemberErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; +export type CreateProviderMappingDraftErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + default: ErrorEnvelope; +}; + +export type CreateProviderMappingDraftError = CreateProviderMappingDraftErrors[keyof CreateProviderMappingDraftErrors]; + +export type CreateProviderMappingDraftResponses = { /** - * Stable machine-readable failure. + * Placeholder mapping */ - 409: ErrorEnvelope; + 201: ProviderMappingEnvelope; +}; + +export type CreateProviderMappingDraftResponse = CreateProviderMappingDraftResponses[keyof CreateProviderMappingDraftResponses]; + +export type GetProviderReadinessData = { + body?: never; + path: { + productId: string; + }; + query: { + environmentId: string; + applicationId: string; + }; + url: '/v1/products/{productId}/provider-readiness'; +}; + +export type GetProviderReadinessErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + default: ErrorEnvelope; }; -export type AddMemberError = AddMemberErrors[keyof AddMemberErrors]; +export type GetProviderReadinessError = GetProviderReadinessErrors[keyof GetProviderReadinessErrors]; -export type AddMemberResponses = { +export type GetProviderReadinessResponses = { /** - * Membership + * Scoped provider readiness and stable recovery codes */ - 201: MembershipEnvelope; + 200: ProviderReadinessEnvelope; }; -export type AddMemberResponse = AddMemberResponses[keyof AddMemberResponses]; +export type GetProviderReadinessResponse = GetProviderReadinessResponses[keyof GetProviderReadinessResponses]; -export type RemoveMemberData = { +export type ArchiveProviderMappingData = { body?: never; path: { - organizationId: string; - actorId: string; + mappingId: string; }; query?: never; - url: '/v1/organizations/{organizationId}/members/{actorId}'; + url: '/v1/provider-mappings/{mappingId}/archive'; }; -export type RemoveMemberErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 403: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 404: ErrorEnvelope; +export type ArchiveProviderMappingErrors = { /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + default: ErrorEnvelope; }; -export type RemoveMemberError = RemoveMemberErrors[keyof RemoveMemberErrors]; +export type ArchiveProviderMappingError = ArchiveProviderMappingErrors[keyof ArchiveProviderMappingErrors]; -export type RemoveMemberResponses = { +export type ArchiveProviderMappingResponses = { /** - * Member removed. + * Placeholder mapping */ - 204: void; + 200: ProviderMappingEnvelope; }; -export type RemoveMemberResponse = RemoveMemberResponses[keyof RemoveMemberResponses]; +export type ArchiveProviderMappingResponse = ArchiveProviderMappingResponses[keyof ArchiveProviderMappingResponses]; -export type UpdateMemberData = { - body: UpdateMemberRequest; +export type ReplaceProviderMappingData = { + body: ReplaceProviderMappingRequest; path: { - organizationId: string; - actorId: string; + mappingId: string; }; query?: never; - url: '/v1/organizations/{organizationId}/members/{actorId}'; + url: '/v1/provider-mappings/{mappingId}/replace'; }; -export type UpdateMemberErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 403: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 404: ErrorEnvelope; +export type ReplaceProviderMappingErrors = { /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + default: ErrorEnvelope; }; -export type UpdateMemberError = UpdateMemberErrors[keyof UpdateMemberErrors]; +export type ReplaceProviderMappingError = ReplaceProviderMappingErrors[keyof ReplaceProviderMappingErrors]; -export type UpdateMemberResponses = { +export type ReplaceProviderMappingResponses = { /** - * Membership + * Placeholder mapping */ - 200: MembershipEnvelope; + 201: ProviderMappingEnvelope; }; -export type UpdateMemberResponse = UpdateMemberResponses[keyof UpdateMemberResponses]; +export type ReplaceProviderMappingResponse = ReplaceProviderMappingResponses[keyof ReplaceProviderMappingResponses]; -export type ListAuditEventsData = { +export type GetProviderMappingMetadataData = { body?: never; path: { - organizationId: string; - }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; - projectId?: string; - action?: string; + mappingId: string; }; - url: '/v1/organizations/{organizationId}/audit-events'; + query?: never; + url: '/v1/provider-mappings/{mappingId}/metadata'; }; -export type ListAuditEventsErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 403: ErrorEnvelope; +export type GetProviderMappingMetadataErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + default: ErrorEnvelope; }; -export type ListAuditEventsError = ListAuditEventsErrors[keyof ListAuditEventsErrors]; +export type GetProviderMappingMetadataError = GetProviderMappingMetadataErrors[keyof GetProviderMappingMetadataErrors]; -export type ListAuditEventsResponses = { +export type GetProviderMappingMetadataResponses = { /** - * Audit events + * Current immutable normalized provider metadata and freshness evidence. */ - 200: AuditEventList; + 200: { + data: ProviderProductMetadataSnapshot; + }; }; -export type ListAuditEventsResponse = ListAuditEventsResponses[keyof ListAuditEventsResponses]; +export type GetProviderMappingMetadataResponse = GetProviderMappingMetadataResponses[keyof GetProviderMappingMetadataResponses]; -export type ListProjectsData = { +export type GetProviderMappingUsageData = { body?: never; - path?: never; - query: { - organizationId: string; - status?: ProjectStatus; - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; + path: { + mappingId: string; }; - url: '/v1/projects'; + query?: never; + url: '/v1/provider-mappings/{mappingId}/usage'; }; -export type ListProjectsErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 403: ErrorEnvelope; +export type GetProviderMappingUsageErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + default: ErrorEnvelope; }; -export type ListProjectsError = ListProjectsErrors[keyof ListProjectsErrors]; +export type GetProviderMappingUsageError = GetProviderMappingUsageErrors[keyof GetProviderMappingUsageErrors]; -export type ListProjectsResponses = { +export type GetProviderMappingUsageResponses = { /** - * Projects + * Mapping-specific replacement impact */ - 200: ProjectList; + 200: ProviderMappingUsageEnvelope; }; -export type ListProjectsResponse = ListProjectsResponses[keyof ListProjectsResponses]; +export type GetProviderMappingUsageResponse = GetProviderMappingUsageResponses[keyof GetProviderMappingUsageResponses]; -export type CreateProjectData = { - body: CreateProjectRequest; - path?: never; +export type ListProviderMappingObservationsData = { + body?: never; + path: { + mappingId: string; + }; query?: never; - url: '/v1/projects'; + url: '/v1/provider-mappings/{mappingId}/observations'; }; -export type CreateProjectErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 403: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 409: ErrorEnvelope; +export type ListProviderMappingObservationsErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + default: ErrorEnvelope; }; -export type CreateProjectError = CreateProjectErrors[keyof CreateProjectErrors]; +export type ListProviderMappingObservationsError = ListProviderMappingObservationsErrors[keyof ListProviderMappingObservationsErrors]; -export type CreateProjectResponses = { +export type ListProviderMappingObservationsResponses = { /** - * Project + * Immutable native-store observation history. */ - 201: ProjectEnvelope; + 200: { + data: Array; + }; }; -export type CreateProjectResponse = CreateProjectResponses[keyof CreateProjectResponses]; +export type ListProviderMappingObservationsResponse = ListProviderMappingObservationsResponses[keyof ListProviderMappingObservationsResponses]; -export type GetProjectData = { - body?: never; +export type CreateProviderMappingObservationData = { + body: CreateProviderMappingObservationRequest; path: { - projectId: string; + mappingId: string; }; query?: never; - url: '/v1/projects/{projectId}'; + url: '/v1/provider-mappings/{mappingId}/observations'; }; -export type GetProjectErrors = { - /** - * Stable machine-readable failure. - */ - 401: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 403: ErrorEnvelope; +export type CreateProviderMappingObservationErrors = { /** * Stable machine-readable failure. */ - 404: ErrorEnvelope; + default: ErrorEnvelope; }; -export type GetProjectError = GetProjectErrors[keyof GetProjectErrors]; +export type CreateProviderMappingObservationError = CreateProviderMappingObservationErrors[keyof CreateProviderMappingObservationErrors]; -export type GetProjectResponses = { +export type CreateProviderMappingObservationResponses = { /** - * Project + * Accepted immutable native-store test observation */ - 200: ProjectEnvelope; + 201: ProviderMappingObservationEnvelope; }; -export type GetProjectResponse = GetProjectResponses[keyof GetProjectResponses]; +export type CreateProviderMappingObservationResponse = CreateProviderMappingObservationResponses[keyof CreateProviderMappingObservationResponses]; -export type UpdateProjectData = { - body: CreateOrganizationRequest; +export type GetNativeProviderProfileData = { + body?: never; path: { - projectId: string; + provider: 'app_store' | 'google_play'; }; - query?: never; - url: '/v1/projects/{projectId}'; + query: { + platform: Platform; + }; + url: '/v1/native-providers/{provider}/profile'; }; -export type UpdateProjectErrors = { +export type GetNativeProviderProfileErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type UpdateProjectError = UpdateProjectErrors[keyof UpdateProjectErrors]; +export type GetNativeProviderProfileError = GetNativeProviderProfileErrors[keyof GetNativeProviderProfileErrors]; -export type UpdateProjectResponses = { +export type GetNativeProviderProfileResponses = { /** - * Project + * Credential-free native provider capability profile */ - 200: ProjectEnvelope; + 200: ProviderProfileEnvelope; }; -export type UpdateProjectResponse = UpdateProjectResponses[keyof UpdateProjectResponses]; +export type GetNativeProviderProfileResponse = GetNativeProviderProfileResponses[keyof GetNativeProviderProfileResponses]; -export type ArchiveProjectData = { +export type ListEntitlementsData = { body?: never; path: { projectId: string; }; - query?: never; - url: '/v1/projects/{projectId}/archive'; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + limit?: number; + }; + url: '/v1/projects/{projectId}/entitlements'; }; -export type ArchiveProjectErrors = { +export type ListEntitlementsErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ArchiveProjectError = ArchiveProjectErrors[keyof ArchiveProjectErrors]; +export type ListEntitlementsError = ListEntitlementsErrors[keyof ListEntitlementsErrors]; -export type ArchiveProjectResponses = { +export type ListEntitlementsResponses = { /** - * Project + * Entitlement definitions */ - 200: ProjectEnvelope; + 200: EntitlementList; }; -export type ArchiveProjectResponse = ArchiveProjectResponses[keyof ArchiveProjectResponses]; +export type ListEntitlementsResponse = ListEntitlementsResponses[keyof ListEntitlementsResponses]; -export type RestoreProjectData = { - body?: never; +export type CreateEntitlementData = { + body: CreateCatalogResourceRequest; path: { projectId: string; }; query?: never; - url: '/v1/projects/{projectId}/restore'; + url: '/v1/projects/{projectId}/entitlements'; }; -export type RestoreProjectErrors = { +export type CreateEntitlementErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type RestoreProjectError = RestoreProjectErrors[keyof RestoreProjectErrors]; +export type CreateEntitlementError = CreateEntitlementErrors[keyof CreateEntitlementErrors]; -export type RestoreProjectResponses = { +export type CreateEntitlementResponses = { /** - * Project + * Entitlement definition */ - 200: ProjectEnvelope; + 201: EntitlementEnvelope; }; -export type RestoreProjectResponse = RestoreProjectResponses[keyof RestoreProjectResponses]; +export type CreateEntitlementResponse = CreateEntitlementResponses[keyof CreateEntitlementResponses]; -export type ListApplicationsData = { +export type GetEntitlementData = { body?: never; path: { - projectId: string; - }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; + entitlementId: string; }; - url: '/v1/projects/{projectId}/applications'; + query?: never; + url: '/v1/entitlements/{entitlementId}'; }; -export type ListApplicationsErrors = { +export type GetEntitlementErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ListApplicationsError = ListApplicationsErrors[keyof ListApplicationsErrors]; +export type GetEntitlementError = GetEntitlementErrors[keyof GetEntitlementErrors]; -export type ListApplicationsResponses = { +export type GetEntitlementResponses = { /** - * Applications + * Entitlement definition */ - 200: ApplicationList; + 200: EntitlementEnvelope; }; -export type ListApplicationsResponse = ListApplicationsResponses[keyof ListApplicationsResponses]; +export type GetEntitlementResponse = GetEntitlementResponses[keyof GetEntitlementResponses]; -export type CreateApplicationData = { - body: CreateApplicationRequest; +export type UpdateEntitlementData = { + body: CreateCatalogResourceRequest; path: { - projectId: string; + entitlementId: string; }; query?: never; - url: '/v1/projects/{projectId}/applications'; + url: '/v1/entitlements/{entitlementId}'; }; -export type CreateApplicationErrors = { +export type UpdateEntitlementErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type CreateApplicationError = CreateApplicationErrors[keyof CreateApplicationErrors]; +export type UpdateEntitlementError = UpdateEntitlementErrors[keyof UpdateEntitlementErrors]; -export type CreateApplicationResponses = { +export type UpdateEntitlementResponses = { /** - * Application + * Entitlement definition */ - 201: ApplicationEnvelope; + 200: EntitlementEnvelope; }; -export type CreateApplicationResponse = CreateApplicationResponses[keyof CreateApplicationResponses]; +export type UpdateEntitlementResponse = UpdateEntitlementResponses[keyof UpdateEntitlementResponses]; -export type ListEnvironmentsData = { +export type ListProductEntitlementsData = { body?: never; path: { - projectId: string; + productId: string; }; query?: { /** @@ -3303,2015 +6104,2258 @@ export type ListEnvironmentsData = { cursor?: string; limit?: number; }; - url: '/v1/projects/{projectId}/environments'; + url: '/v1/products/{productId}/entitlements'; }; -export type ListEnvironmentsErrors = { +export type ListProductEntitlementsErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ListEnvironmentsError = ListEnvironmentsErrors[keyof ListEnvironmentsErrors]; +export type ListProductEntitlementsError = ListProductEntitlementsErrors[keyof ListProductEntitlementsErrors]; -export type ListEnvironmentsResponses = { +export type ListProductEntitlementsResponses = { /** - * Environments + * Entitlement definitions */ - 200: EnvironmentList; + 200: EntitlementList; }; -export type ListEnvironmentsResponse = ListEnvironmentsResponses[keyof ListEnvironmentsResponses]; +export type ListProductEntitlementsResponse = ListProductEntitlementsResponses[keyof ListProductEntitlementsResponses]; -export type ListProviderConnectionsData = { - body?: never; +export type AddProductEntitlementData = { + body: EntitlementReferenceRequest; path: { - projectId: string; - }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; + productId: string; }; - url: '/v1/projects/{projectId}/provider-connections'; + query?: never; + url: '/v1/products/{productId}/entitlements'; }; -export type ListProviderConnectionsErrors = { +export type AddProductEntitlementErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ListProviderConnectionsError = ListProviderConnectionsErrors[keyof ListProviderConnectionsErrors]; +export type AddProductEntitlementError = AddProductEntitlementErrors[keyof AddProductEntitlementErrors]; -export type ListProviderConnectionsResponses = { +export type AddProductEntitlementResponses = { /** - * Non-secret Provider Connections + * Product grant */ - 200: ProviderConnectionList; + 201: ProductEntitlementGrantEnvelope; }; -export type ListProviderConnectionsResponse = ListProviderConnectionsResponses[keyof ListProviderConnectionsResponses]; +export type AddProductEntitlementResponse = AddProductEntitlementResponses[keyof AddProductEntitlementResponses]; -export type CreateProviderConnectionData = { - body: CreateProviderConnectionRequestWritable; +export type RemoveProductEntitlementData = { + body?: never; path: { - projectId: string; + productId: string; + entitlementId: string; }; query?: never; - url: '/v1/projects/{projectId}/provider-connections'; + url: '/v1/products/{productId}/entitlements/{entitlementId}'; }; -export type CreateProviderConnectionErrors = { +export type RemoveProductEntitlementErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type CreateProviderConnectionError = CreateProviderConnectionErrors[keyof CreateProviderConnectionErrors]; +export type RemoveProductEntitlementError = RemoveProductEntitlementErrors[keyof RemoveProductEntitlementErrors]; -export type CreateProviderConnectionResponses = { +export type RemoveProductEntitlementResponses = { /** - * Non-secret Provider Connection metadata + * Entitlement grant removed. */ - 201: ProviderConnectionEnvelope; + 204: void; }; -export type CreateProviderConnectionResponse = CreateProviderConnectionResponses[keyof CreateProviderConnectionResponses]; +export type RemoveProductEntitlementResponse = RemoveProductEntitlementResponses[keyof RemoveProductEntitlementResponses]; -export type ImportProviderProductsData = { - body: ProviderImportRequest; - headers: { - 'Idempotency-Key': string; - }; +export type ListAssetsData = { + body?: never; path: { projectId: string; }; query?: never; - url: '/v1/projects/{projectId}/provider-imports'; + url: '/v1/projects/{projectId}/assets'; }; -export type ImportProviderProductsErrors = { +export type ListAssetsErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ImportProviderProductsError = ImportProviderProductsErrors[keyof ImportProviderProductsErrors]; +export type ListAssetsError = ListAssetsErrors[keyof ListAssetsErrors]; -export type ImportProviderProductsResponses = { +export type ListAssetsResponses = { /** - * Idempotent selected-import result with item-level outcomes. + * Hosted Assets */ - 200: { - data: ProviderImportResult; - }; + 200: AssetListEnvelope; }; -export type ImportProviderProductsResponse = ImportProviderProductsResponses[keyof ImportProviderProductsResponses]; +export type ListAssetsResponse = ListAssetsResponses[keyof ListAssetsResponses]; -export type ListApiKeysData = { - body?: never; - path: { - environmentId: string; +export type UploadAssetData = { + body: { + file: Blob | File; }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; - kind?: ApiKeyKind; - state?: 'active' | 'revoked'; + path: { + projectId: string; }; - url: '/v1/environments/{environmentId}/api-keys'; + query?: never; + url: '/v1/projects/{projectId}/assets'; }; -export type ListApiKeysErrors = { +export type UploadAssetErrors = { + /** + * Stable machine-readable failure. + */ + 413: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ListApiKeysError = ListApiKeysErrors[keyof ListApiKeysErrors]; +export type UploadAssetError = UploadAssetErrors[keyof UploadAssetErrors]; -export type ListApiKeysResponses = { +export type UploadAssetResponses = { /** - * API keys without secrets + * Hosted Asset metadata */ - 200: ApiKeyList; + 201: AssetEnvelope; }; -export type ListApiKeysResponse = ListApiKeysResponses[keyof ListApiKeysResponses]; +export type UploadAssetResponse = UploadAssetResponses[keyof UploadAssetResponses]; -export type CreateApiKeyData = { - body: CreateApiKeyRequest; +export type ArchiveAssetData = { + body?: never; path: { - environmentId: string; + projectId: string; + assetId: string; }; query?: never; - url: '/v1/environments/{environmentId}/api-keys'; + url: '/v1/projects/{projectId}/assets/{assetId}'; }; -export type CreateApiKeyErrors = { +export type ArchiveAssetErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type CreateApiKeyError = CreateApiKeyErrors[keyof CreateApiKeyErrors]; +export type ArchiveAssetError = ArchiveAssetErrors[keyof ArchiveAssetErrors]; -export type CreateApiKeyResponses = { +export type ArchiveAssetResponses = { /** - * One-time secret result + * Hosted Asset metadata */ - 201: ApiKeySecretEnvelope; + 200: AssetEnvelope; }; -export type CreateApiKeyResponse = CreateApiKeyResponses[keyof CreateApiKeyResponses]; +export type ArchiveAssetResponse = ArchiveAssetResponses[keyof ArchiveAssetResponses]; -export type UpdateEnvironmentData = { - body: CreateOrganizationRequest; +export type GetAssetData = { + body?: never; path: { - environmentId: string; + projectId: string; + assetId: string; }; query?: never; - url: '/v1/environments/{environmentId}'; + url: '/v1/projects/{projectId}/assets/{assetId}'; }; -export type UpdateEnvironmentErrors = { +export type GetAssetErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type UpdateEnvironmentError = UpdateEnvironmentErrors[keyof UpdateEnvironmentErrors]; +export type GetAssetError = GetAssetErrors[keyof GetAssetErrors]; -export type UpdateEnvironmentResponses = { +export type GetAssetResponses = { /** - * Environment + * Hosted Asset metadata */ - 200: EnvironmentEnvelope; + 200: AssetEnvelope; }; -export type UpdateEnvironmentResponse = UpdateEnvironmentResponses[keyof UpdateEnvironmentResponses]; +export type GetAssetResponse = GetAssetResponses[keyof GetAssetResponses]; -export type SetEnvironmentModeData = { - body: SetEnvironmentModeRequest; +export type GetAssetUsageData = { + body?: never; path: { - environmentId: string; + projectId: string; + assetId: string; }; query?: never; - url: '/v1/environments/{environmentId}/mode'; + url: '/v1/projects/{projectId}/assets/{assetId}/usage'; }; -export type SetEnvironmentModeErrors = { +export type GetAssetUsageErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type SetEnvironmentModeError = SetEnvironmentModeErrors[keyof SetEnvironmentModeErrors]; +export type GetAssetUsageError = GetAssetUsageErrors[keyof GetAssetUsageErrors]; -export type SetEnvironmentModeResponses = { +export type GetAssetUsageResponses = { /** - * Environment + * Hosted Asset reference counts */ - 200: EnvironmentEnvelope; + 200: AssetUsageEnvelope; }; -export type SetEnvironmentModeResponse = SetEnvironmentModeResponses[keyof SetEnvironmentModeResponses]; +export type GetAssetUsageResponse = GetAssetUsageResponses[keyof GetAssetUsageResponses]; -export type ClearActiveProviderAssignmentData = { +export type ListPaywallsData = { body?: never; path: { - environmentId: string; - applicationId: string; + projectId: string; }; query?: never; - url: '/v1/environments/{environmentId}/applications/{applicationId}/active-provider'; + url: '/v1/projects/{projectId}/paywalls'; }; -export type ClearActiveProviderAssignmentErrors = { +export type ListPaywallsErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ClearActiveProviderAssignmentError = ClearActiveProviderAssignmentErrors[keyof ClearActiveProviderAssignmentErrors]; +export type ListPaywallsError = ListPaywallsErrors[keyof ListPaywallsErrors]; -export type ClearActiveProviderAssignmentResponses = { +export type ListPaywallsResponses = { /** - * The current assignment was cleared; its audit history remains immutable. + * Paywalls */ - 204: void; + 200: PaywallListEnvelope; }; -export type ClearActiveProviderAssignmentResponse = ClearActiveProviderAssignmentResponses[keyof ClearActiveProviderAssignmentResponses]; +export type ListPaywallsResponse = ListPaywallsResponses[keyof ListPaywallsResponses]; -export type GetActiveProviderAssignmentData = { - body?: never; +export type CreatePaywallData = { + body: CreatePaywallRequest; path: { - environmentId: string; - applicationId: string; + projectId: string; }; query?: never; - url: '/v1/environments/{environmentId}/applications/{applicationId}/active-provider'; + url: '/v1/projects/{projectId}/paywalls'; }; -export type GetActiveProviderAssignmentErrors = { +export type CreatePaywallErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type GetActiveProviderAssignmentError = GetActiveProviderAssignmentErrors[keyof GetActiveProviderAssignmentErrors]; +export type CreatePaywallError = CreatePaywallErrors[keyof CreatePaywallErrors]; -export type GetActiveProviderAssignmentResponses = { +export type CreatePaywallResponses = { /** - * Active provider assignment + * Paywall */ - 200: ProviderAssignmentEnvelope; + 201: PaywallEnvelope; }; -export type GetActiveProviderAssignmentResponse = GetActiveProviderAssignmentResponses[keyof GetActiveProviderAssignmentResponses]; +export type CreatePaywallResponse = CreatePaywallResponses[keyof CreatePaywallResponses]; -export type SetActiveProviderAssignmentData = { - body: SetProviderAssignmentRequest; +export type GetPaywallData = { + body?: never; path: { - environmentId: string; - applicationId: string; + projectId: string; + paywallId: string; }; query?: never; - url: '/v1/environments/{environmentId}/applications/{applicationId}/active-provider'; + url: '/v1/projects/{projectId}/paywalls/{paywallId}'; }; -export type SetActiveProviderAssignmentErrors = { +export type GetPaywallErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type SetActiveProviderAssignmentError = SetActiveProviderAssignmentErrors[keyof SetActiveProviderAssignmentErrors]; +export type GetPaywallError = GetPaywallErrors[keyof GetPaywallErrors]; -export type SetActiveProviderAssignmentResponses = { +export type GetPaywallResponses = { /** - * Active provider assignment + * Paywall */ - 200: ProviderAssignmentEnvelope; + 200: PaywallEnvelope; }; -export type SetActiveProviderAssignmentResponse = SetActiveProviderAssignmentResponses[keyof SetActiveProviderAssignmentResponses]; +export type GetPaywallResponse = GetPaywallResponses[keyof GetPaywallResponses]; -export type GetProviderConnectionData = { - body?: never; +export type UpdatePaywallData = { + body: UpdatePaywallRequest; path: { - connectionId: string; + projectId: string; + paywallId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}'; + url: '/v1/projects/{projectId}/paywalls/{paywallId}'; }; -export type GetProviderConnectionErrors = { +export type UpdatePaywallErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type GetProviderConnectionError = GetProviderConnectionErrors[keyof GetProviderConnectionErrors]; +export type UpdatePaywallError = UpdatePaywallErrors[keyof UpdatePaywallErrors]; -export type GetProviderConnectionResponses = { +export type UpdatePaywallResponses = { /** - * Non-secret Provider Connection metadata + * Paywall */ - 200: ProviderConnectionEnvelope; + 200: PaywallEnvelope; }; -export type GetProviderConnectionResponse = GetProviderConnectionResponses[keyof GetProviderConnectionResponses]; +export type UpdatePaywallResponse = UpdatePaywallResponses[keyof UpdatePaywallResponses]; -export type ReplaceProviderConnectionScopesData = { - body: ReplaceProviderConnectionScopesRequest; +export type CreatePaywallDraftData = { + body: CreateDraftRequest; + headers: { + 'Idempotency-Key': string; + }; path: { - connectionId: string; + projectId: string; + paywallId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}/scopes'; + url: '/v1/projects/{projectId}/paywalls/{paywallId}/drafts'; }; -export type ReplaceProviderConnectionScopesErrors = { +export type CreatePaywallDraftErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ReplaceProviderConnectionScopesError = ReplaceProviderConnectionScopesErrors[keyof ReplaceProviderConnectionScopesErrors]; +export type CreatePaywallDraftError = CreatePaywallDraftErrors[keyof CreatePaywallDraftErrors]; -export type ReplaceProviderConnectionScopesResponses = { +export type CreatePaywallDraftResponses = { /** - * Non-secret Provider Connection metadata + * Hosted Draft and current immutable revision document. */ - 200: ProviderConnectionEnvelope; + 201: DraftEnvelope; }; -export type ReplaceProviderConnectionScopesResponse = ReplaceProviderConnectionScopesResponses[keyof ReplaceProviderConnectionScopesResponses]; +export type CreatePaywallDraftResponse = CreatePaywallDraftResponses[keyof CreatePaywallDraftResponses]; -export type RevokeProviderConnectionData = { +export type GetActivePaywallDraftData = { body?: never; path: { - connectionId: string; + projectId: string; + paywallId: string; }; - query?: never; - url: '/v1/provider-connections/{connectionId}/revoke'; + query: { + environmentId: string; + }; + url: '/v1/projects/{projectId}/paywalls/{paywallId}/drafts/active'; }; -export type RevokeProviderConnectionErrors = { +export type GetActivePaywallDraftErrors = { + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type RevokeProviderConnectionError = RevokeProviderConnectionErrors[keyof RevokeProviderConnectionErrors]; +export type GetActivePaywallDraftError = GetActivePaywallDraftErrors[keyof GetActivePaywallDraftErrors]; -export type RevokeProviderConnectionResponses = { +export type GetActivePaywallDraftResponses = { /** - * Non-secret Provider Connection metadata + * Hosted Draft and current immutable revision document. */ - 200: ProviderConnectionEnvelope; + 200: DraftEnvelope; }; -export type RevokeProviderConnectionResponse = RevokeProviderConnectionResponses[keyof RevokeProviderConnectionResponses]; +export type GetActivePaywallDraftResponse = GetActivePaywallDraftResponses[keyof GetActivePaywallDraftResponses]; -export type TestProviderConnectionData = { +export type GetPaywallDraftData = { body?: never; path: { - connectionId: string; + projectId: string; + paywallId: string; + draftId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}/test'; + url: '/v1/projects/{projectId}/paywalls/{paywallId}/drafts/{draftId}'; }; -export type TestProviderConnectionErrors = { +export type GetPaywallDraftErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type TestProviderConnectionError = TestProviderConnectionErrors[keyof TestProviderConnectionErrors]; +export type GetPaywallDraftError = GetPaywallDraftErrors[keyof GetPaywallDraftErrors]; -export type TestProviderConnectionResponses = { +export type GetPaywallDraftResponses = { /** - * Safe connection health and capability summary. + * Hosted Draft and current immutable revision document. */ - 200: { - data: ProviderConnectionHealth; - }; + 200: DraftEnvelope; }; -export type TestProviderConnectionResponse = TestProviderConnectionResponses[keyof TestProviderConnectionResponses]; +export type GetPaywallDraftResponse = GetPaywallDraftResponses[keyof GetPaywallDraftResponses]; -export type GetProviderConnectionHealthData = { - body?: never; +export type UpdatePaywallDraftData = { + body: UpdateDraftRequest; + headers: { + 'If-Match': string; + 'Idempotency-Key': string; + }; path: { - connectionId: string; + projectId: string; + paywallId: string; + draftId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}/health'; + url: '/v1/projects/{projectId}/paywalls/{paywallId}/drafts/{draftId}'; }; -export type GetProviderConnectionHealthErrors = { +export type UpdatePaywallDraftErrors = { + /** + * Stable machine-readable failure. + */ + 412: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 428: ErrorEnvelope; /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type GetProviderConnectionHealthError = GetProviderConnectionHealthErrors[keyof GetProviderConnectionHealthErrors]; +export type UpdatePaywallDraftError = UpdatePaywallDraftErrors[keyof UpdatePaywallDraftErrors]; -export type GetProviderConnectionHealthResponses = { +export type UpdatePaywallDraftResponses = { /** - * Safe connection health and capability summary. + * Hosted Draft and current immutable revision document. */ - 200: { - data: ProviderConnectionHealth; - }; + 200: DraftEnvelope; }; -export type GetProviderConnectionHealthResponse = GetProviderConnectionHealthResponses[keyof GetProviderConnectionHealthResponses]; +export type UpdatePaywallDraftResponse = UpdatePaywallDraftResponses[keyof UpdatePaywallDraftResponses]; -export type GetProviderConnectionCapabilitiesData = { +export type ValidatePaywallDraftData = { body?: never; path: { - connectionId: string; + projectId: string; + paywallId: string; + draftId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}/capabilities'; + url: '/v1/projects/{projectId}/paywalls/{paywallId}/drafts/{draftId}/validate'; }; -export type GetProviderConnectionCapabilitiesErrors = { +export type ValidatePaywallDraftErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type GetProviderConnectionCapabilitiesError = GetProviderConnectionCapabilitiesErrors[keyof GetProviderConnectionCapabilitiesErrors]; +export type ValidatePaywallDraftError = ValidatePaywallDraftErrors[keyof ValidatePaywallDraftErrors]; -export type GetProviderConnectionCapabilitiesResponses = { +export type ValidatePaywallDraftResponses = { /** - * Closed provider capability matrix and required least-privilege permissions. + * Draft validation */ - 200: { - data: ProviderConnectionCapabilities; - }; + 200: ValidationSummaryEnvelope; }; -export type GetProviderConnectionCapabilitiesResponse = GetProviderConnectionCapabilitiesResponses[keyof GetProviderConnectionCapabilitiesResponses]; +export type ValidatePaywallDraftResponse = ValidatePaywallDraftResponses[keyof ValidatePaywallDraftResponses]; -export type ListProviderConnectionDiagnosticsData = { +export type ListPaywallVersionsData = { body?: never; path: { - connectionId: string; + projectId: string; + paywallId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}/diagnostics'; + url: '/v1/projects/{projectId}/paywalls/{paywallId}/versions'; }; -export type ListProviderConnectionDiagnosticsErrors = { +export type ListPaywallVersionsErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ListProviderConnectionDiagnosticsError = ListProviderConnectionDiagnosticsErrors[keyof ListProviderConnectionDiagnosticsErrors]; +export type ListPaywallVersionsError = ListPaywallVersionsErrors[keyof ListPaywallVersionsErrors]; -export type ListProviderConnectionDiagnosticsResponses = { +export type ListPaywallVersionsResponses = { /** - * Safe provider diagnostics without provider response bodies or secrets. + * Immutable Paywall Versions */ - 200: { - data: { - items: Array; - }; - }; + 200: PaywallVersionListEnvelope; }; -export type ListProviderConnectionDiagnosticsResponse = ListProviderConnectionDiagnosticsResponses[keyof ListProviderConnectionDiagnosticsResponses]; +export type ListPaywallVersionsResponse = ListPaywallVersionsResponses[keyof ListPaywallVersionsResponses]; -export type PreviewProviderCatalogData = { +export type GetPaywallVersionData = { body?: never; path: { - connectionId: string; + projectId: string; + paywallId: string; + versionId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}/catalog-preview'; + url: '/v1/projects/{projectId}/paywalls/{paywallId}/versions/{versionId}'; }; -export type PreviewProviderCatalogErrors = { +export type GetPaywallVersionErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type PreviewProviderCatalogError = PreviewProviderCatalogErrors[keyof PreviewProviderCatalogErrors]; +export type GetPaywallVersionError = GetPaywallVersionErrors[keyof GetPaywallVersionErrors]; -export type PreviewProviderCatalogResponses = { +export type GetPaywallVersionResponses = { /** - * Normalized live provider catalog preview. + * Immutable Paywall Version */ - 200: { - data: ProviderCatalogPreview; - }; + 200: PaywallVersionEnvelope; }; -export type PreviewProviderCatalogResponse = PreviewProviderCatalogResponses[keyof PreviewProviderCatalogResponses]; +export type GetPaywallVersionResponse = GetPaywallVersionResponses[keyof GetPaywallVersionResponses]; -export type RotateProviderCredentialData = { - body: ProviderCredentialRequest; +export type ClonePaywallVersionToDraftData = { + body?: never; + headers: { + 'Idempotency-Key': string; + }; path: { - connectionId: string; + projectId: string; + paywallId: string; + versionId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}/rotate-credential'; + url: '/v1/projects/{projectId}/paywalls/{paywallId}/versions/{versionId}/drafts'; }; -export type RotateProviderCredentialErrors = { +export type ClonePaywallVersionToDraftErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type RotateProviderCredentialError = RotateProviderCredentialErrors[keyof RotateProviderCredentialErrors]; +export type ClonePaywallVersionToDraftError = ClonePaywallVersionToDraftErrors[keyof ClonePaywallVersionToDraftErrors]; -export type RotateProviderCredentialResponses = { +export type ClonePaywallVersionToDraftResponses = { /** - * Non-secret Provider Connection metadata + * Hosted Draft and current immutable revision document. */ - 200: ProviderConnectionEnvelope; + 201: DraftEnvelope; }; -export type RotateProviderCredentialResponse = RotateProviderCredentialResponses[keyof RotateProviderCredentialResponses]; +export type ClonePaywallVersionToDraftResponse = ClonePaywallVersionToDraftResponses[keyof ClonePaywallVersionToDraftResponses]; -export type ReconnectProviderConnectionData = { - body: ProviderCredentialRequest; +export type ListPlacementsData = { + body?: never; path: { - connectionId: string; + projectId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}/reconnect'; + url: '/v1/projects/{projectId}/placements'; }; -export type ReconnectProviderConnectionErrors = { +export type ListPlacementsErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ReconnectProviderConnectionError = ReconnectProviderConnectionErrors[keyof ReconnectProviderConnectionErrors]; +export type ListPlacementsError = ListPlacementsErrors[keyof ListPlacementsErrors]; -export type ReconnectProviderConnectionResponses = { +export type ListPlacementsResponses = { /** - * Non-secret Provider Connection metadata + * Placements */ - 200: ProviderConnectionEnvelope; + 200: PlacementListEnvelope; }; -export type ReconnectProviderConnectionResponse = ReconnectProviderConnectionResponses[keyof ReconnectProviderConnectionResponses]; +export type ListPlacementsResponse = ListPlacementsResponses[keyof ListPlacementsResponses]; -export type EnqueueProviderSyncData = { - body?: never; +export type CreatePlacementData = { + body: CreatePlacementRequest; path: { - connectionId: string; + projectId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}/sync'; + url: '/v1/projects/{projectId}/placements'; }; -export type EnqueueProviderSyncErrors = { +export type CreatePlacementErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type EnqueueProviderSyncError = EnqueueProviderSyncErrors[keyof EnqueueProviderSyncErrors]; +export type CreatePlacementError = CreatePlacementErrors[keyof CreatePlacementErrors]; -export type EnqueueProviderSyncResponses = { +export type CreatePlacementResponses = { /** - * Accepted provider synchronization job. + * Placement */ - 202: { - data: ProviderSyncJob; - }; + 201: PlacementEnvelope; }; -export type EnqueueProviderSyncResponse = EnqueueProviderSyncResponses[keyof EnqueueProviderSyncResponses]; +export type CreatePlacementResponse = CreatePlacementResponses[keyof CreatePlacementResponses]; -export type ListProviderSyncRunsData = { - body?: never; +export type UpdatePlacementData = { + body: UpdatePlacementRequest; path: { - connectionId: string; + projectId: string; + placementId: string; }; query?: never; - url: '/v1/provider-connections/{connectionId}/sync-runs'; + url: '/v1/projects/{projectId}/placements/{placementId}'; }; -export type ListProviderSyncRunsErrors = { +export type UpdatePlacementErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ListProviderSyncRunsError = ListProviderSyncRunsErrors[keyof ListProviderSyncRunsErrors]; +export type UpdatePlacementError = UpdatePlacementErrors[keyof UpdatePlacementErrors]; -export type ListProviderSyncRunsResponses = { +export type UpdatePlacementResponses = { /** - * Provider synchronization run history. + * Placement */ - 200: { - data: { - items: Array; - }; - }; + 200: PlacementEnvelope; }; -export type ListProviderSyncRunsResponse = ListProviderSyncRunsResponses[keyof ListProviderSyncRunsResponses]; +export type UpdatePlacementResponse = UpdatePlacementResponses[keyof UpdatePlacementResponses]; -export type RotateApiKeyData = { +export type GetPlacementBindingData = { body?: never; path: { - apiKeyId: string; + projectId: string; + environmentId: string; + placementId: string; }; query?: never; - url: '/v1/api-keys/{apiKeyId}/rotate'; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/binding'; }; -export type RotateApiKeyErrors = { +export type GetPlacementBindingErrors = { + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type RotateApiKeyError = RotateApiKeyErrors[keyof RotateApiKeyErrors]; +export type GetPlacementBindingError = GetPlacementBindingErrors[keyof GetPlacementBindingErrors]; -export type RotateApiKeyResponses = { +export type GetPlacementBindingResponses = { /** - * One-time secret result + * Environment Placement binding */ - 200: ApiKeySecretEnvelope; + 200: PlacementBindingEnvelope; }; -export type RotateApiKeyResponse = RotateApiKeyResponses[keyof RotateApiKeyResponses]; +export type GetPlacementBindingResponse = GetPlacementBindingResponses[keyof GetPlacementBindingResponses]; -export type RevokeApiKeyData = { - body?: never; +export type BindPlacementData = { + body: BindPlacementRequest; path: { - apiKeyId: string; + projectId: string; + environmentId: string; + placementId: string; }; query?: never; - url: '/v1/api-keys/{apiKeyId}/revoke'; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/binding'; }; -export type RevokeApiKeyErrors = { +export type BindPlacementErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type RevokeApiKeyError = RevokeApiKeyErrors[keyof RevokeApiKeyErrors]; +export type BindPlacementError = BindPlacementErrors[keyof BindPlacementErrors]; -export type RevokeApiKeyResponses = { +export type BindPlacementResponses = { /** - * API key metadata + * Environment Placement binding */ - 200: ApiKeyEnvelope; + 200: PlacementBindingEnvelope; }; -export type RevokeApiKeyResponse = RevokeApiKeyResponses[keyof RevokeApiKeyResponses]; +export type BindPlacementResponse = BindPlacementResponses[keyof BindPlacementResponses]; -export type ListPlansData = { - body?: never; +export type PublishConfigurationData = { + body: PublishRequest; + headers: { + 'Idempotency-Key': string; + }; path: { projectId: string; + environmentId: string; }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; - }; - url: '/v1/projects/{projectId}/plans'; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/publish'; }; -export type ListPlansErrors = { +export type PublishConfigurationErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type ListPlansError = ListPlansErrors[keyof ListPlansErrors]; +export type PublishConfigurationError = PublishConfigurationErrors[keyof PublishConfigurationErrors]; -export type ListPlansResponses = { +export type PublishConfigurationResponses = { /** - * Plans + * Publication result and nonblocking warnings */ - 200: PlanList; + 201: PublishResultEnvelope; }; -export type ListPlansResponse = ListPlansResponses[keyof ListPlansResponses]; +export type PublishConfigurationResponse = PublishConfigurationResponses[keyof PublishConfigurationResponses]; -export type CreatePlanData = { - body: CreateCatalogResourceRequest; +export type ListConfigurationReleasesData = { + body?: never; path: { projectId: string; + environmentId: string; }; query?: never; - url: '/v1/projects/{projectId}/plans'; + url: '/v1/projects/{projectId}/environments/{environmentId}/releases'; }; -export type CreatePlanErrors = { +export type ListConfigurationReleasesErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type CreatePlanError = CreatePlanErrors[keyof CreatePlanErrors]; +export type ListConfigurationReleasesError = ListConfigurationReleasesErrors[keyof ListConfigurationReleasesErrors]; -export type CreatePlanResponses = { +export type ListConfigurationReleasesResponses = { /** - * Plan + * Configuration Release history */ - 201: PlanEnvelope; + 200: ReleaseListEnvelope; }; -export type CreatePlanResponse = CreatePlanResponses[keyof CreatePlanResponses]; +export type ListConfigurationReleasesResponse = ListConfigurationReleasesResponses[keyof ListConfigurationReleasesResponses]; -export type GetPlanData = { +export type RollbackConfigurationReleaseData = { body?: never; + headers: { + 'Idempotency-Key': string; + }; path: { - planId: string; + projectId: string; + environmentId: string; + releaseId: string; }; query?: never; - url: '/v1/plans/{planId}'; + url: '/v1/projects/{projectId}/environments/{environmentId}/releases/{releaseId}/rollback'; }; -export type GetPlanErrors = { +export type RollbackConfigurationReleaseErrors = { /** * Stable machine-readable failure. */ default: ErrorEnvelope; }; -export type GetPlanError = GetPlanErrors[keyof GetPlanErrors]; +export type RollbackConfigurationReleaseError = RollbackConfigurationReleaseErrors[keyof RollbackConfigurationReleaseErrors]; -export type GetPlanResponses = { +export type RollbackConfigurationReleaseResponses = { /** - * Plan + * Immutable Configuration Release metadata */ - 200: PlanEnvelope; + 201: ReleaseEnvelope; }; -export type GetPlanResponse = GetPlanResponses[keyof GetPlanResponses]; +export type RollbackConfigurationReleaseResponse = RollbackConfigurationReleaseResponses[keyof RollbackConfigurationReleaseResponses]; -export type UpdatePlanData = { - body: CreateCatalogResourceRequest; - path: { - planId: string; +export type GetSdkConfigurationData = { + body?: never; + headers: { + 'Mosaic-SDK-Platform': 'flutter' | 'ios' | 'android'; + 'Mosaic-SDK-Version': string; + 'Mosaic-Configuration-Versions': string; + 'Mosaic-Paywall-Protocol-Versions': '0.2'; + /** + * Comma-separated unique exact Protocol capability pairs (`name@0.2`), bounded to 128 pairs. The selected Release is returned only when every required pair is reported. + */ + 'Mosaic-Paywall-Capabilities': string; + 'Mosaic-Placement-Decision-Versions'?: string; + /** + * Comma-separated exact Placement Decision v1 feature identifiers. + */ + 'Mosaic-Decision-Features'?: string; + 'Mosaic-Bucketing-Algorithms'?: 'sha256_length_prefixed_v1'; + /** + * Required when Delivery v3 is requested. Comma-separated unique Experiment Assignment contract versions. + */ + 'Mosaic-Experiment-Assignment-Versions'?: string; + /** + * Required when Delivery v3 is requested. Comma-separated unique exact Experiment Assignment v1 feature identifiers. + */ + 'Mosaic-Experiment-Features'?: string; + /** + * Required when Delivery v3 is requested. Comma-separated unique canonical Variant and Group bucketing algorithms. + */ + 'Mosaic-Experiment-Bucketing-Algorithms'?: string; + /** + * Required when Delivery v3 is requested. Comma-separated unique trusted-time schedule policies. + */ + 'Mosaic-Experiment-Schedule-Policies'?: string; + 'Mosaic-App-Version'?: string; + 'If-None-Match'?: string; }; + path?: never; query?: never; - url: '/v1/plans/{planId}'; + url: '/v1/sdk/configuration'; }; -export type UpdatePlanErrors = { +export type GetSdkConfigurationErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 406: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 429: ErrorEnvelope; }; -export type UpdatePlanError = UpdatePlanErrors[keyof UpdatePlanErrors]; +export type GetSdkConfigurationError = GetSdkConfigurationErrors[keyof GetSdkConfigurationErrors]; -export type UpdatePlanResponses = { +export type GetSdkConfigurationResponses = { /** - * Plan + * Highest mutually supported representation actually available for the current immutable Release. A v3-capable SDK falls back to an available v2 or safe v1 representation when that Release predates v3. Delivery v3 requires complete Experiment capability headers. A v1 candidate is withheld when an advanced Placement lacks an explicit Paywall default. */ - 200: PlanEnvelope; + 200: { + [key: string]: unknown; + }; }; -export type UpdatePlanResponse = UpdatePlanResponses[keyof UpdatePlanResponses]; +export type GetSdkConfigurationResponse = GetSdkConfigurationResponses[keyof GetSdkConfigurationResponses]; -export type ListPlanProductsData = { +export type GetSdkCommerceConfigurationData = { body?: never; - path: { - planId: string; - }; - query?: { + headers: { /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + * Comma-separated accepted Commerce media types. Supported versions are application/vnd.mosaic.commerce-configuration+json;version=1 and version=2. */ - cursor?: string; - limit?: number; + Accept: string; + 'Mosaic-SDK-Platform': 'flutter' | 'ios' | 'android'; + 'Mosaic-SDK-Version': string; + 'Mosaic-Commerce-Configuration-Versions': string; + 'Mosaic-Commerce-Provider-Contract-Versions': string; + 'If-None-Match'?: string; }; - url: '/v1/plans/{planId}/products'; + path?: never; + query: { + applicationId: string; + }; + url: '/v1/sdk/commerce-configuration'; }; -export type ListPlanProductsErrors = { +export type GetSdkCommerceConfigurationErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 406: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 429: ErrorEnvelope; }; -export type ListPlanProductsError = ListPlanProductsErrors[keyof ListPlanProductsErrors]; +export type GetSdkCommerceConfigurationError = GetSdkCommerceConfigurationErrors[keyof GetSdkCommerceConfigurationErrors]; -export type ListPlanProductsResponses = { +export type GetSdkCommerceConfigurationResponses = { /** - * Products + * Immutable version-negotiated Commerce Configuration sidecar associated with the current Configuration Release and requested Application. */ - 200: ProductList; + 200: { + [key: string]: unknown; + }; }; -export type ListPlanProductsResponse = ListPlanProductsResponses[keyof ListPlanProductsResponses]; +export type GetSdkCommerceConfigurationResponse = GetSdkCommerceConfigurationResponses[keyof GetSdkCommerceConfigurationResponses]; -export type AddPlanProductData = { - body: ProductReferenceRequest; +export type GetAssetContentData = { + body?: never; path: { - planId: string; + assetId: string; + contentDigest: string; }; query?: never; - url: '/v1/plans/{planId}/products'; + url: '/v1/sdk/assets/{assetId}/{contentDigest}'; }; -export type AddPlanProductErrors = { +export type GetAssetContentErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type AddPlanProductError = AddPlanProductErrors[keyof AddPlanProductErrors]; +export type GetAssetContentError = GetAssetContentErrors[keyof GetAssetContentErrors]; -export type AddPlanProductResponses = { +export type GetAssetContentResponses = { /** - * Plan membership + * Immutable Asset content. */ - 201: PlanProductEnvelope; + 200: Blob | File; }; -export type AddPlanProductResponse = AddPlanProductResponses[keyof AddPlanProductResponses]; +export type GetAssetContentResponse = GetAssetContentResponses[keyof GetAssetContentResponses]; -export type RemovePlanProductData = { +export type ListPlacementAttributesData = { body?: never; path: { - planId: string; - productId: string; + projectId: string; }; query?: never; - url: '/v1/plans/{planId}/products/{productId}'; + url: '/v1/projects/{projectId}/placement-attributes'; }; -export type RemovePlanProductErrors = { +export type ListPlacementAttributesErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; }; -export type RemovePlanProductError = RemovePlanProductErrors[keyof RemovePlanProductErrors]; +export type ListPlacementAttributesError = ListPlacementAttributesErrors[keyof ListPlacementAttributesErrors]; -export type RemovePlanProductResponses = { +export type ListPlacementAttributesResponses = { /** - * Product removed from Plan. + * Project attribute allow-list. */ - 204: void; + 200: PlacementAttributeListEnvelope; }; -export type RemovePlanProductResponse = RemovePlanProductResponses[keyof RemovePlanProductResponses]; +export type ListPlacementAttributesResponse = ListPlacementAttributesResponses[keyof ListPlacementAttributesResponses]; -export type ListProductsData = { - body?: never; +export type CreatePlacementAttributeData = { + body: CreatePlacementAttributeRequest; path: { projectId: string; }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; - status?: ProductStatus; - type?: ProductType; - search?: string; - }; - url: '/v1/projects/{projectId}/products'; + query?: never; + url: '/v1/projects/{projectId}/placement-attributes'; }; -export type ListProductsErrors = { +export type CreatePlacementAttributeErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type ListProductsError = ListProductsErrors[keyof ListProductsErrors]; +export type CreatePlacementAttributeError = CreatePlacementAttributeErrors[keyof CreatePlacementAttributeErrors]; -export type ListProductsResponses = { +export type CreatePlacementAttributeResponses = { /** - * Products + * Attribute definition created. */ - 200: ProductList; + 201: PlacementAttributeEnvelope; }; -export type ListProductsResponse = ListProductsResponses[keyof ListProductsResponses]; +export type CreatePlacementAttributeResponse = CreatePlacementAttributeResponses[keyof CreatePlacementAttributeResponses]; -export type CreateProductData = { - body: CreateProductRequest; +export type ArchivePlacementAttributeData = { + body?: never; path: { projectId: string; + attributeId: string; }; query?: never; - url: '/v1/projects/{projectId}/products'; + url: '/v1/projects/{projectId}/placement-attributes/{attributeId}'; }; -export type CreateProductErrors = { +export type ArchivePlacementAttributeErrors = { + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 409: ErrorEnvelope; }; -export type CreateProductError = CreateProductErrors[keyof CreateProductErrors]; +export type ArchivePlacementAttributeError = ArchivePlacementAttributeErrors[keyof ArchivePlacementAttributeErrors]; -export type CreateProductResponses = { +export type ArchivePlacementAttributeResponses = { /** - * Product + * Attribute definition archived. */ - 201: ProductEnvelope; + 204: void; }; -export type CreateProductResponse = CreateProductResponses[keyof CreateProductResponses]; +export type ArchivePlacementAttributeResponse = ArchivePlacementAttributeResponses[keyof ArchivePlacementAttributeResponses]; -export type DeleteProductData = { +export type GetPlacementUsageData = { body?: never; path: { - productId: string; + projectId: string; + placementId: string; }; query?: never; - url: '/v1/products/{productId}'; + url: '/v1/projects/{projectId}/placements/{placementId}/usage'; }; -export type DeleteProductErrors = { +export type GetPlacementUsageErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type DeleteProductError = DeleteProductErrors[keyof DeleteProductErrors]; +export type GetPlacementUsageError = GetPlacementUsageErrors[keyof GetPlacementUsageErrors]; -export type DeleteProductResponses = { +export type GetPlacementUsageResponses = { /** - * Unreferenced Product deleted. + * Placement usage. */ - 204: void; + 200: PlacementUsageEnvelope; }; -export type DeleteProductResponse = DeleteProductResponses[keyof DeleteProductResponses]; +export type GetPlacementUsageResponse = GetPlacementUsageResponses[keyof GetPlacementUsageResponses]; -export type GetProductData = { +export type ListPlacementAliasesData = { body?: never; path: { - productId: string; + projectId: string; + placementId: string; }; query?: never; - url: '/v1/products/{productId}'; -}; - -export type GetProductErrors = { - /** - * Stable machine-readable failure. - */ - default: ErrorEnvelope; + url: '/v1/projects/{projectId}/placements/{placementId}/aliases'; }; -export type GetProductError = GetProductErrors[keyof GetProductErrors]; - -export type GetProductResponses = { +export type ListPlacementAliasesResponses = { /** - * Product + * Placement aliases. */ - 200: ProductEnvelope; + 200: PlacementAliasListEnvelope; }; -export type GetProductResponse = GetProductResponses[keyof GetProductResponses]; +export type ListPlacementAliasesResponse = ListPlacementAliasesResponses[keyof ListPlacementAliasesResponses]; -export type UpdateProductData = { - body: CreateProductRequest; +export type CreatePlacementAliasData = { + body: { + key: string; + }; path: { - productId: string; + projectId: string; + placementId: string; }; query?: never; - url: '/v1/products/{productId}'; + url: '/v1/projects/{projectId}/placements/{placementId}/aliases'; }; -export type UpdateProductErrors = { +export type CreatePlacementAliasErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 409: ErrorEnvelope; }; -export type UpdateProductError = UpdateProductErrors[keyof UpdateProductErrors]; +export type CreatePlacementAliasError = CreatePlacementAliasErrors[keyof CreatePlacementAliasErrors]; -export type UpdateProductResponses = { +export type CreatePlacementAliasResponses = { /** - * Product + * Alias created. */ - 200: ProductEnvelope; + 201: PlacementAliasEnvelope; }; -export type UpdateProductResponse = UpdateProductResponses[keyof UpdateProductResponses]; +export type CreatePlacementAliasResponse = CreatePlacementAliasResponses[keyof CreatePlacementAliasResponses]; -export type ArchiveProductData = { +export type ArchivePlacementWithUsageCheckData = { body?: never; path: { - productId: string; + projectId: string; + placementId: string; }; query?: never; - url: '/v1/products/{productId}/archive'; + url: '/v1/projects/{projectId}/placements/{placementId}/archive'; }; -export type ArchiveProductErrors = { +export type ArchivePlacementWithUsageCheckErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 409: ErrorEnvelope; }; -export type ArchiveProductError = ArchiveProductErrors[keyof ArchiveProductErrors]; +export type ArchivePlacementWithUsageCheckError = ArchivePlacementWithUsageCheckErrors[keyof ArchivePlacementWithUsageCheckErrors]; -export type ArchiveProductResponses = { +export type ArchivePlacementWithUsageCheckResponses = { /** - * Product + * Placement archived. */ - 200: ProductEnvelope; + 204: void; }; -export type ArchiveProductResponse = ArchiveProductResponses[keyof ArchiveProductResponses]; +export type ArchivePlacementWithUsageCheckResponse = ArchivePlacementWithUsageCheckResponses[keyof ArchivePlacementWithUsageCheckResponses]; -export type RestoreProductData = { +export type GetPlacementDecisionData = { body?: never; path: { - productId: string; + projectId: string; + environmentId: string; + placementId: string; }; query?: never; - url: '/v1/products/{productId}/restore'; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-set'; }; -export type RestoreProductErrors = { +export type GetPlacementDecisionErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type RestoreProductError = RestoreProductErrors[keyof RestoreProductErrors]; +export type GetPlacementDecisionError = GetPlacementDecisionErrors[keyof GetPlacementDecisionErrors]; -export type RestoreProductResponses = { +export type GetPlacementDecisionResponses = { /** - * Product + * Combined Rule Set and current Draft detail. */ - 200: ProductEnvelope; + 200: PlacementRuleSetDraftEnvelope; }; -export type RestoreProductResponse = RestoreProductResponses[keyof RestoreProductResponses]; +export type GetPlacementDecisionResponse = GetPlacementDecisionResponses[keyof GetPlacementDecisionResponses]; -export type SetProductReplacementData = { - body: ProductReferenceRequest; +export type CreatePlacementRuleSetData = { + body: PlacementDecisionDocumentRequest; + headers: { + 'Idempotency-Key': string; + }; path: { - productId: string; + projectId: string; + environmentId: string; + placementId: string; }; query?: never; - url: '/v1/products/{productId}/replacement'; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-set'; }; -export type SetProductReplacementErrors = { +export type CreatePlacementRuleSetErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type SetProductReplacementError = SetProductReplacementErrors[keyof SetProductReplacementErrors]; +export type CreatePlacementRuleSetError = CreatePlacementRuleSetErrors[keyof CreatePlacementRuleSetErrors]; -export type SetProductReplacementResponses = { +export type CreatePlacementRuleSetResponses = { /** - * Product + * Rule Set and Draft created. */ - 200: ProductEnvelope; + 201: PlacementRuleSetDraftEnvelope; }; -export type SetProductReplacementResponse = SetProductReplacementResponses[keyof SetProductReplacementResponses]; +export type CreatePlacementRuleSetResponse = CreatePlacementRuleSetResponses[keyof CreatePlacementRuleSetResponses]; -export type GetProductUsageData = { - body?: never; +export type UpdatePlacementRuleSetDraftData = { + body: PlacementDecisionDocumentRequest; + headers: { + 'If-Match': string; + 'Idempotency-Key': string; + }; path: { - productId: string; + projectId: string; + environmentId: string; + placementId: string; + ruleSetId: string; }; query?: never; - url: '/v1/products/{productId}/usage'; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/draft'; }; -export type GetProductUsageErrors = { +export type UpdatePlacementRuleSetDraftErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 412: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 428: ErrorEnvelope; }; -export type GetProductUsageError = GetProductUsageErrors[keyof GetProductUsageErrors]; +export type UpdatePlacementRuleSetDraftError = UpdatePlacementRuleSetDraftErrors[keyof UpdatePlacementRuleSetDraftErrors]; -export type GetProductUsageResponses = { +export type UpdatePlacementRuleSetDraftResponses = { /** - * Product usage + * New immutable Draft revision. */ - 200: ProductUsageEnvelope; + 200: PlacementRuleSetDraftEnvelope; }; -export type GetProductUsageResponse = GetProductUsageResponses[keyof GetProductUsageResponses]; +export type UpdatePlacementRuleSetDraftResponse = UpdatePlacementRuleSetDraftResponses[keyof UpdatePlacementRuleSetDraftResponses]; -export type GetProductReadinessData = { +export type ValidatePlacementRuleSetData = { body?: never; path: { - productId: string; + projectId: string; + environmentId: string; + placementId: string; + ruleSetId: string; }; - query: { + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/validate'; +}; + +export type ValidatePlacementRuleSetResponses = { + /** + * Semantic validation result. + */ + 200: PlacementValidationEnvelope; +}; + +export type ValidatePlacementRuleSetResponse = ValidatePlacementRuleSetResponses[keyof ValidatePlacementRuleSetResponses]; + +export type PublishPlacementRuleSetData = { + body: { + expectedRevision: number; + }; + path: { + projectId: string; environmentId: string; - applicationId: string; + placementId: string; + ruleSetId: string; }; - url: '/v1/products/{productId}/readiness'; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/publish'; }; -export type GetProductReadinessErrors = { +export type PublishPlacementRuleSetErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 412: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type GetProductReadinessError = GetProductReadinessErrors[keyof GetProductReadinessErrors]; +export type PublishPlacementRuleSetError = PublishPlacementRuleSetErrors[keyof PublishPlacementRuleSetErrors]; -export type GetProductReadinessResponses = { +export type PublishPlacementRuleSetResponses = { /** - * Scoped provider readiness and stable recovery codes + * Immutable Rule Set Version. */ - 200: ProviderReadinessEnvelope; + 201: PlacementRuleSetVersionEnvelope; }; -export type GetProductReadinessResponse = GetProductReadinessResponses[keyof GetProductReadinessResponses]; - -export type ListProviderMappingsData = { - body?: never; - path: { - productId: string; - }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; +export type PublishPlacementRuleSetResponse = PublishPlacementRuleSetResponses[keyof PublishPlacementRuleSetResponses]; + +export type ArchivePlacementRuleSetData = { + body?: never; + path: { + projectId: string; + environmentId: string; + placementId: string; + ruleSetId: string; }; - url: '/v1/products/{productId}/provider-mappings'; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/archive'; }; -export type ListProviderMappingsErrors = { +export type ArchivePlacementRuleSetErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Rule Set is already archived or conflicts with current state. + */ + 409: ErrorEnvelope; }; -export type ListProviderMappingsError = ListProviderMappingsErrors[keyof ListProviderMappingsErrors]; +export type ArchivePlacementRuleSetError = ArchivePlacementRuleSetErrors[keyof ArchivePlacementRuleSetErrors]; -export type ListProviderMappingsResponses = { +export type ArchivePlacementRuleSetResponses = { /** - * Placeholder mappings + * Rule Set archived; immutable version history is retained. */ - 200: ProviderMappingList; + 204: void; }; -export type ListProviderMappingsResponse = ListProviderMappingsResponses[keyof ListProviderMappingsResponses]; +export type ArchivePlacementRuleSetResponse = ArchivePlacementRuleSetResponses[keyof ArchivePlacementRuleSetResponses]; -export type CreateProviderMappingData = { - body: CreateProviderMappingRequest; +export type ListPlacementRuleSetVersionsData = { + body?: never; path: { - productId: string; + projectId: string; + environmentId: string; + placementId: string; + ruleSetId: string; }; query?: never; - url: '/v1/products/{productId}/provider-mappings'; -}; - -export type CreateProviderMappingErrors = { - /** - * Stable machine-readable failure. - */ - default: ErrorEnvelope; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/versions'; }; -export type CreateProviderMappingError = CreateProviderMappingErrors[keyof CreateProviderMappingErrors]; - -export type CreateProviderMappingResponses = { +export type ListPlacementRuleSetVersionsResponses = { /** - * Placeholder mapping + * Immutable version history. */ - 201: ProviderMappingEnvelope; + 200: PlacementRuleSetVersionListEnvelope; }; -export type CreateProviderMappingResponse = CreateProviderMappingResponses[keyof CreateProviderMappingResponses]; +export type ListPlacementRuleSetVersionsResponse = ListPlacementRuleSetVersionsResponses[keyof ListPlacementRuleSetVersionsResponses]; -export type CreateProviderMappingDraftData = { - body: CreateProviderMappingDraftRequest; +export type ClonePlacementRuleSetVersionData = { + body?: never; + headers: { + 'Idempotency-Key': string; + }; path: { - productId: string; + projectId: string; + environmentId: string; + placementId: string; + ruleSetId: string; + versionId: string; }; query?: never; - url: '/v1/products/{productId}/provider-mapping-drafts'; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/versions/{versionId}/draft'; }; -export type CreateProviderMappingDraftErrors = { +export type ClonePlacementRuleSetVersionErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 409: ErrorEnvelope; }; -export type CreateProviderMappingDraftError = CreateProviderMappingDraftErrors[keyof CreateProviderMappingDraftErrors]; +export type ClonePlacementRuleSetVersionError = ClonePlacementRuleSetVersionErrors[keyof ClonePlacementRuleSetVersionErrors]; -export type CreateProviderMappingDraftResponses = { +export type ClonePlacementRuleSetVersionResponses = { /** - * Placeholder mapping + * Active Draft cloned from the immutable version. */ - 201: ProviderMappingEnvelope; + 201: PlacementRuleSetDraftEnvelope; }; -export type CreateProviderMappingDraftResponse = CreateProviderMappingDraftResponses[keyof CreateProviderMappingDraftResponses]; +export type ClonePlacementRuleSetVersionResponse = ClonePlacementRuleSetVersionResponses[keyof ClonePlacementRuleSetVersionResponses]; -export type GetProviderReadinessData = { - body?: never; +export type SimulatePlacementDecisionData = { + body: PlacementSimulationRequestWritable; path: { - productId: string; - }; - query: { + projectId: string; environmentId: string; - applicationId: string; + placementId: string; + ruleSetId: string; }; - url: '/v1/products/{productId}/provider-readiness'; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/simulate'; }; -export type GetProviderReadinessErrors = { +export type SimulatePlacementDecisionErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 422: ErrorEnvelope; }; -export type GetProviderReadinessError = GetProviderReadinessErrors[keyof GetProviderReadinessErrors]; +export type SimulatePlacementDecisionError = SimulatePlacementDecisionErrors[keyof SimulatePlacementDecisionErrors]; -export type GetProviderReadinessResponses = { +export type SimulatePlacementDecisionResponses = { /** - * Scoped provider readiness and stable recovery codes + * Ephemeral bounded decision trace; request inputs are neither logged nor persisted. */ - 200: ProviderReadinessEnvelope; + 200: PlacementSimulationEnvelope; }; -export type GetProviderReadinessResponse = GetProviderReadinessResponses[keyof GetProviderReadinessResponses]; +export type SimulatePlacementDecisionResponse = SimulatePlacementDecisionResponses[keyof SimulatePlacementDecisionResponses]; -export type ArchiveProviderMappingData = { +export type ListPlacementQaOverridesData = { body?: never; path: { - mappingId: string; + projectId: string; + environmentId: string; + placementId: string; }; query?: never; - url: '/v1/provider-mappings/{mappingId}/archive'; -}; - -export type ArchiveProviderMappingErrors = { - /** - * Stable machine-readable failure. - */ - default: ErrorEnvelope; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/qa-overrides'; }; -export type ArchiveProviderMappingError = ArchiveProviderMappingErrors[keyof ArchiveProviderMappingErrors]; - -export type ArchiveProviderMappingResponses = { +export type ListPlacementQaOverridesResponses = { /** - * Placeholder mapping + * Active non-production QA overrides. */ - 200: ProviderMappingEnvelope; + 200: QaOverrideListEnvelope; }; -export type ArchiveProviderMappingResponse = ArchiveProviderMappingResponses[keyof ArchiveProviderMappingResponses]; +export type ListPlacementQaOverridesResponse = ListPlacementQaOverridesResponses[keyof ListPlacementQaOverridesResponses]; -export type ReplaceProviderMappingData = { - body: ReplaceProviderMappingRequest; +export type CreatePlacementQaOverrideData = { + body: CreateQaOverrideRequestWritable; path: { - mappingId: string; + projectId: string; + environmentId: string; + placementId: string; }; query?: never; - url: '/v1/provider-mappings/{mappingId}/replace'; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/qa-overrides'; }; -export type ReplaceProviderMappingErrors = { +export type CreatePlacementQaOverrideErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 422: ErrorEnvelope; }; -export type ReplaceProviderMappingError = ReplaceProviderMappingErrors[keyof ReplaceProviderMappingErrors]; +export type CreatePlacementQaOverrideError = CreatePlacementQaOverrideErrors[keyof CreatePlacementQaOverrideErrors]; -export type ReplaceProviderMappingResponses = { +export type CreatePlacementQaOverrideResponses = { /** - * Placeholder mapping + * Override created; opaque token is returned once. */ - 201: ProviderMappingEnvelope; + 201: QaOverrideCreatedEnvelope; }; -export type ReplaceProviderMappingResponse = ReplaceProviderMappingResponses[keyof ReplaceProviderMappingResponses]; +export type CreatePlacementQaOverrideResponse = CreatePlacementQaOverrideResponses[keyof CreatePlacementQaOverrideResponses]; -export type GetProviderMappingMetadataData = { +export type RevokePlacementQaOverrideData = { body?: never; path: { - mappingId: string; + projectId: string; + environmentId: string; + placementId: string; + overrideId: string; }; query?: never; - url: '/v1/provider-mappings/{mappingId}/metadata'; + url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/qa-overrides/{overrideId}'; }; -export type GetProviderMappingMetadataErrors = { +export type RevokePlacementQaOverrideErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type GetProviderMappingMetadataError = GetProviderMappingMetadataErrors[keyof GetProviderMappingMetadataErrors]; +export type RevokePlacementQaOverrideError = RevokePlacementQaOverrideErrors[keyof RevokePlacementQaOverrideErrors]; -export type GetProviderMappingMetadataResponses = { +export type RevokePlacementQaOverrideResponses = { /** - * Current immutable normalized provider metadata and freshness evidence. + * Override revoked; the next immutable release omits it. */ - 200: { - data: ProviderProductMetadataSnapshot; - }; + 204: void; }; -export type GetProviderMappingMetadataResponse = GetProviderMappingMetadataResponses[keyof GetProviderMappingMetadataResponses]; +export type RevokePlacementQaOverrideResponse = RevokePlacementQaOverrideResponses[keyof RevokePlacementQaOverrideResponses]; -export type GetProviderMappingUsageData = { +export type ListExperimentsData = { body?: never; path: { - mappingId: string; + projectId: string; + environmentId: string; }; query?: never; - url: '/v1/provider-mappings/{mappingId}/usage'; -}; - -export type GetProviderMappingUsageErrors = { - /** - * Stable machine-readable failure. - */ - default: ErrorEnvelope; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments'; }; -export type GetProviderMappingUsageError = GetProviderMappingUsageErrors[keyof GetProviderMappingUsageErrors]; - -export type GetProviderMappingUsageResponses = { +export type ListExperimentsResponses = { /** - * Mapping-specific replacement impact + * Environment-scoped Experiments. */ - 200: ProviderMappingUsageEnvelope; + 200: ExperimentListEnvelope; }; -export type GetProviderMappingUsageResponse = GetProviderMappingUsageResponses[keyof GetProviderMappingUsageResponses]; +export type ListExperimentsResponse = ListExperimentsResponses[keyof ListExperimentsResponses]; -export type ListProviderMappingObservationsData = { - body?: never; +export type CreateExperimentData = { + body: CreateExperimentRequest; + headers: { + 'Idempotency-Key': string; + }; path: { - mappingId: string; + projectId: string; + environmentId: string; }; query?: never; - url: '/v1/provider-mappings/{mappingId}/observations'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments'; }; -export type ListProviderMappingObservationsErrors = { +export type CreateExperimentErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type ListProviderMappingObservationsError = ListProviderMappingObservationsErrors[keyof ListProviderMappingObservationsErrors]; +export type CreateExperimentError = CreateExperimentErrors[keyof CreateExperimentErrors]; -export type ListProviderMappingObservationsResponses = { +export type CreateExperimentResponses = { /** - * Immutable native-store observation history. + * Experiment and first Draft revision. */ - 200: { - data: Array; - }; + 201: ExperimentEnvelope; }; -export type ListProviderMappingObservationsResponse = ListProviderMappingObservationsResponses[keyof ListProviderMappingObservationsResponses]; +export type CreateExperimentResponse = CreateExperimentResponses[keyof CreateExperimentResponses]; -export type CreateProviderMappingObservationData = { - body: CreateProviderMappingObservationRequest; +export type ListExperimentMetricDefinitionsData = { + body?: never; path: { - mappingId: string; + projectId: string; + environmentId: string; }; query?: never; - url: '/v1/provider-mappings/{mappingId}/observations'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/metrics'; }; -export type CreateProviderMappingObservationErrors = { +export type ListExperimentMetricDefinitionsResponses = { /** - * Stable machine-readable failure. + * Immutable Experiment metric definitions. */ - default: ErrorEnvelope; + 200: ExperimentMetricListEnvelope; }; -export type CreateProviderMappingObservationError = CreateProviderMappingObservationErrors[keyof CreateProviderMappingObservationErrors]; +export type ListExperimentMetricDefinitionsResponse = ListExperimentMetricDefinitionsResponses[keyof ListExperimentMetricDefinitionsResponses]; -export type CreateProviderMappingObservationResponses = { +export type ListExperimentGroupsData = { + body?: never; + path: { + projectId: string; + environmentId: string; + }; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/groups'; +}; + +export type ListExperimentGroupsResponses = { /** - * Accepted immutable native-store test observation + * Mutual-exclusion groups. */ - 201: ProviderMappingObservationEnvelope; + 200: ExperimentGroupListEnvelope; }; -export type CreateProviderMappingObservationResponse = CreateProviderMappingObservationResponses[keyof CreateProviderMappingObservationResponses]; +export type ListExperimentGroupsResponse = ListExperimentGroupsResponses[keyof ListExperimentGroupsResponses]; -export type GetNativeProviderProfileData = { - body?: never; +export type CreateExperimentGroupVersionData = { + body: CreateExperimentGroupRequest; path: { - provider: 'app_store' | 'google_play'; - }; - query: { - platform: Platform; + projectId: string; + environmentId: string; }; - url: '/v1/native-providers/{provider}/profile'; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/groups'; }; -export type GetNativeProviderProfileErrors = { +export type CreateExperimentGroupVersionErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type GetNativeProviderProfileError = GetNativeProviderProfileErrors[keyof GetNativeProviderProfileErrors]; +export type CreateExperimentGroupVersionError = CreateExperimentGroupVersionErrors[keyof CreateExperimentGroupVersionErrors]; -export type GetNativeProviderProfileResponses = { +export type CreateExperimentGroupVersionResponses = { /** - * Credential-free native provider capability profile + * Group and immutable Version. */ - 200: ProviderProfileEnvelope; + 201: ExperimentGroupCreatedEnvelope; }; -export type GetNativeProviderProfileResponse = GetNativeProviderProfileResponses[keyof GetNativeProviderProfileResponses]; +export type CreateExperimentGroupVersionResponse = CreateExperimentGroupVersionResponses[keyof CreateExperimentGroupVersionResponses]; -export type ListEntitlementsData = { +export type ListExperimentMutualExclusionGroupVersionsData = { body?: never; path: { projectId: string; + environmentId: string; + groupId: string; }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; - }; - url: '/v1/projects/{projectId}/entitlements'; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/groups/{groupId}/versions'; }; -export type ListEntitlementsErrors = { +export type ListExperimentMutualExclusionGroupVersionsErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type ListEntitlementsError = ListEntitlementsErrors[keyof ListEntitlementsErrors]; +export type ListExperimentMutualExclusionGroupVersionsError = ListExperimentMutualExclusionGroupVersionsErrors[keyof ListExperimentMutualExclusionGroupVersionsErrors]; -export type ListEntitlementsResponses = { +export type ListExperimentMutualExclusionGroupVersionsResponses = { /** - * Entitlement definitions + * Immutable group Version history. */ - 200: EntitlementList; + 200: ExperimentGroupVersionListEnvelope; }; -export type ListEntitlementsResponse = ListEntitlementsResponses[keyof ListEntitlementsResponses]; +export type ListExperimentMutualExclusionGroupVersionsResponse = ListExperimentMutualExclusionGroupVersionsResponses[keyof ListExperimentMutualExclusionGroupVersionsResponses]; -export type CreateEntitlementData = { - body: CreateCatalogResourceRequest; +export type CreateExperimentMutualExclusionGroupVersionData = { + body: CreateExperimentGroupVersionRequest; path: { projectId: string; + environmentId: string; + groupId: string; }; query?: never; - url: '/v1/projects/{projectId}/entitlements'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/groups/{groupId}/versions'; }; -export type CreateEntitlementErrors = { +export type CreateExperimentMutualExclusionGroupVersionErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type CreateEntitlementError = CreateEntitlementErrors[keyof CreateEntitlementErrors]; +export type CreateExperimentMutualExclusionGroupVersionError = CreateExperimentMutualExclusionGroupVersionErrors[keyof CreateExperimentMutualExclusionGroupVersionErrors]; -export type CreateEntitlementResponses = { +export type CreateExperimentMutualExclusionGroupVersionResponses = { /** - * Entitlement definition + * New immutable group Version. */ - 201: EntitlementEnvelope; + 201: ExperimentGroupVersionEnvelope; }; -export type CreateEntitlementResponse = CreateEntitlementResponses[keyof CreateEntitlementResponses]; +export type CreateExperimentMutualExclusionGroupVersionResponse = CreateExperimentMutualExclusionGroupVersionResponses[keyof CreateExperimentMutualExclusionGroupVersionResponses]; -export type GetEntitlementData = { +export type GetExperimentData = { body?: never; path: { - entitlementId: string; + projectId: string; + environmentId: string; + experimentId: string; }; query?: never; - url: '/v1/entitlements/{entitlementId}'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}'; }; -export type GetEntitlementErrors = { +export type GetExperimentErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type GetEntitlementError = GetEntitlementErrors[keyof GetEntitlementErrors]; +export type GetExperimentError = GetExperimentErrors[keyof GetExperimentErrors]; -export type GetEntitlementResponses = { +export type GetExperimentResponses = { /** - * Entitlement definition + * Experiment root */ - 200: EntitlementEnvelope; + 200: ExperimentEnvelope; }; -export type GetEntitlementResponse = GetEntitlementResponses[keyof GetEntitlementResponses]; +export type GetExperimentResponse = GetExperimentResponses[keyof GetExperimentResponses]; -export type UpdateEntitlementData = { - body: CreateCatalogResourceRequest; +export type UpdateExperimentDraftData = { + body: UpdateExperimentDraftRequest; + headers: { + 'If-Match': string; + 'Idempotency-Key': string; + }; path: { - entitlementId: string; + projectId: string; + environmentId: string; + experimentId: string; }; query?: never; - url: '/v1/entitlements/{entitlementId}'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/draft'; }; -export type UpdateEntitlementErrors = { +export type UpdateExperimentDraftErrors = { + /** + * Stale Draft revision with currentRevision and ETag recovery details. + */ + 409: ErrorEnvelope; /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 422: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 428: ErrorEnvelope; }; -export type UpdateEntitlementError = UpdateEntitlementErrors[keyof UpdateEntitlementErrors]; +export type UpdateExperimentDraftError = UpdateExperimentDraftErrors[keyof UpdateExperimentDraftErrors]; -export type UpdateEntitlementResponses = { +export type UpdateExperimentDraftResponses = { /** - * Entitlement definition + * New immutable Draft revision. */ - 200: EntitlementEnvelope; + 200: ExperimentDraftEnvelope; }; -export type UpdateEntitlementResponse = UpdateEntitlementResponses[keyof UpdateEntitlementResponses]; +export type UpdateExperimentDraftResponse = UpdateExperimentDraftResponses[keyof UpdateExperimentDraftResponses]; -export type ListProductEntitlementsData = { +export type ValidateExperimentDraftData = { body?: never; path: { - productId: string; - }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; + projectId: string; + environmentId: string; + experimentId: string; }; - url: '/v1/products/{productId}/entitlements'; -}; - -export type ListProductEntitlementsErrors = { - /** - * Stable machine-readable failure. - */ - default: ErrorEnvelope; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/validate'; }; -export type ListProductEntitlementsError = ListProductEntitlementsErrors[keyof ListProductEntitlementsErrors]; - -export type ListProductEntitlementsResponses = { +export type ValidateExperimentDraftResponses = { /** - * Entitlement definitions + * Scientific */ - 200: EntitlementList; + 200: ExperimentValidationEnvelope; }; -export type ListProductEntitlementsResponse = ListProductEntitlementsResponses[keyof ListProductEntitlementsResponses]; +export type ValidateExperimentDraftResponse = ValidateExperimentDraftResponses[keyof ValidateExperimentDraftResponses]; -export type AddProductEntitlementData = { - body: EntitlementReferenceRequest; +export type PublishExperimentData = { + body: PublishExperimentRequest; path: { - productId: string; + projectId: string; + environmentId: string; + experimentId: string; }; query?: never; - url: '/v1/products/{productId}/entitlements'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/publish'; }; -export type AddProductEntitlementErrors = { +export type PublishExperimentErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type AddProductEntitlementError = AddProductEntitlementErrors[keyof AddProductEntitlementErrors]; +export type PublishExperimentError = PublishExperimentErrors[keyof PublishExperimentErrors]; -export type AddProductEntitlementResponses = { +export type PublishExperimentResponses = { /** - * Product grant + * Immutable Experiment Version and atomic Configuration Delivery v3 release. */ - 201: ProductEntitlementGrantEnvelope; + 201: ExperimentVersionEnvelope; }; -export type AddProductEntitlementResponse = AddProductEntitlementResponses[keyof AddProductEntitlementResponses]; +export type PublishExperimentResponse = PublishExperimentResponses[keyof PublishExperimentResponses]; -export type RemoveProductEntitlementData = { +export type ListExperimentVersionsData = { body?: never; path: { - productId: string; - entitlementId: string; + projectId: string; + environmentId: string; + experimentId: string; }; query?: never; - url: '/v1/products/{productId}/entitlements/{entitlementId}'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/versions'; }; -export type RemoveProductEntitlementErrors = { +export type ListExperimentVersionsResponses = { /** - * Stable machine-readable failure. + * Immutable Experiment Version history. */ - default: ErrorEnvelope; + 200: ExperimentVersionListEnvelope; +}; + +export type ListExperimentVersionsResponse = ListExperimentVersionsResponses[keyof ListExperimentVersionsResponses]; + +export type ListExperimentHistoryData = { + body?: never; + path: { + projectId: string; + environmentId: string; + experimentId: string; + }; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/history'; }; -export type RemoveProductEntitlementError = RemoveProductEntitlementErrors[keyof RemoveProductEntitlementErrors]; - -export type RemoveProductEntitlementResponses = { +export type ListExperimentHistoryResponses = { /** - * Entitlement grant removed. + * Audited lifecycle and publication history. */ - 204: void; + 200: ExperimentHistoryListEnvelope; }; -export type RemoveProductEntitlementResponse = RemoveProductEntitlementResponses[keyof RemoveProductEntitlementResponses]; +export type ListExperimentHistoryResponse = ListExperimentHistoryResponses[keyof ListExperimentHistoryResponses]; -export type ListAssetsData = { +export type GetExperimentResultsData = { body?: never; path: { projectId: string; + environmentId: string; + experimentId: string; }; query?: never; - url: '/v1/projects/{projectId}/assets'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/results'; }; -export type ListAssetsErrors = { +export type GetExperimentResultsResponses = { /** - * Stable machine-readable failure. + * Unique-unit conversion */ - default: ErrorEnvelope; + 200: ExperimentResultsEnvelope; }; -export type ListAssetsError = ListAssetsErrors[keyof ListAssetsErrors]; +export type GetExperimentResultsResponse = GetExperimentResultsResponses[keyof GetExperimentResultsResponses]; -export type ListAssetsResponses = { +export type GetExperimentSampleRatioMismatchData = { + body?: never; + path: { + projectId: string; + environmentId: string; + experimentId: string; + }; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/srm'; +}; + +export type GetExperimentSampleRatioMismatchResponses = { /** - * Hosted Assets + * Descriptive Pearson chi-square SRM diagnostic. */ - 200: AssetListEnvelope; + 200: { + data: ExperimentSrm; + }; }; -export type ListAssetsResponse = ListAssetsResponses[keyof ListAssetsResponses]; +export type GetExperimentSampleRatioMismatchResponse = GetExperimentSampleRatioMismatchResponses[keyof GetExperimentSampleRatioMismatchResponses]; -export type UploadAssetData = { - body: { - file: Blob | File; +export type TransitionExperimentLifecycleData = { + body?: { + reason?: string; }; path: { projectId: string; + environmentId: string; + experimentId: string; + lifecycleAction: 'schedule' | 'start' | 'pause' | 'resume' | 'stop' | 'complete' | 'archive' | 'emergency-stop'; }; query?: never; - url: '/v1/projects/{projectId}/assets'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/{lifecycleAction}'; }; -export type UploadAssetErrors = { +export type TransitionExperimentLifecycleErrors = { /** * Stable machine-readable failure. */ - 413: ErrorEnvelope; + 409: ErrorEnvelope; /** * Stable machine-readable failure. */ 422: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - default: ErrorEnvelope; }; -export type UploadAssetError = UploadAssetErrors[keyof UploadAssetErrors]; +export type TransitionExperimentLifecycleError = TransitionExperimentLifecycleErrors[keyof TransitionExperimentLifecycleErrors]; -export type UploadAssetResponses = { +export type TransitionExperimentLifecycleResponses = { /** - * Hosted Asset metadata + * Updated Experiment root. */ - 201: AssetEnvelope; + 200: ExperimentEnvelope; }; -export type UploadAssetResponse = UploadAssetResponses[keyof UploadAssetResponses]; +export type TransitionExperimentLifecycleResponse = TransitionExperimentLifecycleResponses[keyof TransitionExperimentLifecycleResponses]; -export type ArchiveAssetData = { +export type ListExperimentQaOverridesData = { body?: never; path: { projectId: string; - assetId: string; + environmentId: string; + experimentId: string; }; query?: never; - url: '/v1/projects/{projectId}/assets/{assetId}'; -}; - -export type ArchiveAssetErrors = { - /** - * Stable machine-readable failure. - */ - default: ErrorEnvelope; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/qa-overrides'; }; -export type ArchiveAssetError = ArchiveAssetErrors[keyof ArchiveAssetErrors]; - -export type ArchiveAssetResponses = { +export type ListExperimentQaOverridesResponses = { /** - * Hosted Asset metadata + * Safe QA override metadata without tokens. */ - 200: AssetEnvelope; + 200: ExperimentQaOverrideListEnvelope; }; -export type ArchiveAssetResponse = ArchiveAssetResponses[keyof ArchiveAssetResponses]; +export type ListExperimentQaOverridesResponse = ListExperimentQaOverridesResponses[keyof ListExperimentQaOverridesResponses]; -export type GetAssetData = { - body?: never; +export type CreateExperimentQaOverrideData = { + body: CreateExperimentQaOverrideRequest; path: { projectId: string; - assetId: string; + environmentId: string; + experimentId: string; }; query?: never; - url: '/v1/projects/{projectId}/assets/{assetId}'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/qa-overrides'; }; -export type GetAssetErrors = { +export type CreateExperimentQaOverrideErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 422: ErrorEnvelope; }; -export type GetAssetError = GetAssetErrors[keyof GetAssetErrors]; +export type CreateExperimentQaOverrideError = CreateExperimentQaOverrideErrors[keyof CreateExperimentQaOverrideErrors]; -export type GetAssetResponses = { +export type CreateExperimentQaOverrideResponses = { /** - * Hosted Asset metadata + * Non-production override; raw token returned once and never delivered as creator identity. */ - 200: AssetEnvelope; + 201: ExperimentQaOverrideCreatedEnvelope; }; -export type GetAssetResponse = GetAssetResponses[keyof GetAssetResponses]; +export type CreateExperimentQaOverrideResponse = CreateExperimentQaOverrideResponses[keyof CreateExperimentQaOverrideResponses]; -export type GetAssetUsageData = { +export type RevokeExperimentQaOverrideData = { body?: never; path: { projectId: string; - assetId: string; + environmentId: string; + experimentId: string; + overrideId: string; }; query?: never; - url: '/v1/projects/{projectId}/assets/{assetId}/usage'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/qa-overrides/{overrideId}'; }; -export type GetAssetUsageErrors = { +export type RevokeExperimentQaOverrideErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type GetAssetUsageError = GetAssetUsageErrors[keyof GetAssetUsageErrors]; +export type RevokeExperimentQaOverrideError = RevokeExperimentQaOverrideErrors[keyof RevokeExperimentQaOverrideErrors]; -export type GetAssetUsageResponses = { +export type RevokeExperimentQaOverrideResponses = { /** - * Hosted Asset reference counts + * Override revoked. */ - 200: AssetUsageEnvelope; + 204: void; }; -export type GetAssetUsageResponse = GetAssetUsageResponses[keyof GetAssetUsageResponses]; +export type RevokeExperimentQaOverrideResponse = RevokeExperimentQaOverrideResponses[keyof RevokeExperimentQaOverrideResponses]; -export type ListPaywallsData = { - body?: never; +export type CreateExperimentRawExportData = { + body: CreateExperimentExportRequest; path: { projectId: string; + environmentId: string; + experimentId: string; }; query?: never; - url: '/v1/projects/{projectId}/paywalls'; + url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/exports'; }; -export type ListPaywallsErrors = { +export type CreateExperimentRawExportErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type ListPaywallsError = ListPaywallsErrors[keyof ListPaywallsErrors]; +export type CreateExperimentRawExportError = CreateExperimentRawExportErrors[keyof CreateExperimentRawExportErrors]; -export type ListPaywallsResponses = { +export type CreateExperimentRawExportResponses = { /** - * Paywalls + * Asynchronous analytics job. */ - 200: PaywallListEnvelope; + 202: AnalyticsJobEnvelope; }; -export type ListPaywallsResponse = ListPaywallsResponses[keyof ListPaywallsResponses]; +export type CreateExperimentRawExportResponse = CreateExperimentRawExportResponses[keyof CreateExperimentRawExportResponses]; -export type CreatePaywallData = { - body: CreatePaywallRequest; - path: { - projectId: string; - }; +export type IngestAnalyticsEventBatchData = { + body: AnalyticsEventBatch; + path?: never; query?: never; - url: '/v1/projects/{projectId}/paywalls'; + url: '/v1/sdk/events/batch'; }; -export type CreatePaywallErrors = { +export type IngestAnalyticsEventBatchErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 429: ErrorEnvelope; }; -export type CreatePaywallError = CreatePaywallErrors[keyof CreatePaywallErrors]; +export type IngestAnalyticsEventBatchError = IngestAnalyticsEventBatchErrors[keyof IngestAnalyticsEventBatchErrors]; -export type CreatePaywallResponses = { +export type IngestAnalyticsEventBatchResponses = { /** - * Paywall + * Per-event ingestion outcomes. */ - 201: PaywallEnvelope; + 200: AnalyticsIngestionResult; }; -export type CreatePaywallResponse = CreatePaywallResponses[keyof CreatePaywallResponses]; +export type IngestAnalyticsEventBatchResponse = IngestAnalyticsEventBatchResponses[keyof IngestAnalyticsEventBatchResponses]; -export type GetPaywallData = { +export type GetAnalyticsSettingsData = { body?: never; path: { projectId: string; - paywallId: string; + environmentId: string; }; query?: never; - url: '/v1/projects/{projectId}/paywalls/{paywallId}'; + url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/settings'; }; -export type GetPaywallErrors = { +export type GetAnalyticsSettingsErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; }; -export type GetPaywallError = GetPaywallErrors[keyof GetPaywallErrors]; +export type GetAnalyticsSettingsError = GetAnalyticsSettingsErrors[keyof GetAnalyticsSettingsErrors]; -export type GetPaywallResponses = { +export type GetAnalyticsSettingsResponses = { /** - * Paywall + * Analytics settings. */ - 200: PaywallEnvelope; + 200: AnalyticsSettingsEnvelope; }; -export type GetPaywallResponse = GetPaywallResponses[keyof GetPaywallResponses]; +export type GetAnalyticsSettingsResponse = GetAnalyticsSettingsResponses[keyof GetAnalyticsSettingsResponses]; -export type UpdatePaywallData = { - body: UpdatePaywallRequest; +export type UpdateAnalyticsSettingsData = { + body: UpdateAnalyticsSettingsRequest; path: { projectId: string; - paywallId: string; + environmentId: string; }; query?: never; - url: '/v1/projects/{projectId}/paywalls/{paywallId}'; + url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/settings'; }; -export type UpdatePaywallErrors = { +export type UpdateAnalyticsSettingsErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type UpdatePaywallError = UpdatePaywallErrors[keyof UpdatePaywallErrors]; +export type UpdateAnalyticsSettingsError = UpdateAnalyticsSettingsErrors[keyof UpdateAnalyticsSettingsErrors]; -export type UpdatePaywallResponses = { +export type UpdateAnalyticsSettingsResponses = { /** - * Paywall + * Updated analytics settings. */ - 200: PaywallEnvelope; + 200: AnalyticsSettingsEnvelope; }; -export type UpdatePaywallResponse = UpdatePaywallResponses[keyof UpdatePaywallResponses]; +export type UpdateAnalyticsSettingsResponse = UpdateAnalyticsSettingsResponses[keyof UpdateAnalyticsSettingsResponses]; -export type CreatePaywallDraftData = { - body: CreateDraftRequest; - headers: { - 'Idempotency-Key': string; - }; +export type GetAnalyticsOverviewData = { + body?: never; path: { projectId: string; - paywallId: string; + environmentId: string; }; - query?: never; - url: '/v1/projects/{projectId}/paywalls/{paywallId}/drafts'; + query: { + from: Timestamp; + to: Timestamp; + timezone: string; + metricBasis: 'event_count'; + platform?: 'ios' | 'android'; + locale?: string; + applicationVersion?: string; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/overview'; }; -export type CreatePaywallDraftErrors = { +export type GetAnalyticsOverviewErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type CreatePaywallDraftError = CreatePaywallDraftErrors[keyof CreatePaywallDraftErrors]; +export type GetAnalyticsOverviewError = GetAnalyticsOverviewErrors[keyof GetAnalyticsOverviewErrors]; -export type CreatePaywallDraftResponses = { +export type GetAnalyticsOverviewResponses = { /** - * Hosted Draft and current immutable revision document. + * Event-count analytics result. */ - 201: DraftEnvelope; + 200: AnalyticsResultEnvelope; }; -export type CreatePaywallDraftResponse = CreatePaywallDraftResponses[keyof CreatePaywallDraftResponses]; +export type GetAnalyticsOverviewResponse = GetAnalyticsOverviewResponses[keyof GetAnalyticsOverviewResponses]; -export type GetActivePaywallDraftData = { +export type GetAnalyticsFunnelData = { body?: never; path: { projectId: string; - paywallId: string; + environmentId: string; + funnel: 'placements' | 'paywalls' | 'products' | 'purchases'; }; query: { - environmentId: string; + from: Timestamp; + to: Timestamp; + timezone: string; + metricBasis: 'event_count'; + platform?: 'ios' | 'android'; + locale?: string; + applicationVersion?: string; }; - url: '/v1/projects/{projectId}/paywalls/{paywallId}/drafts/active'; + url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/funnels/{funnel}'; }; -export type GetActivePaywallDraftErrors = { +export type GetAnalyticsFunnelErrors = { /** * Stable machine-readable failure. */ @@ -5319,485 +8363,586 @@ export type GetActivePaywallDraftErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 422: ErrorEnvelope; }; -export type GetActivePaywallDraftError = GetActivePaywallDraftErrors[keyof GetActivePaywallDraftErrors]; +export type GetAnalyticsFunnelError = GetAnalyticsFunnelErrors[keyof GetAnalyticsFunnelErrors]; -export type GetActivePaywallDraftResponses = { +export type GetAnalyticsFunnelResponses = { /** - * Hosted Draft and current immutable revision document. + * Event-count analytics result. */ - 200: DraftEnvelope; + 200: AnalyticsResultEnvelope; }; -export type GetActivePaywallDraftResponse = GetActivePaywallDraftResponses[keyof GetActivePaywallDraftResponses]; +export type GetAnalyticsFunnelResponse = GetAnalyticsFunnelResponses[keyof GetAnalyticsFunnelResponses]; -export type GetPaywallDraftData = { +export type CompareAnalyticsPaywallVersionsData = { body?: never; path: { projectId: string; - paywallId: string; - draftId: string; + environmentId: string; }; - query?: never; - url: '/v1/projects/{projectId}/paywalls/{paywallId}/drafts/{draftId}'; + query: { + from: Timestamp; + to: Timestamp; + timezone: string; + metricBasis: 'event_count'; + platform?: 'ios' | 'android'; + locale?: string; + applicationVersion?: string; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/paywall-version-comparison'; }; -export type GetPaywallDraftErrors = { +export type CompareAnalyticsPaywallVersionsErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 422: ErrorEnvelope; }; -export type GetPaywallDraftError = GetPaywallDraftErrors[keyof GetPaywallDraftErrors]; +export type CompareAnalyticsPaywallVersionsError = CompareAnalyticsPaywallVersionsErrors[keyof CompareAnalyticsPaywallVersionsErrors]; -export type GetPaywallDraftResponses = { +export type CompareAnalyticsPaywallVersionsResponses = { /** - * Hosted Draft and current immutable revision document. + * Event-count analytics result. */ - 200: DraftEnvelope; + 200: AnalyticsResultEnvelope; }; -export type GetPaywallDraftResponse = GetPaywallDraftResponses[keyof GetPaywallDraftResponses]; +export type CompareAnalyticsPaywallVersionsResponse = CompareAnalyticsPaywallVersionsResponses[keyof CompareAnalyticsPaywallVersionsResponses]; -export type UpdatePaywallDraftData = { - body: UpdateDraftRequest; - headers: { - 'If-Match': string; - 'Idempotency-Key': string; - }; +export type GetAnalyticsProviderErrorsData = { + body?: never; path: { projectId: string; - paywallId: string; - draftId: string; + environmentId: string; }; - query?: never; - url: '/v1/projects/{projectId}/paywalls/{paywallId}/drafts/{draftId}'; + query: { + from: Timestamp; + to: Timestamp; + timezone: string; + metricBasis: 'event_count'; + platform?: 'ios' | 'android'; + locale?: string; + applicationVersion?: string; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/provider-errors'; }; -export type UpdatePaywallDraftErrors = { +export type GetAnalyticsProviderErrorsErrors = { /** * Stable machine-readable failure. */ - 412: ErrorEnvelope; + 422: ErrorEnvelope; +}; + +export type GetAnalyticsProviderErrorsError = GetAnalyticsProviderErrorsErrors[keyof GetAnalyticsProviderErrorsErrors]; + +export type GetAnalyticsProviderErrorsResponses = { /** - * Stable machine-readable failure. + * Event-count analytics result. */ - 428: ErrorEnvelope; + 200: AnalyticsResultEnvelope; +}; + +export type GetAnalyticsProviderErrorsResponse = GetAnalyticsProviderErrorsResponses[keyof GetAnalyticsProviderErrorsResponses]; + +export type GetAnalyticsProductAvailabilityFailuresData = { + body?: never; + path: { + projectId: string; + environmentId: string; + }; + query: { + from: Timestamp; + to: Timestamp; + timezone: string; + metricBasis: 'event_count'; + platform?: 'ios' | 'android'; + locale?: string; + applicationVersion?: string; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/product-availability-failures'; +}; + +export type GetAnalyticsProductAvailabilityFailuresErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 422: ErrorEnvelope; }; -export type UpdatePaywallDraftError = UpdatePaywallDraftErrors[keyof UpdatePaywallDraftErrors]; +export type GetAnalyticsProductAvailabilityFailuresError = GetAnalyticsProductAvailabilityFailuresErrors[keyof GetAnalyticsProductAvailabilityFailuresErrors]; -export type UpdatePaywallDraftResponses = { +export type GetAnalyticsProductAvailabilityFailuresResponses = { /** - * Hosted Draft and current immutable revision document. + * Event-count analytics result. */ - 200: DraftEnvelope; + 200: AnalyticsResultEnvelope; }; -export type UpdatePaywallDraftResponse = UpdatePaywallDraftResponses[keyof UpdatePaywallDraftResponses]; +export type GetAnalyticsProductAvailabilityFailuresResponse = GetAnalyticsProductAvailabilityFailuresResponses[keyof GetAnalyticsProductAvailabilityFailuresResponses]; -export type ValidatePaywallDraftData = { +export type GetAnalyticsBreakdownData = { body?: never; path: { projectId: string; - paywallId: string; - draftId: string; + environmentId: string; + dimension: 'platforms' | 'locales'; }; - query?: never; - url: '/v1/projects/{projectId}/paywalls/{paywallId}/drafts/{draftId}/validate'; + query: { + from: Timestamp; + to: Timestamp; + timezone: string; + metricBasis: 'event_count'; + platform?: 'ios' | 'android'; + locale?: string; + applicationVersion?: string; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/breakdowns/{dimension}'; }; -export type ValidatePaywallDraftErrors = { +export type GetAnalyticsBreakdownErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type ValidatePaywallDraftError = ValidatePaywallDraftErrors[keyof ValidatePaywallDraftErrors]; +export type GetAnalyticsBreakdownError = GetAnalyticsBreakdownErrors[keyof GetAnalyticsBreakdownErrors]; -export type ValidatePaywallDraftResponses = { +export type GetAnalyticsBreakdownResponses = { /** - * Draft validation + * Event-count analytics result. */ - 200: ValidationSummaryEnvelope; + 200: AnalyticsResultEnvelope; }; -export type ValidatePaywallDraftResponse = ValidatePaywallDraftResponses[keyof ValidatePaywallDraftResponses]; +export type GetAnalyticsBreakdownResponse = GetAnalyticsBreakdownResponses[keyof GetAnalyticsBreakdownResponses]; -export type ListPaywallVersionsData = { +export type GetAnalyticsFreshnessData = { body?: never; path: { projectId: string; - paywallId: string; + environmentId: string; }; - query?: never; - url: '/v1/projects/{projectId}/paywalls/{paywallId}/versions'; + query: { + from: Timestamp; + to: Timestamp; + timezone: string; + metricBasis: 'event_count'; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/freshness'; }; -export type ListPaywallVersionsErrors = { +export type GetAnalyticsFreshnessErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 422: ErrorEnvelope; }; -export type ListPaywallVersionsError = ListPaywallVersionsErrors[keyof ListPaywallVersionsErrors]; +export type GetAnalyticsFreshnessError = GetAnalyticsFreshnessErrors[keyof GetAnalyticsFreshnessErrors]; -export type ListPaywallVersionsResponses = { +export type GetAnalyticsFreshnessResponses = { /** - * Immutable Paywall Versions + * Event-count analytics result. */ - 200: PaywallVersionListEnvelope; + 200: AnalyticsResultEnvelope; }; -export type ListPaywallVersionsResponse = ListPaywallVersionsResponses[keyof ListPaywallVersionsResponses]; +export type GetAnalyticsFreshnessResponse = GetAnalyticsFreshnessResponses[keyof GetAnalyticsFreshnessResponses]; -export type GetPaywallVersionData = { - body?: never; +export type CreateAnalyticsEventExportData = { + body: CreateAnalyticsEventExportRequest; path: { projectId: string; - paywallId: string; - versionId: string; + environmentId: string; }; query?: never; - url: '/v1/projects/{projectId}/paywalls/{paywallId}/versions/{versionId}'; + url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/exports'; }; -export type GetPaywallVersionErrors = { +export type CreateAnalyticsEventExportErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type GetPaywallVersionError = GetPaywallVersionErrors[keyof GetPaywallVersionErrors]; +export type CreateAnalyticsEventExportError = CreateAnalyticsEventExportErrors[keyof CreateAnalyticsEventExportErrors]; -export type GetPaywallVersionResponses = { +export type CreateAnalyticsEventExportResponses = { /** - * Immutable Paywall Version + * Asynchronous analytics job. */ - 200: PaywallVersionEnvelope; + 202: AnalyticsJobEnvelope; }; -export type GetPaywallVersionResponse = GetPaywallVersionResponses[keyof GetPaywallVersionResponses]; +export type CreateAnalyticsEventExportResponse = CreateAnalyticsEventExportResponses[keyof CreateAnalyticsEventExportResponses]; -export type ClonePaywallVersionToDraftData = { - body?: never; - headers: { - 'Idempotency-Key': string; - }; +export type PreviewAnalyticsPrivacyRequestData = { + body: AnalyticsIdentityRequest; path: { projectId: string; - paywallId: string; - versionId: string; }; query?: never; - url: '/v1/projects/{projectId}/paywalls/{paywallId}/versions/{versionId}/drafts'; + url: '/v1/projects/{projectId}/analytics/privacy/preview'; }; -export type ClonePaywallVersionToDraftErrors = { +export type PreviewAnalyticsPrivacyRequestErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; }; -export type ClonePaywallVersionToDraftError = ClonePaywallVersionToDraftErrors[keyof ClonePaywallVersionToDraftErrors]; +export type PreviewAnalyticsPrivacyRequestError = PreviewAnalyticsPrivacyRequestErrors[keyof PreviewAnalyticsPrivacyRequestErrors]; -export type ClonePaywallVersionToDraftResponses = { +export type PreviewAnalyticsPrivacyRequestResponses = { /** - * Hosted Draft and current immutable revision document. + * Privacy impact preview. */ - 201: DraftEnvelope; + 200: AnalyticsPrivacyPreviewEnvelope; }; -export type ClonePaywallVersionToDraftResponse = ClonePaywallVersionToDraftResponses[keyof ClonePaywallVersionToDraftResponses]; +export type PreviewAnalyticsPrivacyRequestResponse = PreviewAnalyticsPrivacyRequestResponses[keyof PreviewAnalyticsPrivacyRequestResponses]; -export type ListPlacementsData = { - body?: never; +export type CreateAnalyticsPrivacyExportData = { + body: CreateAnalyticsPrivacyExportRequest; path: { projectId: string; }; query?: never; - url: '/v1/projects/{projectId}/placements'; + url: '/v1/projects/{projectId}/analytics/privacy/exports'; }; -export type ListPlacementsErrors = { +export type CreateAnalyticsPrivacyExportErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type ListPlacementsError = ListPlacementsErrors[keyof ListPlacementsErrors]; +export type CreateAnalyticsPrivacyExportError = CreateAnalyticsPrivacyExportErrors[keyof CreateAnalyticsPrivacyExportErrors]; -export type ListPlacementsResponses = { +export type CreateAnalyticsPrivacyExportResponses = { /** - * Placements + * Asynchronous analytics job. */ - 200: PlacementListEnvelope; + 202: AnalyticsJobEnvelope; }; -export type ListPlacementsResponse = ListPlacementsResponses[keyof ListPlacementsResponses]; +export type CreateAnalyticsPrivacyExportResponse = CreateAnalyticsPrivacyExportResponses[keyof CreateAnalyticsPrivacyExportResponses]; -export type CreatePlacementData = { - body: CreatePlacementRequest; +export type CreateAnalyticsPrivacyDeletionData = { + body: CreateAnalyticsPrivacyDeletionRequest; path: { projectId: string; }; query?: never; - url: '/v1/projects/{projectId}/placements'; + url: '/v1/projects/{projectId}/analytics/privacy/deletions'; }; -export type CreatePlacementErrors = { +export type CreateAnalyticsPrivacyDeletionErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type CreatePlacementError = CreatePlacementErrors[keyof CreatePlacementErrors]; +export type CreateAnalyticsPrivacyDeletionError = CreateAnalyticsPrivacyDeletionErrors[keyof CreateAnalyticsPrivacyDeletionErrors]; -export type CreatePlacementResponses = { +export type CreateAnalyticsPrivacyDeletionResponses = { /** - * Placement + * Asynchronous analytics job. */ - 201: PlacementEnvelope; + 202: AnalyticsJobEnvelope; }; -export type CreatePlacementResponse = CreatePlacementResponses[keyof CreatePlacementResponses]; +export type CreateAnalyticsPrivacyDeletionResponse = CreateAnalyticsPrivacyDeletionResponses[keyof CreateAnalyticsPrivacyDeletionResponses]; -export type UpdatePlacementData = { - body: UpdatePlacementRequest; +export type GetAnalyticsJobData = { + body?: never; path: { projectId: string; - placementId: string; + jobId: string; }; query?: never; - url: '/v1/projects/{projectId}/placements/{placementId}'; + url: '/v1/projects/{projectId}/analytics/jobs/{jobId}'; }; -export type UpdatePlacementErrors = { +export type GetAnalyticsJobErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; }; -export type UpdatePlacementError = UpdatePlacementErrors[keyof UpdatePlacementErrors]; +export type GetAnalyticsJobError = GetAnalyticsJobErrors[keyof GetAnalyticsJobErrors]; -export type UpdatePlacementResponses = { +export type GetAnalyticsJobResponses = { /** - * Placement + * Asynchronous analytics job. */ - 200: PlacementEnvelope; + 200: AnalyticsJobEnvelope; }; -export type UpdatePlacementResponse = UpdatePlacementResponses[keyof UpdatePlacementResponses]; +export type GetAnalyticsJobResponse = GetAnalyticsJobResponses[keyof GetAnalyticsJobResponses]; -export type GetPlacementBindingData = { +export type DownloadAnalyticsJobData = { body?: never; path: { projectId: string; - environmentId: string; - placementId: string; + jobId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/binding'; + url: '/v1/projects/{projectId}/analytics/jobs/{jobId}/download'; }; -export type GetPlacementBindingErrors = { +export type DownloadAnalyticsJobErrors = { /** * Stable machine-readable failure. */ - 404: ErrorEnvelope; + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type GetPlacementBindingError = GetPlacementBindingErrors[keyof GetPlacementBindingErrors]; +export type DownloadAnalyticsJobError = DownloadAnalyticsJobErrors[keyof DownloadAnalyticsJobErrors]; -export type GetPlacementBindingResponses = { +export type DownloadAnalyticsJobResponses = { /** - * Environment Placement binding + * Private export artifact. */ - 200: PlacementBindingEnvelope; + 200: Blob | File; }; -export type GetPlacementBindingResponse = GetPlacementBindingResponses[keyof GetPlacementBindingResponses]; +export type DownloadAnalyticsJobResponse = DownloadAnalyticsJobResponses[keyof DownloadAnalyticsJobResponses]; -export type BindPlacementData = { - body: BindPlacementRequest; +export type ReceiveAppleStoreNotificationData = { + body: { + /** + * Apple JWS notification payload. Never logged or echoed. + */ + signedPayload: string; + }; path: { - projectId: string; - environmentId: string; - placementId: string; + /** + * One-time intake token issued with the credential. + */ + intakeToken: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/binding'; + url: '/v1/billing/apple/notifications/{intakeToken}'; }; -export type BindPlacementErrors = { +export type ReceiveAppleStoreNotificationErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type BindPlacementError = BindPlacementErrors[keyof BindPlacementErrors]; +export type ReceiveAppleStoreNotificationError = ReceiveAppleStoreNotificationErrors[keyof ReceiveAppleStoreNotificationErrors]; -export type BindPlacementResponses = { +export type ReceiveAppleStoreNotificationResponses = { /** - * Environment Placement binding + * The notification was durably recorded and queued for validation. */ - 200: PlacementBindingEnvelope; + 202: { + data?: { + status?: 'accepted'; + }; + }; }; -export type BindPlacementResponse = BindPlacementResponses[keyof BindPlacementResponses]; +export type ReceiveAppleStoreNotificationResponse = ReceiveAppleStoreNotificationResponses[keyof ReceiveAppleStoreNotificationResponses]; -export type PublishConfigurationData = { - body: PublishRequest; - headers: { - 'Idempotency-Key': string; - }; - path: { - projectId: string; - environmentId: string; +export type SubmitTransactionObservationData = { + body: ClientTransactionObservationRecord; + headers?: { + /** + * Optional Customer Access Token binding the observation to its customer as token-bound public-client evidence. It can attach an unowned lineage but cannot reassign or freeze an attached lineage. + */ + 'Mosaic-Customer-Token'?: string; }; + path?: never; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/publish'; + url: '/v1/sdk/billing/observations'; }; -export type PublishConfigurationErrors = { +export type SubmitTransactionObservationErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Resubmitting the identical document cannot succeed. The SDK queue should drop it. + */ + 422: ObservationSubmissionResultRecord; + /** + * Transient. Resubmit the identical document later; Retry-After carries the hint. + */ + 429: ObservationSubmissionResultRecord; + /** + * Transient. Resubmit the identical document later; Retry-After carries the hint. + */ + 503: ObservationSubmissionResultRecord; }; -export type PublishConfigurationError = PublishConfigurationErrors[keyof PublishConfigurationErrors]; +export type SubmitTransactionObservationError = SubmitTransactionObservationErrors[keyof SubmitTransactionObservationErrors]; -export type PublishConfigurationResponses = { +export type SubmitTransactionObservationResponses = { /** - * Publication result and nonblocking warnings + * The submission was already recorded. A duplicate is idempotent, not an error. */ - 201: PublishResultEnvelope; + 200: ObservationSubmissionResultRecord; + /** + * The observation is well formed and queued for validation. Nothing more: the store has not been consulted when this response is written. + */ + 202: ObservationSubmissionResultRecord; }; -export type PublishConfigurationResponse = PublishConfigurationResponses[keyof PublishConfigurationResponses]; +export type SubmitTransactionObservationResponse = SubmitTransactionObservationResponses[keyof SubmitTransactionObservationResponses]; -export type ListConfigurationReleasesData = { - body?: never; - path: { - projectId: string; - environmentId: string; +export type SubmitServerTransactionObservationData = { + body: ServerTransactionObservationRecord; + headers?: { + /** + * Optional Customer Access Token naming the customer. Because this request is authenticated by the secret server key + */ + 'Mosaic-Customer-Token'?: string; }; + path?: never; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/releases'; + url: '/v1/billing/server/observations'; }; -export type ListConfigurationReleasesErrors = { +export type SubmitServerTransactionObservationErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Resubmitting the identical document cannot succeed. The SDK queue should drop it. + */ + 422: ObservationSubmissionResultRecord; + /** + * Transient. Resubmit the identical document later; Retry-After carries the hint. + */ + 429: ObservationSubmissionResultRecord; + /** + * Transient. Resubmit the identical document later; Retry-After carries the hint. + */ + 503: ObservationSubmissionResultRecord; }; -export type ListConfigurationReleasesError = ListConfigurationReleasesErrors[keyof ListConfigurationReleasesErrors]; +export type SubmitServerTransactionObservationError = SubmitServerTransactionObservationErrors[keyof SubmitServerTransactionObservationErrors]; -export type ListConfigurationReleasesResponses = { +export type SubmitServerTransactionObservationResponses = { /** - * Configuration Release history + * The submission was already recorded. A duplicate is idempotent, not an error. */ - 200: ReleaseListEnvelope; + 200: ObservationSubmissionResultRecord; + /** + * The observation is well formed and queued for validation. Nothing more: the store has not been consulted when this response is written. + */ + 202: ObservationSubmissionResultRecord; }; -export type ListConfigurationReleasesResponse = ListConfigurationReleasesResponses[keyof ListConfigurationReleasesResponses]; +export type SubmitServerTransactionObservationResponse = SubmitServerTransactionObservationResponses[keyof SubmitServerTransactionObservationResponses]; -export type RollbackConfigurationReleaseData = { +export type SyncCustomerEntitlementsData = { body?: never; headers: { - 'Idempotency-Key': string; - }; - path: { - projectId: string; - environmentId: string; - releaseId: string; + /** + * Public SDK key identifying the Environment and Application. + */ + 'Mosaic-SDK-Key': string; }; + path?: never; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/releases/{releaseId}/rollback'; + url: '/v1/sdk/billing/entitlements'; }; -export type RollbackConfigurationReleaseErrors = { +export type SyncCustomerEntitlementsErrors = { /** * Stable machine-readable failure. */ - default: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 429: ErrorEnvelope; }; -export type RollbackConfigurationReleaseError = RollbackConfigurationReleaseErrors[keyof RollbackConfigurationReleaseErrors]; +export type SyncCustomerEntitlementsError = SyncCustomerEntitlementsErrors[keyof SyncCustomerEntitlementsErrors]; -export type RollbackConfigurationReleaseResponses = { +export type SyncCustomerEntitlementsResponses = { /** - * Immutable Configuration Release metadata + * The current Customer Entitlement Snapshot. */ - 201: ReleaseEnvelope; + 200: CustomerEntitlementSnapshotRecord; }; -export type RollbackConfigurationReleaseResponse = RollbackConfigurationReleaseResponses[keyof RollbackConfigurationReleaseResponses]; +export type SyncCustomerEntitlementsResponse = SyncCustomerEntitlementsResponses[keyof SyncCustomerEntitlementsResponses]; -export type GetSdkConfigurationData = { - body?: never; +export type SyncCustomerEntitlementsWithNegotiationData = { + body: EntitlementSyncRequestRecord; headers: { - 'Mosaic-SDK-Platform': 'flutter' | 'ios' | 'android'; - 'Mosaic-SDK-Version': string; - 'Mosaic-Configuration-Versions': string; - 'Mosaic-Paywall-Protocol-Versions': '0.2'; - /** - * Comma-separated unique exact Protocol capability pairs (`name@0.2`), bounded to 128 pairs. The selected Release is returned only when every required pair is reported. - */ - 'Mosaic-Paywall-Capabilities': string; - 'Mosaic-Placement-Decision-Versions'?: string; - /** - * Comma-separated exact Placement Decision v1 feature identifiers. - */ - 'Mosaic-Decision-Features'?: string; - 'Mosaic-Bucketing-Algorithms'?: 'sha256_length_prefixed_v1'; - /** - * Required when Delivery v3 is requested. Comma-separated unique Experiment Assignment contract versions. - */ - 'Mosaic-Experiment-Assignment-Versions'?: string; - /** - * Required when Delivery v3 is requested. Comma-separated unique exact Experiment Assignment v1 feature identifiers. - */ - 'Mosaic-Experiment-Features'?: string; - /** - * Required when Delivery v3 is requested. Comma-separated unique canonical Variant and Group bucketing algorithms. - */ - 'Mosaic-Experiment-Bucketing-Algorithms'?: string; - /** - * Required when Delivery v3 is requested. Comma-separated unique trusted-time schedule policies. - */ - 'Mosaic-Experiment-Schedule-Policies'?: string; - 'Mosaic-App-Version'?: string; - 'If-None-Match'?: string; + 'Mosaic-SDK-Key': string; }; path?: never; query?: never; - url: '/v1/sdk/configuration'; + url: '/v1/sdk/billing/entitlements'; }; -export type GetSdkConfigurationErrors = { +export type SyncCustomerEntitlementsWithNegotiationErrors = { /** * Stable machine-readable failure. */ @@ -5805,51 +8950,52 @@ export type GetSdkConfigurationErrors = { /** * Stable machine-readable failure. */ - 404: ErrorEnvelope; + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ 406: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; /** * Stable machine-readable failure. */ 429: ErrorEnvelope; }; -export type GetSdkConfigurationError = GetSdkConfigurationErrors[keyof GetSdkConfigurationErrors]; +export type SyncCustomerEntitlementsWithNegotiationError = SyncCustomerEntitlementsWithNegotiationErrors[keyof SyncCustomerEntitlementsWithNegotiationErrors]; -export type GetSdkConfigurationResponses = { +export type SyncCustomerEntitlementsWithNegotiationResponses = { /** - * Highest mutually supported representation actually available for the current immutable Release. A v3-capable SDK falls back to an available v2 or safe v1 representation when that Release predates v3. Delivery v3 requires complete Experiment capability headers. A v1 candidate is withheld when an advanced Placement lacks an explicit Paywall default. + * The current Customer Entitlement Snapshot, or — when the stated knownSnapshotVersion + * and entity tag both match — the canonical snapshotUnchanged record. This form never + * answers 304: a bodyless response would force freshness into header names no frozen + * schema defines, so the unchanged record carries refreshAfter, validUntil, and + * staleGraceSeconds in the body instead, recomputed at the instant it was answered. + * */ - 200: { - [key: string]: unknown; - }; + 200: CustomerEntitlementSnapshotRecord; }; -export type GetSdkConfigurationResponse = GetSdkConfigurationResponses[keyof GetSdkConfigurationResponses]; +export type SyncCustomerEntitlementsWithNegotiationResponse = SyncCustomerEntitlementsWithNegotiationResponses[keyof SyncCustomerEntitlementsWithNegotiationResponses]; -export type GetSdkCommerceConfigurationData = { +export type ListCustomerAccessTokensData = { body?: never; - headers: { - /** - * Comma-separated accepted Commerce media types. Supported versions are application/vnd.mosaic.commerce-configuration+json;version=1 and version=2. - */ - Accept: string; - 'Mosaic-SDK-Platform': 'flutter' | 'ios' | 'android'; - 'Mosaic-SDK-Version': string; - 'Mosaic-Commerce-Configuration-Versions': string; - 'Mosaic-Commerce-Provider-Contract-Versions': string; - 'If-None-Match'?: string; - }; path?: never; query: { - applicationId: string; + billingCustomerId: string; + limit?: number; }; - url: '/v1/sdk/commerce-configuration'; + url: '/v1/billing/server/customer-tokens'; }; -export type GetSdkCommerceConfigurationErrors = { +export type ListCustomerAccessTokensErrors = { /** * Stable machine-readable failure. */ @@ -5857,45 +9003,40 @@ export type GetSdkCommerceConfigurationErrors = { /** * Stable machine-readable failure. */ - 404: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 406: ErrorEnvelope; + 409: ErrorEnvelope; /** * Stable machine-readable failure. */ 422: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 429: ErrorEnvelope; }; -export type GetSdkCommerceConfigurationError = GetSdkCommerceConfigurationErrors[keyof GetSdkCommerceConfigurationErrors]; +export type ListCustomerAccessTokensError = ListCustomerAccessTokensErrors[keyof ListCustomerAccessTokensErrors]; -export type GetSdkCommerceConfigurationResponses = { +export type ListCustomerAccessTokensResponses = { /** - * Immutable version-negotiated Commerce Configuration sidecar associated with the current Configuration Release and requested Application. + * Token metadata. */ 200: { - [key: string]: unknown; + data?: { + items?: Array; + }; }; }; -export type GetSdkCommerceConfigurationResponse = GetSdkCommerceConfigurationResponses[keyof GetSdkCommerceConfigurationResponses]; +export type ListCustomerAccessTokensResponse = ListCustomerAccessTokensResponses[keyof ListCustomerAccessTokensResponses]; -export type GetAssetContentData = { - body?: never; - path: { - assetId: string; - contentDigest: string; - }; +export type IssueCustomerAccessTokenData = { + body: CustomerAccessTokenIssuanceRequest; + path?: never; query?: never; - url: '/v1/sdk/assets/{assetId}/{contentDigest}'; + url: '/v1/billing/server/customer-tokens'; }; -export type GetAssetContentErrors = { +export type IssueCustomerAccessTokenErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -5903,30 +9044,36 @@ export type GetAssetContentErrors = { /** * Stable machine-readable failure. */ - 503: ErrorEnvelope; + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type GetAssetContentError = GetAssetContentErrors[keyof GetAssetContentErrors]; +export type IssueCustomerAccessTokenError = IssueCustomerAccessTokenErrors[keyof IssueCustomerAccessTokenErrors]; -export type GetAssetContentResponses = { +export type IssueCustomerAccessTokenResponses = { /** - * Immutable Asset content. + * The token and its metadata. The token value appears here and nowhere else. */ - 200: Blob | File; + 201: CustomerAccessTokenIssuanceResult; }; -export type GetAssetContentResponse = GetAssetContentResponses[keyof GetAssetContentResponses]; +export type IssueCustomerAccessTokenResponse = IssueCustomerAccessTokenResponses[keyof IssueCustomerAccessTokenResponses]; -export type ListPlacementAttributesData = { - body?: never; +export type RevokeCustomerAccessTokenData = { + body: { + revocationReason: 'customer_signed_out' | 'identity_changed' | 'operator_revoked' | 'customer_deleted' | 'key_rotated' | 'suspected_compromise' | 'superseded_by_new_token'; + }; path: { - projectId: string; + tokenId: string; }; query?: never; - url: '/v1/projects/{projectId}/placement-attributes'; + url: '/v1/billing/server/customer-tokens/{tokenId}/revoke'; }; -export type ListPlacementAttributesErrors = { +export type RevokeCustomerAccessTokenErrors = { /** * Stable machine-readable failure. */ @@ -5934,30 +9081,48 @@ export type ListPlacementAttributesErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type ListPlacementAttributesError = ListPlacementAttributesErrors[keyof ListPlacementAttributesErrors]; +export type RevokeCustomerAccessTokenError = RevokeCustomerAccessTokenErrors[keyof RevokeCustomerAccessTokenErrors]; -export type ListPlacementAttributesResponses = { +export type RevokeCustomerAccessTokenResponses = { /** - * Project attribute allow-list. + * The revoked token's metadata. */ - 200: PlacementAttributeListEnvelope; + 200: { + data?: CustomerAccessTokenMetadata; + }; }; -export type ListPlacementAttributesResponse = ListPlacementAttributesResponses[keyof ListPlacementAttributesResponses]; +export type RevokeCustomerAccessTokenResponse = RevokeCustomerAccessTokenResponses[keyof RevokeCustomerAccessTokenResponses]; -export type CreatePlacementAttributeData = { - body: CreatePlacementAttributeRequest; - path: { - projectId: string; +export type SubmitSdkRestoreData = { + body: RestoreRequestRecord; + headers: { + /** + * Public SDK key identifying the Environment and Application. It proves which Environment is asking and can never select a customer. + */ + 'Mosaic-SDK-Key': string; }; + path?: never; query?: never; - url: '/v1/projects/{projectId}/placement-attributes'; + url: '/v1/sdk/billing/restores'; }; -export type CreatePlacementAttributeErrors = { +export type SubmitSdkRestoreErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -5966,687 +9131,820 @@ export type CreatePlacementAttributeErrors = { * Stable machine-readable failure. */ 422: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 429: ErrorEnvelope; }; -export type CreatePlacementAttributeError = CreatePlacementAttributeErrors[keyof CreatePlacementAttributeErrors]; +export type SubmitSdkRestoreError = SubmitSdkRestoreErrors[keyof SubmitSdkRestoreErrors]; -export type CreatePlacementAttributeResponses = { +export type SubmitSdkRestoreResponses = { /** - * Attribute definition created. + * The restore was recorded and the chain is running. */ - 201: PlacementAttributeEnvelope; + 202: RestoreResultRecord; }; -export type CreatePlacementAttributeResponse = CreatePlacementAttributeResponses[keyof CreatePlacementAttributeResponses]; +export type SubmitSdkRestoreResponse = SubmitSdkRestoreResponses[keyof SubmitSdkRestoreResponses]; -export type ArchivePlacementAttributeData = { +export type GetSdkRestoreData = { body?: never; + headers: { + 'Mosaic-SDK-Key': string; + }; path: { - projectId: string; - attributeId: string; + restoreId: string; }; query?: never; - url: '/v1/projects/{projectId}/placement-attributes/{attributeId}'; + url: '/v1/sdk/billing/restores/{restoreId}'; }; -export type ArchivePlacementAttributeErrors = { +export type GetSdkRestoreErrors = { /** * Stable machine-readable failure. */ - 404: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type ArchivePlacementAttributeError = ArchivePlacementAttributeErrors[keyof ArchivePlacementAttributeErrors]; +export type GetSdkRestoreError = GetSdkRestoreErrors[keyof GetSdkRestoreErrors]; -export type ArchivePlacementAttributeResponses = { +export type GetSdkRestoreResponses = { /** - * Attribute definition archived. + * The restore record. */ - 204: void; + 200: RestoreResultRecord; }; -export type ArchivePlacementAttributeResponse = ArchivePlacementAttributeResponses[keyof ArchivePlacementAttributeResponses]; +export type GetSdkRestoreResponse = GetSdkRestoreResponses[keyof GetSdkRestoreResponses]; -export type GetPlacementUsageData = { - body?: never; - path: { - projectId: string; - placementId: string; - }; +export type SubmitServerRestoreData = { + body: RestoreRequestRecord; + path?: never; query?: never; - url: '/v1/projects/{projectId}/placements/{placementId}/usage'; + url: '/v1/billing/server/restores'; }; -export type GetPlacementUsageErrors = { +export type SubmitServerRestoreErrors = { /** * Stable machine-readable failure. */ - 404: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type GetPlacementUsageError = GetPlacementUsageErrors[keyof GetPlacementUsageErrors]; +export type SubmitServerRestoreError = SubmitServerRestoreErrors[keyof SubmitServerRestoreErrors]; -export type GetPlacementUsageResponses = { +export type SubmitServerRestoreResponses = { /** - * Placement usage. + * The restore was recorded and the chain is running. */ - 200: PlacementUsageEnvelope; + 202: RestoreResultRecord; }; -export type GetPlacementUsageResponse = GetPlacementUsageResponses[keyof GetPlacementUsageResponses]; +export type SubmitServerRestoreResponse = SubmitServerRestoreResponses[keyof SubmitServerRestoreResponses]; -export type ListPlacementAliasesData = { +export type GetServerRestoreData = { body?: never; path: { - projectId: string; - placementId: string; + restoreId: string; }; query?: never; - url: '/v1/projects/{projectId}/placements/{placementId}/aliases'; + url: '/v1/billing/server/restores/{restoreId}'; }; -export type ListPlacementAliasesResponses = { +export type GetServerRestoreErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; +}; + +export type GetServerRestoreError = GetServerRestoreErrors[keyof GetServerRestoreErrors]; + +export type GetServerRestoreResponses = { /** - * Placement aliases. + * The restore record. */ - 200: PlacementAliasListEnvelope; + 200: RestoreResultRecord; }; -export type ListPlacementAliasesResponse = ListPlacementAliasesResponses[keyof ListPlacementAliasesResponses]; +export type GetServerRestoreResponse = GetServerRestoreResponses[keyof GetServerRestoreResponses]; -export type CreatePlacementAliasData = { - body: { - key: string; - }; - path: { - projectId: string; - placementId: string; - }; +export type IdentifyBillingCustomerData = { + body: IdentifyBillingCustomerRequest; + path?: never; query?: never; - url: '/v1/projects/{projectId}/placements/{placementId}/aliases'; + url: '/v1/billing/identity/customers'; }; -export type CreatePlacementAliasErrors = { +export type IdentifyBillingCustomerErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type CreatePlacementAliasError = CreatePlacementAliasErrors[keyof CreatePlacementAliasErrors]; +export type IdentifyBillingCustomerError = IdentifyBillingCustomerErrors[keyof IdentifyBillingCustomerErrors]; -export type CreatePlacementAliasResponses = { +export type IdentifyBillingCustomerResponses = { /** - * Alias created. + * The existing Billing Customer. */ - 201: PlacementAliasEnvelope; + 200: { + data?: BillingIdentityCustomer; + }; + /** + * A Billing Customer was created. + */ + 201: { + data?: BillingIdentityCustomer; + }; }; -export type CreatePlacementAliasResponse = CreatePlacementAliasResponses[keyof CreatePlacementAliasResponses]; +export type IdentifyBillingCustomerResponse = IdentifyBillingCustomerResponses[keyof IdentifyBillingCustomerResponses]; -export type ArchivePlacementWithUsageCheckData = { +export type ListBillingCustomerAliasesData = { body?: never; path: { - projectId: string; - placementId: string; + customerId: string; }; query?: never; - url: '/v1/projects/{projectId}/placements/{placementId}/archive'; + url: '/v1/billing/identity/customers/{customerId}/aliases'; }; -export type ArchivePlacementWithUsageCheckErrors = { +export type ListBillingCustomerAliasesErrors = { /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; }; -export type ArchivePlacementWithUsageCheckError = ArchivePlacementWithUsageCheckErrors[keyof ArchivePlacementWithUsageCheckErrors]; +export type ListBillingCustomerAliasesError = ListBillingCustomerAliasesErrors[keyof ListBillingCustomerAliasesErrors]; -export type ArchivePlacementWithUsageCheckResponses = { +export type ListBillingCustomerAliasesResponses = { /** - * Placement archived. + * Aliases. */ - 204: void; + 200: { + data?: { + items?: Array; + }; + }; }; -export type ArchivePlacementWithUsageCheckResponse = ArchivePlacementWithUsageCheckResponses[keyof ArchivePlacementWithUsageCheckResponses]; +export type ListBillingCustomerAliasesResponse = ListBillingCustomerAliasesResponses[keyof ListBillingCustomerAliasesResponses]; -export type GetPlacementDecisionData = { - body?: never; +export type AttachBillingCustomerAliasData = { + body: AttachBillingCustomerAliasRequest; path: { - projectId: string; - environmentId: string; - placementId: string; + customerId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-set'; + url: '/v1/billing/identity/customers/{customerId}/aliases'; }; -export type GetPlacementDecisionErrors = { +export type AttachBillingCustomerAliasErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type GetPlacementDecisionError = GetPlacementDecisionErrors[keyof GetPlacementDecisionErrors]; +export type AttachBillingCustomerAliasError = AttachBillingCustomerAliasErrors[keyof AttachBillingCustomerAliasErrors]; -export type GetPlacementDecisionResponses = { +export type AttachBillingCustomerAliasResponses = { /** - * Combined Rule Set and current Draft detail. + * The attached alias. */ - 200: PlacementRuleSetDraftEnvelope; + 201: { + data?: BillingCustomerAlias; + }; }; -export type GetPlacementDecisionResponse = GetPlacementDecisionResponses[keyof GetPlacementDecisionResponses]; +export type AttachBillingCustomerAliasResponse = AttachBillingCustomerAliasResponses[keyof AttachBillingCustomerAliasResponses]; -export type CreatePlacementRuleSetData = { - body: PlacementDecisionDocumentRequest; - headers: { - 'Idempotency-Key': string; - }; +export type RevokeBillingCustomerAliasData = { + body?: never; path: { - projectId: string; - environmentId: string; - placementId: string; + aliasId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-set'; + url: '/v1/billing/identity/aliases/{aliasId}/revoke'; }; -export type CreatePlacementRuleSetErrors = { +export type RevokeBillingCustomerAliasErrors = { /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type CreatePlacementRuleSetError = CreatePlacementRuleSetErrors[keyof CreatePlacementRuleSetErrors]; +export type RevokeBillingCustomerAliasError = RevokeBillingCustomerAliasErrors[keyof RevokeBillingCustomerAliasErrors]; -export type CreatePlacementRuleSetResponses = { +export type RevokeBillingCustomerAliasResponses = { /** - * Rule Set and Draft created. + * The alias was revoked. */ - 201: PlacementRuleSetDraftEnvelope; + 204: void; }; -export type CreatePlacementRuleSetResponse = CreatePlacementRuleSetResponses[keyof CreatePlacementRuleSetResponses]; +export type RevokeBillingCustomerAliasResponse = RevokeBillingCustomerAliasResponses[keyof RevokeBillingCustomerAliasResponses]; -export type UpdatePlacementRuleSetDraftData = { - body: PlacementDecisionDocumentRequest; - headers: { - 'If-Match': string; - 'Idempotency-Key': string; - }; +export type RequestBillingCustomerSyncData = { + body?: never; path: { - projectId: string; - environmentId: string; - placementId: string; - ruleSetId: string; + customerId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/draft'; + url: '/v1/billing/identity/customers/{customerId}/sync-requests'; }; -export type UpdatePlacementRuleSetDraftErrors = { +export type RequestBillingCustomerSyncErrors = { /** * Stable machine-readable failure. */ - 412: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 404: ErrorEnvelope; /** * Stable machine-readable failure. */ - 428: ErrorEnvelope; + 409: ErrorEnvelope; }; -export type UpdatePlacementRuleSetDraftError = UpdatePlacementRuleSetDraftErrors[keyof UpdatePlacementRuleSetDraftErrors]; +export type RequestBillingCustomerSyncError = RequestBillingCustomerSyncErrors[keyof RequestBillingCustomerSyncErrors]; -export type UpdatePlacementRuleSetDraftResponses = { +export type RequestBillingCustomerSyncResponses = { /** - * New immutable Draft revision. + * A projection was queued. */ - 200: PlacementRuleSetDraftEnvelope; + 202: { + data?: BillingSyncRequest; + }; }; -export type UpdatePlacementRuleSetDraftResponse = UpdatePlacementRuleSetDraftResponses[keyof UpdatePlacementRuleSetDraftResponses]; +export type RequestBillingCustomerSyncResponse = RequestBillingCustomerSyncResponses[keyof RequestBillingCustomerSyncResponses]; -export type ValidatePlacementRuleSetData = { +export type ListBillingIdentityConflictsData = { body?: never; - path: { - projectId: string; - environmentId: string; - placementId: string; - ruleSetId: string; + path?: never; + query?: { + status?: 'open' | 'resolved'; }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/validate'; + url: '/v1/billing/identity/conflicts'; }; -export type ValidatePlacementRuleSetResponses = { +export type ListBillingIdentityConflictsErrors = { /** - * Semantic validation result. + * Stable machine-readable failure. */ - 200: PlacementValidationEnvelope; + 401: ErrorEnvelope; }; -export type ValidatePlacementRuleSetResponse = ValidatePlacementRuleSetResponses[keyof ValidatePlacementRuleSetResponses]; +export type ListBillingIdentityConflictsError = ListBillingIdentityConflictsErrors[keyof ListBillingIdentityConflictsErrors]; -export type PublishPlacementRuleSetData = { - body: { - expectedRevision: number; +export type ListBillingIdentityConflictsResponses = { + /** + * Identity conflicts. + */ + 200: { + data?: { + items?: Array; + }; }; +}; + +export type ListBillingIdentityConflictsResponse = ListBillingIdentityConflictsResponses[keyof ListBillingIdentityConflictsResponses]; + +export type GetBillingIdentityConflictData = { + body?: never; path: { - projectId: string; - environmentId: string; - placementId: string; - ruleSetId: string; + conflictId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/publish'; + url: '/v1/billing/identity/conflicts/{conflictId}'; }; -export type PublishPlacementRuleSetErrors = { +export type GetBillingIdentityConflictErrors = { /** * Stable machine-readable failure. */ - 412: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type PublishPlacementRuleSetError = PublishPlacementRuleSetErrors[keyof PublishPlacementRuleSetErrors]; +export type GetBillingIdentityConflictError = GetBillingIdentityConflictErrors[keyof GetBillingIdentityConflictErrors]; -export type PublishPlacementRuleSetResponses = { +export type GetBillingIdentityConflictResponses = { /** - * Immutable Rule Set Version. + * The conflict. */ - 201: PlacementRuleSetVersionEnvelope; + 200: { + data?: BillingIdentityConflictDetail; + }; }; -export type PublishPlacementRuleSetResponse = PublishPlacementRuleSetResponses[keyof PublishPlacementRuleSetResponses]; +export type GetBillingIdentityConflictResponse = GetBillingIdentityConflictResponses[keyof GetBillingIdentityConflictResponses]; -export type ArchivePlacementRuleSetData = { +export type GetBillingCustomerData = { body?: never; path: { - projectId: string; - environmentId: string; - placementId: string; - ruleSetId: string; + customerId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/archive'; + url: '/v1/billing/server/customers/{customerId}'; }; -export type ArchivePlacementRuleSetErrors = { +export type GetBillingCustomerErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; /** - * Rule Set is already archived or conflicts with current state. + * Stable machine-readable failure. */ 409: ErrorEnvelope; }; -export type ArchivePlacementRuleSetError = ArchivePlacementRuleSetErrors[keyof ArchivePlacementRuleSetErrors]; +export type GetBillingCustomerError = GetBillingCustomerErrors[keyof GetBillingCustomerErrors]; -export type ArchivePlacementRuleSetResponses = { +export type GetBillingCustomerResponses = { /** - * Rule Set archived; immutable version history is retained. + * The Billing Customer. */ - 204: void; + 200: { + data?: BillingCustomer; + }; }; -export type ArchivePlacementRuleSetResponse = ArchivePlacementRuleSetResponses[keyof ArchivePlacementRuleSetResponses]; +export type GetBillingCustomerResponse = GetBillingCustomerResponses[keyof GetBillingCustomerResponses]; -export type ListPlacementRuleSetVersionsData = { +export type GetCustomerEntitlementSnapshotData = { body?: never; path: { - projectId: string; - environmentId: string; - placementId: string; - ruleSetId: string; + customerId: string; }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/versions'; + query?: { + /** + * Defaults to the Environment the secret key belongs to. Any other value is 403. + */ + environmentId?: string; + }; + url: '/v1/billing/server/customers/{customerId}/entitlements'; }; -export type ListPlacementRuleSetVersionsResponses = { +export type GetCustomerEntitlementSnapshotErrors = { /** - * Immutable version history. + * Stable machine-readable failure. */ - 200: PlacementRuleSetVersionListEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; }; -export type ListPlacementRuleSetVersionsResponse = ListPlacementRuleSetVersionsResponses[keyof ListPlacementRuleSetVersionsResponses]; +export type GetCustomerEntitlementSnapshotError = GetCustomerEntitlementSnapshotErrors[keyof GetCustomerEntitlementSnapshotErrors]; -export type ClonePlacementRuleSetVersionData = { - body?: never; - headers: { - 'Idempotency-Key': string; - }; +export type GetCustomerEntitlementSnapshotResponses = { + /** + * The current snapshot. + */ + 200: CustomerEntitlementSnapshotRecord; +}; + +export type GetCustomerEntitlementSnapshotResponse = GetCustomerEntitlementSnapshotResponses[keyof GetCustomerEntitlementSnapshotResponses]; + +export type CheckCustomerEntitlementsData = { + body: EntitlementCheckRequestRecord; path: { - projectId: string; - environmentId: string; - placementId: string; - ruleSetId: string; - versionId: string; + customerId: string; }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/versions/{versionId}/draft'; + query?: { + environmentId?: string; + }; + url: '/v1/billing/server/customers/{customerId}/entitlement-checks'; }; -export type ClonePlacementRuleSetVersionErrors = { +export type CheckCustomerEntitlementsErrors = { /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 406: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type ClonePlacementRuleSetVersionError = ClonePlacementRuleSetVersionErrors[keyof ClonePlacementRuleSetVersionErrors]; +export type CheckCustomerEntitlementsError = CheckCustomerEntitlementsErrors[keyof CheckCustomerEntitlementsErrors]; -export type ClonePlacementRuleSetVersionResponses = { +export type CheckCustomerEntitlementsResponses = { /** - * Active Draft cloned from the immutable version. + * The check result. */ - 201: PlacementRuleSetDraftEnvelope; + 200: EntitlementCheckResultRecord; }; -export type ClonePlacementRuleSetVersionResponse = ClonePlacementRuleSetVersionResponses[keyof ClonePlacementRuleSetVersionResponses]; +export type CheckCustomerEntitlementsResponse = CheckCustomerEntitlementsResponses[keyof CheckCustomerEntitlementsResponses]; -export type SimulatePlacementDecisionData = { - body: PlacementSimulationRequestWritable; +export type ListCustomerSubscriptionsData = { + body?: never; path: { - projectId: string; - environmentId: string; - placementId: string; - ruleSetId: string; + customerId: string; }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/rule-sets/{ruleSetId}/simulate'; + query?: { + environmentId?: string; + limit?: number; + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + }; + url: '/v1/billing/server/customers/{customerId}/subscriptions'; }; -export type SimulatePlacementDecisionErrors = { +export type ListCustomerSubscriptionsErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; }; -export type SimulatePlacementDecisionError = SimulatePlacementDecisionErrors[keyof SimulatePlacementDecisionErrors]; +export type ListCustomerSubscriptionsError = ListCustomerSubscriptionsErrors[keyof ListCustomerSubscriptionsErrors]; -export type SimulatePlacementDecisionResponses = { +export type ListCustomerSubscriptionsResponses = { /** - * Ephemeral bounded decision trace; request inputs are neither logged nor persisted. + * Projected subscriptions. */ - 200: PlacementSimulationEnvelope; + 200: { + data?: { + items?: Array; + nextCursor?: string; + }; + }; }; -export type SimulatePlacementDecisionResponse = SimulatePlacementDecisionResponses[keyof SimulatePlacementDecisionResponses]; +export type ListCustomerSubscriptionsResponse = ListCustomerSubscriptionsResponses[keyof ListCustomerSubscriptionsResponses]; -export type ListPlacementQaOverridesData = { +export type GetSubscriptionSnapshotData = { body?: never; path: { - projectId: string; - environmentId: string; - placementId: string; + instanceId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/qa-overrides'; + url: '/v1/billing/server/subscriptions/{instanceId}'; }; -export type ListPlacementQaOverridesResponses = { +export type GetSubscriptionSnapshotErrors = { /** - * Active non-production QA overrides. + * Stable machine-readable failure. */ - 200: QaOverrideListEnvelope; -}; - -export type ListPlacementQaOverridesResponse = ListPlacementQaOverridesResponses[keyof ListPlacementQaOverridesResponses]; - -export type CreatePlacementQaOverrideData = { - body: CreateQaOverrideRequestWritable; - path: { - projectId: string; - environmentId: string; - placementId: string; - }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/qa-overrides'; -}; - -export type CreatePlacementQaOverrideErrors = { + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; }; -export type CreatePlacementQaOverrideError = CreatePlacementQaOverrideErrors[keyof CreatePlacementQaOverrideErrors]; +export type GetSubscriptionSnapshotError = GetSubscriptionSnapshotErrors[keyof GetSubscriptionSnapshotErrors]; -export type CreatePlacementQaOverrideResponses = { +export type GetSubscriptionSnapshotResponses = { /** - * Override created; opaque token is returned once. + * The subscription snapshot. */ - 201: QaOverrideCreatedEnvelope; + 200: SubscriptionSnapshotRecord; }; -export type CreatePlacementQaOverrideResponse = CreatePlacementQaOverrideResponses[keyof CreatePlacementQaOverrideResponses]; +export type GetSubscriptionSnapshotResponse = GetSubscriptionSnapshotResponses[keyof GetSubscriptionSnapshotResponses]; -export type RevokePlacementQaOverrideData = { +export type ListSubscriptionTimelineData = { body?: never; path: { - projectId: string; - environmentId: string; - placementId: string; - overrideId: string; + instanceId: string; }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/placements/{placementId}/qa-overrides/{overrideId}'; + query?: { + limit?: number; + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + }; + url: '/v1/billing/server/subscriptions/{instanceId}/timeline'; }; -export type RevokePlacementQaOverrideErrors = { +export type ListSubscriptionTimelineErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; }; -export type RevokePlacementQaOverrideError = RevokePlacementQaOverrideErrors[keyof RevokePlacementQaOverrideErrors]; +export type ListSubscriptionTimelineError = ListSubscriptionTimelineErrors[keyof ListSubscriptionTimelineErrors]; -export type RevokePlacementQaOverrideResponses = { +export type ListSubscriptionTimelineResponses = { /** - * Override revoked; the next immutable release omits it. + * Timeline entries. */ - 204: void; + 200: { + data?: { + items?: Array; + nextCursor?: string; + }; + }; }; -export type RevokePlacementQaOverrideResponse = RevokePlacementQaOverrideResponses[keyof RevokePlacementQaOverrideResponses]; +export type ListSubscriptionTimelineResponse = ListSubscriptionTimelineResponses[keyof ListSubscriptionTimelineResponses]; -export type ListExperimentsData = { +export type GetBillingSettingsData = { body?: never; path: { projectId: string; - environmentId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments'; + url: '/v1/projects/{projectId}/billing/settings'; }; -export type ListExperimentsResponses = { +export type GetBillingSettingsErrors = { /** - * Environment-scoped Experiments. + * Stable machine-readable failure. */ - 200: ExperimentListEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; }; -export type ListExperimentsResponse = ListExperimentsResponses[keyof ListExperimentsResponses]; +export type GetBillingSettingsError = GetBillingSettingsErrors[keyof GetBillingSettingsErrors]; -export type CreateExperimentData = { - body: CreateExperimentRequest; - headers: { - 'Idempotency-Key': string; +export type GetBillingSettingsResponses = { + /** + * Billing settings. + */ + 200: { + data?: BillingSettings; + }; +}; + +export type GetBillingSettingsResponse = GetBillingSettingsResponses[keyof GetBillingSettingsResponses]; + +export type UpdateBillingSettingsData = { + body: { + billingEnabled: boolean; }; path: { projectId: string; - environmentId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments'; + url: '/v1/projects/{projectId}/billing/settings'; }; -export type CreateExperimentErrors = { +export type UpdateBillingSettingsErrors = { /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; }; -export type CreateExperimentError = CreateExperimentErrors[keyof CreateExperimentErrors]; +export type UpdateBillingSettingsError = UpdateBillingSettingsErrors[keyof UpdateBillingSettingsErrors]; -export type CreateExperimentResponses = { +export type UpdateBillingSettingsResponses = { /** - * Experiment and first Draft revision. + * Updated settings. */ - 201: ExperimentEnvelope; + 200: { + data?: { + billingEnabled?: boolean; + }; + }; }; -export type CreateExperimentResponse = CreateExperimentResponses[keyof CreateExperimentResponses]; +export type UpdateBillingSettingsResponse = UpdateBillingSettingsResponses[keyof UpdateBillingSettingsResponses]; -export type ListExperimentMetricDefinitionsData = { +export type ListStoreServerCredentialsData = { body?: never; path: { projectId: string; - environmentId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/metrics'; + url: '/v1/projects/{projectId}/billing/store-credentials'; }; -export type ListExperimentMetricDefinitionsResponses = { +export type ListStoreServerCredentialsErrors = { /** - * Immutable Experiment metric definitions. + * Stable machine-readable failure. */ - 200: ExperimentMetricListEnvelope; + 403: ErrorEnvelope; }; -export type ListExperimentMetricDefinitionsResponse = ListExperimentMetricDefinitionsResponses[keyof ListExperimentMetricDefinitionsResponses]; - -export type ListExperimentGroupsData = { - body?: never; - path: { - projectId: string; - environmentId: string; - }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/groups'; -}; +export type ListStoreServerCredentialsError = ListStoreServerCredentialsErrors[keyof ListStoreServerCredentialsErrors]; -export type ListExperimentGroupsResponses = { +export type ListStoreServerCredentialsResponses = { /** - * Mutual-exclusion groups. + * Store Server Credentials. */ - 200: ExperimentGroupListEnvelope; + 200: { + data?: { + items?: Array; + }; + }; }; -export type ListExperimentGroupsResponse = ListExperimentGroupsResponses[keyof ListExperimentGroupsResponses]; +export type ListStoreServerCredentialsResponse = ListStoreServerCredentialsResponses[keyof ListStoreServerCredentialsResponses]; -export type CreateExperimentGroupVersionData = { - body: CreateExperimentGroupRequest; +export type CreateStoreServerCredentialData = { + body: CreateStoreServerCredentialRequest; path: { projectId: string; - environmentId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/groups'; + url: '/v1/projects/{projectId}/billing/store-credentials'; }; -export type CreateExperimentGroupVersionErrors = { +export type CreateStoreServerCredentialErrors = { /** * Stable machine-readable failure. */ - 404: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; /** * Stable machine-readable failure. */ 422: ErrorEnvelope; }; -export type CreateExperimentGroupVersionError = CreateExperimentGroupVersionErrors[keyof CreateExperimentGroupVersionErrors]; +export type CreateStoreServerCredentialError = CreateStoreServerCredentialErrors[keyof CreateStoreServerCredentialErrors]; -export type CreateExperimentGroupVersionResponses = { +export type CreateStoreServerCredentialResponses = { /** - * Group and immutable Version. + * Store Server Credential including the one-time notification endpoint URL. This is the only response that ever carries it. */ - 201: ExperimentGroupCreatedEnvelope; + 201: { + data?: StoreServerCredentialWithEndpoint; + }; }; -export type CreateExperimentGroupVersionResponse = CreateExperimentGroupVersionResponses[keyof CreateExperimentGroupVersionResponses]; +export type CreateStoreServerCredentialResponse = CreateStoreServerCredentialResponses[keyof CreateStoreServerCredentialResponses]; -export type ListExperimentMutualExclusionGroupVersionsData = { +export type GetStoreServerCredentialData = { body?: never; path: { projectId: string; - environmentId: string; - groupId: string; + credentialId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/groups/{groupId}/versions'; + url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}'; }; -export type ListExperimentMutualExclusionGroupVersionsErrors = { +export type GetStoreServerCredentialErrors = { + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; }; -export type ListExperimentMutualExclusionGroupVersionsError = ListExperimentMutualExclusionGroupVersionsErrors[keyof ListExperimentMutualExclusionGroupVersionsErrors]; +export type GetStoreServerCredentialError = GetStoreServerCredentialErrors[keyof GetStoreServerCredentialErrors]; -export type ListExperimentMutualExclusionGroupVersionsResponses = { +export type GetStoreServerCredentialResponses = { /** - * Immutable group Version history. + * Store Server Credential without secret material or endpoint URL. */ - 200: ExperimentGroupVersionListEnvelope; + 200: { + data?: StoreServerCredential; + }; }; -export type ListExperimentMutualExclusionGroupVersionsResponse = ListExperimentMutualExclusionGroupVersionsResponses[keyof ListExperimentMutualExclusionGroupVersionsResponses]; +export type GetStoreServerCredentialResponse = GetStoreServerCredentialResponses[keyof GetStoreServerCredentialResponses]; -export type CreateExperimentMutualExclusionGroupVersionData = { - body: CreateExperimentGroupVersionRequest; +export type RotateStoreServerCredentialData = { + body: { + /** + * Write-only. Never returned. + */ + secret: string; + }; path: { projectId: string; - environmentId: string; - groupId: string; + credentialId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/groups/{groupId}/versions'; + url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/rotate'; }; -export type CreateExperimentMutualExclusionGroupVersionErrors = { +export type RotateStoreServerCredentialErrors = { + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -6654,352 +9952,463 @@ export type CreateExperimentMutualExclusionGroupVersionErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 409: ErrorEnvelope; }; -export type CreateExperimentMutualExclusionGroupVersionError = CreateExperimentMutualExclusionGroupVersionErrors[keyof CreateExperimentMutualExclusionGroupVersionErrors]; +export type RotateStoreServerCredentialError = RotateStoreServerCredentialErrors[keyof RotateStoreServerCredentialErrors]; -export type CreateExperimentMutualExclusionGroupVersionResponses = { +export type RotateStoreServerCredentialResponses = { /** - * New immutable group Version. + * Store Server Credential including the one-time notification endpoint URL. This is the only response that ever carries it. */ - 201: ExperimentGroupVersionEnvelope; + 200: { + data?: StoreServerCredentialWithEndpoint; + }; }; -export type CreateExperimentMutualExclusionGroupVersionResponse = CreateExperimentMutualExclusionGroupVersionResponses[keyof CreateExperimentMutualExclusionGroupVersionResponses]; +export type RotateStoreServerCredentialResponse = RotateStoreServerCredentialResponses[keyof RotateStoreServerCredentialResponses]; -export type GetExperimentData = { +export type RevokeStoreServerCredentialData = { body?: never; path: { projectId: string; - environmentId: string; - experimentId: string; + credentialId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}'; + url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/revoke'; }; -export type GetExperimentErrors = { +export type RevokeStoreServerCredentialErrors = { + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; }; -export type GetExperimentError = GetExperimentErrors[keyof GetExperimentErrors]; +export type RevokeStoreServerCredentialError = RevokeStoreServerCredentialErrors[keyof RevokeStoreServerCredentialErrors]; -export type GetExperimentResponses = { +export type RevokeStoreServerCredentialResponses = { /** - * Experiment root + * Store Server Credential without secret material or endpoint URL. */ - 200: ExperimentEnvelope; + 200: { + data?: StoreServerCredential; + }; }; -export type GetExperimentResponse = GetExperimentResponses[keyof GetExperimentResponses]; +export type RevokeStoreServerCredentialResponse = RevokeStoreServerCredentialResponses[keyof RevokeStoreServerCredentialResponses]; -export type UpdateExperimentDraftData = { - body: UpdateExperimentDraftRequest; - headers: { - 'If-Match': string; - 'Idempotency-Key': string; - }; +export type TestStoreServerCredentialData = { + body?: never; path: { projectId: string; - environmentId: string; - experimentId: string; + credentialId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/draft'; + url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/test'; }; -export type UpdateExperimentDraftErrors = { +export type TestStoreServerCredentialErrors = { /** - * Stale Draft revision with currentRevision and ETag recovery details. + * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 404: ErrorEnvelope; /** * Stable machine-readable failure. */ - 428: ErrorEnvelope; + 429: ErrorEnvelope; }; -export type UpdateExperimentDraftError = UpdateExperimentDraftErrors[keyof UpdateExperimentDraftErrors]; +export type TestStoreServerCredentialError = TestStoreServerCredentialErrors[keyof TestStoreServerCredentialErrors]; -export type UpdateExperimentDraftResponses = { +export type TestStoreServerCredentialResponses = { /** - * New immutable Draft revision. + * Store Server Credential without secret material or endpoint URL. */ - 200: ExperimentDraftEnvelope; + 200: { + data?: StoreServerCredential; + }; }; -export type UpdateExperimentDraftResponse = UpdateExperimentDraftResponses[keyof UpdateExperimentDraftResponses]; +export type TestStoreServerCredentialResponse = TestStoreServerCredentialResponses[keyof TestStoreServerCredentialResponses]; -export type ValidateExperimentDraftData = { +export type ListTransactionFactsData = { body?: never; path: { projectId: string; environmentId: string; - experimentId: string; }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/validate'; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Forward it unchanged; do not + * construct or parse one — it encodes both the ordering timestamp and the row id, because + * billing identifiers are not time-ordered and an id alone cannot express a position in a + * timestamp ordering. + * + * A malformed or stale value starts from the first page rather than erroring, so a mangled + * cursor cannot silently truncate a list. Omit it for the first page; a response with no + * `nextCursor` is the last page. + * + */ + cursor?: string; + limit?: number; + provider?: 'app_store' | 'google_play'; + from?: string; + to?: string; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/facts'; }; -export type ValidateExperimentDraftResponses = { +export type ListTransactionFactsErrors = { /** - * Scientific + * Stable machine-readable failure. */ - 200: ExperimentValidationEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; }; -export type ValidateExperimentDraftResponse = ValidateExperimentDraftResponses[keyof ValidateExperimentDraftResponses]; +export type ListTransactionFactsError = ListTransactionFactsErrors[keyof ListTransactionFactsErrors]; -export type PublishExperimentData = { - body: PublishExperimentRequest; +export type ListTransactionFactsResponses = { + /** + * Transaction Facts. + */ + 200: { + data?: { + items?: Array; + nextCursor?: string; + }; + }; +}; + +export type ListTransactionFactsResponse = ListTransactionFactsResponses[keyof ListTransactionFactsResponses]; + +export type ListValidationAttemptsData = { + body?: never; path: { projectId: string; environmentId: string; - experimentId: string; }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/publish'; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Forward it unchanged; do not + * construct or parse one — it encodes both the ordering timestamp and the row id, because + * billing identifiers are not time-ordered and an id alone cannot express a position in a + * timestamp ordering. + * + * A malformed or stale value starts from the first page rather than erroring, so a mangled + * cursor cannot silently truncate a list. Omit it for the first page; a response with no + * `nextCursor` is the last page. + * + */ + cursor?: string; + limit?: number; + status?: 'validated' | 'recorded_no_fact' | 'quarantined' | 'retryable_failure' | 'permanently_failed'; + /** + * Narrow the list to one input's attempt history. + */ + rawInputId?: string; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/validation-attempts'; }; -export type PublishExperimentErrors = { +export type ListValidationAttemptsErrors = { /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type PublishExperimentError = PublishExperimentErrors[keyof PublishExperimentErrors]; +export type ListValidationAttemptsError = ListValidationAttemptsErrors[keyof ListValidationAttemptsErrors]; -export type PublishExperimentResponses = { +export type ListValidationAttemptsResponses = { /** - * Immutable Experiment Version and atomic Configuration Delivery v3 release. + * Validation Attempts. */ - 201: ExperimentVersionEnvelope; + 200: { + data?: { + items?: Array; + nextCursor?: string; + }; + }; }; -export type PublishExperimentResponse = PublishExperimentResponses[keyof PublishExperimentResponses]; +export type ListValidationAttemptsResponse = ListValidationAttemptsResponses[keyof ListValidationAttemptsResponses]; -export type ListExperimentVersionsData = { +export type ListBillingLedgerData = { body?: never; path: { projectId: string; environmentId: string; - experimentId: string; }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/versions'; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Forward it unchanged; do not + * construct or parse one — it encodes both the ordering timestamp and the row id, because + * billing identifiers are not time-ordered and an id alone cannot express a position in a + * timestamp ordering. + * + * A malformed or stale value starts from the first page rather than erroring, so a mangled + * cursor cannot silently truncate a list. Omit it for the first page; a response with no + * `nextCursor` is the last page. + * + */ + cursor?: string; + limit?: number; + from?: string; + to?: string; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/ledger'; }; -export type ListExperimentVersionsResponses = { +export type ListBillingLedgerErrors = { /** - * Immutable Experiment Version history. + * Stable machine-readable failure. */ - 200: ExperimentVersionListEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; }; -export type ListExperimentVersionsResponse = ListExperimentVersionsResponses[keyof ListExperimentVersionsResponses]; - -export type ListExperimentHistoryData = { - body?: never; - path: { - projectId: string; - environmentId: string; - experimentId: string; - }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/history'; -}; +export type ListBillingLedgerError = ListBillingLedgerErrors[keyof ListBillingLedgerErrors]; -export type ListExperimentHistoryResponses = { +export type ListBillingLedgerResponses = { /** - * Audited lifecycle and publication history. + * Billing Ledger Entries. */ - 200: ExperimentHistoryListEnvelope; + 200: { + data?: { + items?: Array; + nextCursor?: string; + }; + }; }; -export type ListExperimentHistoryResponse = ListExperimentHistoryResponses[keyof ListExperimentHistoryResponses]; +export type ListBillingLedgerResponse = ListBillingLedgerResponses[keyof ListBillingLedgerResponses]; -export type GetExperimentResultsData = { +export type ListBillingQuarantineData = { body?: never; path: { projectId: string; environmentId: string; - experimentId: string; }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/results'; + query?: { + /** + * Opaque cursor from the immediately preceding list response. Forward it unchanged; do not + * construct or parse one — it encodes both the ordering timestamp and the row id, because + * billing identifiers are not time-ordered and an id alone cannot express a position in a + * timestamp ordering. + * + * A malformed or stale value starts from the first page rather than erroring, so a mangled + * cursor cannot silently truncate a list. Omit it for the first page; a response with no + * `nextCursor` is the last page. + * + */ + cursor?: string; + limit?: number; + status?: 'open' | 'retrying' | 'closed_after_success' | 'closed_superseded'; + reasonCode?: string; + provider?: 'app_store' | 'google_play'; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/quarantine'; }; -export type GetExperimentResultsResponses = { +export type ListBillingQuarantineErrors = { /** - * Unique-unit conversion - */ - 200: ExperimentResultsEnvelope; -}; - -export type GetExperimentResultsResponse = GetExperimentResultsResponses[keyof GetExperimentResultsResponses]; - -export type GetExperimentSampleRatioMismatchData = { - body?: never; - path: { - projectId: string; - environmentId: string; - experimentId: string; - }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/srm'; + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; }; -export type GetExperimentSampleRatioMismatchResponses = { +export type ListBillingQuarantineError = ListBillingQuarantineErrors[keyof ListBillingQuarantineErrors]; + +export type ListBillingQuarantineResponses = { /** - * Descriptive Pearson chi-square SRM diagnostic. + * Quarantine Records. */ 200: { - data: ExperimentSrm; + data?: { + items?: Array; + nextCursor?: string; + }; }; }; -export type GetExperimentSampleRatioMismatchResponse = GetExperimentSampleRatioMismatchResponses[keyof GetExperimentSampleRatioMismatchResponses]; +export type ListBillingQuarantineResponse = ListBillingQuarantineResponses[keyof ListBillingQuarantineResponses]; -export type TransitionExperimentLifecycleData = { - body?: { - reason?: string; - }; +export type GetBillingHealthData = { + body?: never; path: { projectId: string; environmentId: string; - experimentId: string; - lifecycleAction: 'schedule' | 'start' | 'pause' | 'resume' | 'stop' | 'complete' | 'archive' | 'emergency-stop'; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/{lifecycleAction}'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/health'; }; -export type TransitionExperimentLifecycleErrors = { +export type GetBillingHealthErrors = { /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type TransitionExperimentLifecycleError = TransitionExperimentLifecycleErrors[keyof TransitionExperimentLifecycleErrors]; +export type GetBillingHealthError = GetBillingHealthErrors[keyof GetBillingHealthErrors]; -export type TransitionExperimentLifecycleResponses = { +export type GetBillingHealthResponses = { /** - * Updated Experiment root. + * Billing health summary. */ - 200: ExperimentEnvelope; + 200: { + data?: BillingHealth; + }; }; -export type TransitionExperimentLifecycleResponse = TransitionExperimentLifecycleResponses[keyof TransitionExperimentLifecycleResponses]; +export type GetBillingHealthResponse = GetBillingHealthResponses[keyof GetBillingHealthResponses]; -export type ListExperimentQaOverridesData = { +export type GetBillingProjectionHealthData = { body?: never; path: { projectId: string; environmentId: string; - experimentId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/qa-overrides'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/projection-health'; }; -export type ListExperimentQaOverridesResponses = { +export type GetBillingProjectionHealthErrors = { /** - * Safe QA override metadata without tokens. + * Stable machine-readable failure. */ - 200: ExperimentQaOverrideListEnvelope; -}; - -export type ListExperimentQaOverridesResponse = ListExperimentQaOverridesResponses[keyof ListExperimentQaOverridesResponses]; - -export type CreateExperimentQaOverrideData = { - body: CreateExperimentQaOverrideRequest; - path: { - projectId: string; - environmentId: string; - experimentId: string; - }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/qa-overrides'; -}; - -export type CreateExperimentQaOverrideErrors = { + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type CreateExperimentQaOverrideError = CreateExperimentQaOverrideErrors[keyof CreateExperimentQaOverrideErrors]; +export type GetBillingProjectionHealthError = GetBillingProjectionHealthErrors[keyof GetBillingProjectionHealthErrors]; -export type CreateExperimentQaOverrideResponses = { +export type GetBillingProjectionHealthResponses = { /** - * Non-production override; raw token returned once and never delivered as creator identity. + * Projection health summary. */ - 201: ExperimentQaOverrideCreatedEnvelope; + 200: { + data?: BillingProjectionHealth; + }; }; -export type CreateExperimentQaOverrideResponse = CreateExperimentQaOverrideResponses[keyof CreateExperimentQaOverrideResponses]; +export type GetBillingProjectionHealthResponse = GetBillingProjectionHealthResponses[keyof GetBillingProjectionHealthResponses]; -export type RevokeExperimentQaOverrideData = { - body?: never; +export type CreateBillingProjectionReplayData = { + body: CreateProjectionReplayRequest; path: { projectId: string; environmentId: string; - experimentId: string; - overrideId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/qa-overrides/{overrideId}'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/projection-replays'; }; -export type RevokeExperimentQaOverrideErrors = { +export type CreateBillingProjectionReplayErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type RevokeExperimentQaOverrideError = RevokeExperimentQaOverrideErrors[keyof RevokeExperimentQaOverrideErrors]; +export type CreateBillingProjectionReplayError = CreateBillingProjectionReplayErrors[keyof CreateBillingProjectionReplayErrors]; -export type RevokeExperimentQaOverrideResponses = { +export type CreateBillingProjectionReplayResponses = { /** - * Override revoked. + * The replay ran. */ - 204: void; + 200: { + data?: ProjectionReplayResult; + }; }; -export type RevokeExperimentQaOverrideResponse = RevokeExperimentQaOverrideResponses[keyof RevokeExperimentQaOverrideResponses]; +export type CreateBillingProjectionReplayResponse = CreateBillingProjectionReplayResponses[keyof CreateBillingProjectionReplayResponses]; -export type CreateExperimentRawExportData = { - body: CreateExperimentExportRequest; +export type ListBillingCustomersData = { + body?: never; path: { projectId: string; environmentId: string; - experimentId: string; }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/experiments/{experimentId}/exports'; + query?: { + status?: 'active' | 'frozen' | 'anonymized' | 'absorbed'; + /** + * Restrict to identified or to purchase-anchored-only customers. + */ + identified?: boolean; + /** + * Only customers party to an open identity conflict. + */ + conflictedOnly?: boolean; + limit?: number; + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; + }; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customers'; }; -export type CreateExperimentRawExportErrors = { +export type ListBillingCustomersErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7011,32 +10420,52 @@ export type CreateExperimentRawExportErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type CreateExperimentRawExportError = CreateExperimentRawExportErrors[keyof CreateExperimentRawExportErrors]; +export type ListBillingCustomersError = ListBillingCustomersErrors[keyof ListBillingCustomersErrors]; -export type CreateExperimentRawExportResponses = { +export type ListBillingCustomersResponses = { /** - * Asynchronous analytics job. + * Billing Customers. */ - 202: AnalyticsJobEnvelope; + 200: { + data?: { + items?: Array; + nextCursor?: string; + }; + }; }; -export type CreateExperimentRawExportResponse = CreateExperimentRawExportResponses[keyof CreateExperimentRawExportResponses]; +export type ListBillingCustomersResponse = ListBillingCustomersResponses[keyof ListBillingCustomersResponses]; -export type IngestAnalyticsEventBatchData = { - body: AnalyticsEventBatch; - path?: never; +export type LookupBillingCustomerData = { + body: BillingCustomerLookupRequest; + path: { + projectId: string; + environmentId: string; + }; query?: never; - url: '/v1/sdk/events/batch'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customer-lookups'; }; -export type IngestAnalyticsEventBatchErrors = { +export type LookupBillingCustomerErrors = { /** * Stable machine-readable failure. */ 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7051,28 +10480,35 @@ export type IngestAnalyticsEventBatchErrors = { 429: ErrorEnvelope; }; -export type IngestAnalyticsEventBatchError = IngestAnalyticsEventBatchErrors[keyof IngestAnalyticsEventBatchErrors]; +export type LookupBillingCustomerError = LookupBillingCustomerErrors[keyof LookupBillingCustomerErrors]; -export type IngestAnalyticsEventBatchResponses = { +export type LookupBillingCustomerResponses = { /** - * Per-event ingestion outcomes. + * The lookup result. */ - 200: AnalyticsIngestionResult; + 200: { + data?: BillingCustomerLookupResult; + }; }; -export type IngestAnalyticsEventBatchResponse = IngestAnalyticsEventBatchResponses[keyof IngestAnalyticsEventBatchResponses]; +export type LookupBillingCustomerResponse = LookupBillingCustomerResponses[keyof LookupBillingCustomerResponses]; -export type GetAnalyticsSettingsData = { +export type GetOperatorBillingCustomerData = { body?: never; path: { projectId: string; environmentId: string; + customerId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/settings'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}'; }; -export type GetAnalyticsSettingsErrors = { +export type GetOperatorBillingCustomerErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7081,30 +10517,45 @@ export type GetAnalyticsSettingsErrors = { * Stable machine-readable failure. */ 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type GetAnalyticsSettingsError = GetAnalyticsSettingsErrors[keyof GetAnalyticsSettingsErrors]; +export type GetOperatorBillingCustomerError = GetOperatorBillingCustomerErrors[keyof GetOperatorBillingCustomerErrors]; -export type GetAnalyticsSettingsResponses = { +export type GetOperatorBillingCustomerResponses = { /** - * Analytics settings. + * The Billing Customer. */ - 200: AnalyticsSettingsEnvelope; + 200: { + data?: BillingCustomerDetail; + }; }; -export type GetAnalyticsSettingsResponse = GetAnalyticsSettingsResponses[keyof GetAnalyticsSettingsResponses]; +export type GetOperatorBillingCustomerResponse = GetOperatorBillingCustomerResponses[keyof GetOperatorBillingCustomerResponses]; -export type UpdateAnalyticsSettingsData = { - body: UpdateAnalyticsSettingsRequest; +export type GetBillingCustomerEntitlementSnapshotData = { + body?: never; path: { projectId: string; environmentId: string; + customerId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/settings'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}/entitlements'; }; -export type UpdateAnalyticsSettingsErrors = { +export type GetBillingCustomerEntitlementSnapshotErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7112,80 +10563,109 @@ export type UpdateAnalyticsSettingsErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type UpdateAnalyticsSettingsError = UpdateAnalyticsSettingsErrors[keyof UpdateAnalyticsSettingsErrors]; +export type GetBillingCustomerEntitlementSnapshotError = GetBillingCustomerEntitlementSnapshotErrors[keyof GetBillingCustomerEntitlementSnapshotErrors]; -export type UpdateAnalyticsSettingsResponses = { +export type GetBillingCustomerEntitlementSnapshotResponses = { /** - * Updated analytics settings. + * The current snapshot. */ - 200: AnalyticsSettingsEnvelope; + 200: { + data?: { + snapshot?: BillingEntitlementSnapshot; + projectionStatus?: BillingProjectionStatus; + }; + }; }; -export type UpdateAnalyticsSettingsResponse = UpdateAnalyticsSettingsResponses[keyof UpdateAnalyticsSettingsResponses]; +export type GetBillingCustomerEntitlementSnapshotResponse = GetBillingCustomerEntitlementSnapshotResponses[keyof GetBillingCustomerEntitlementSnapshotResponses]; -export type GetAnalyticsOverviewData = { +export type ListBillingCustomerSubscriptionsData = { body?: never; path: { projectId: string; environmentId: string; + customerId: string; }; - query: { - from: Timestamp; - to: Timestamp; - timezone: string; - metricBasis: 'event_count'; - platform?: 'ios' | 'android'; - locale?: string; - applicationVersion?: string; + query?: { + limit?: number; + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; }; - url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/overview'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}/subscriptions'; }; -export type GetAnalyticsOverviewErrors = { +export type ListBillingCustomerSubscriptionsErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 409: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 503: ErrorEnvelope; }; -export type GetAnalyticsOverviewError = GetAnalyticsOverviewErrors[keyof GetAnalyticsOverviewErrors]; +export type ListBillingCustomerSubscriptionsError = ListBillingCustomerSubscriptionsErrors[keyof ListBillingCustomerSubscriptionsErrors]; -export type GetAnalyticsOverviewResponses = { +export type ListBillingCustomerSubscriptionsResponses = { /** - * Event-count analytics result. + * Subscriptions. */ - 200: AnalyticsResultEnvelope; + 200: { + data?: { + items?: Array; + nextCursor?: string; + }; + }; }; -export type GetAnalyticsOverviewResponse = GetAnalyticsOverviewResponses[keyof GetAnalyticsOverviewResponses]; +export type ListBillingCustomerSubscriptionsResponse = ListBillingCustomerSubscriptionsResponses[keyof ListBillingCustomerSubscriptionsResponses]; -export type GetAnalyticsFunnelData = { +export type CreateBillingCustomerSyncRequestData = { body?: never; path: { projectId: string; environmentId: string; - funnel: 'placements' | 'paywalls' | 'products' | 'purchases'; - }; - query: { - from: Timestamp; - to: Timestamp; - timezone: string; - metricBasis: 'event_count'; - platform?: 'ios' | 'android'; - locale?: string; - applicationVersion?: string; + customerId: string; }; - url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/funnels/{funnel}'; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}/sync-requests'; }; -export type GetAnalyticsFunnelErrors = { +export type CreateBillingCustomerSyncRequestErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7193,148 +10673,209 @@ export type GetAnalyticsFunnelErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 429: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type GetAnalyticsFunnelError = GetAnalyticsFunnelErrors[keyof GetAnalyticsFunnelErrors]; +export type CreateBillingCustomerSyncRequestError = CreateBillingCustomerSyncRequestErrors[keyof CreateBillingCustomerSyncRequestErrors]; -export type GetAnalyticsFunnelResponses = { +export type CreateBillingCustomerSyncRequestResponses = { /** - * Event-count analytics result. + * The projection was queued. */ - 200: AnalyticsResultEnvelope; + 202: { + data?: OperatorBillingSyncRequest; + }; }; -export type GetAnalyticsFunnelResponse = GetAnalyticsFunnelResponses[keyof GetAnalyticsFunnelResponses]; +export type CreateBillingCustomerSyncRequestResponse = CreateBillingCustomerSyncRequestResponses[keyof CreateBillingCustomerSyncRequestResponses]; -export type CompareAnalyticsPaywallVersionsData = { +export type GetBillingSubscriptionData = { body?: never; path: { projectId: string; environmentId: string; + instanceId: string; }; - query: { - from: Timestamp; - to: Timestamp; - timezone: string; - metricBasis: 'event_count'; - platform?: 'ios' | 'android'; - locale?: string; - applicationVersion?: string; - }; - url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/paywall-version-comparison'; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/subscriptions/{instanceId}'; }; -export type CompareAnalyticsPaywallVersionsErrors = { +export type GetBillingSubscriptionErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type CompareAnalyticsPaywallVersionsError = CompareAnalyticsPaywallVersionsErrors[keyof CompareAnalyticsPaywallVersionsErrors]; +export type GetBillingSubscriptionError = GetBillingSubscriptionErrors[keyof GetBillingSubscriptionErrors]; -export type CompareAnalyticsPaywallVersionsResponses = { +export type GetBillingSubscriptionResponses = { /** - * Event-count analytics result. + * The Subscription Instance. */ - 200: AnalyticsResultEnvelope; + 200: { + data?: BillingSubscriptionSnapshot; + }; }; -export type CompareAnalyticsPaywallVersionsResponse = CompareAnalyticsPaywallVersionsResponses[keyof CompareAnalyticsPaywallVersionsResponses]; +export type GetBillingSubscriptionResponse = GetBillingSubscriptionResponses[keyof GetBillingSubscriptionResponses]; -export type GetAnalyticsProviderErrorsData = { +export type ListBillingSubscriptionTimelineData = { body?: never; path: { projectId: string; environmentId: string; + instanceId: string; }; - query: { - from: Timestamp; - to: Timestamp; - timezone: string; - metricBasis: 'event_count'; - platform?: 'ios' | 'android'; - locale?: string; - applicationVersion?: string; + query?: { + limit?: number; + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; }; - url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/provider-errors'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/subscriptions/{instanceId}/timeline'; }; -export type GetAnalyticsProviderErrorsErrors = { +export type ListBillingSubscriptionTimelineErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type GetAnalyticsProviderErrorsError = GetAnalyticsProviderErrorsErrors[keyof GetAnalyticsProviderErrorsErrors]; +export type ListBillingSubscriptionTimelineError = ListBillingSubscriptionTimelineErrors[keyof ListBillingSubscriptionTimelineErrors]; -export type GetAnalyticsProviderErrorsResponses = { +export type ListBillingSubscriptionTimelineResponses = { /** - * Event-count analytics result. + * Timeline entries. */ - 200: AnalyticsResultEnvelope; + 200: { + data?: { + items?: Array; + nextCursor?: string; + }; + }; }; -export type GetAnalyticsProviderErrorsResponse = GetAnalyticsProviderErrorsResponses[keyof GetAnalyticsProviderErrorsResponses]; +export type ListBillingSubscriptionTimelineResponse = ListBillingSubscriptionTimelineResponses[keyof ListBillingSubscriptionTimelineResponses]; -export type GetAnalyticsProductAvailabilityFailuresData = { +export type ListBillingRestoreJobsData = { body?: never; path: { projectId: string; environmentId: string; }; - query: { - from: Timestamp; - to: Timestamp; - timezone: string; - metricBasis: 'event_count'; - platform?: 'ios' | 'android'; - locale?: string; - applicationVersion?: string; + query?: { + billingCustomerId?: string; + limit?: number; + /** + * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + */ + cursor?: string; }; - url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/product-availability-failures'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/restore-jobs'; }; -export type GetAnalyticsProductAvailabilityFailuresErrors = { +export type ListBillingRestoreJobsErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type GetAnalyticsProductAvailabilityFailuresError = GetAnalyticsProductAvailabilityFailuresErrors[keyof GetAnalyticsProductAvailabilityFailuresErrors]; +export type ListBillingRestoreJobsError = ListBillingRestoreJobsErrors[keyof ListBillingRestoreJobsErrors]; -export type GetAnalyticsProductAvailabilityFailuresResponses = { +export type ListBillingRestoreJobsResponses = { /** - * Event-count analytics result. + * Restore jobs. */ - 200: AnalyticsResultEnvelope; + 200: { + data?: { + items?: Array; + nextCursor?: string; + }; + }; }; -export type GetAnalyticsProductAvailabilityFailuresResponse = GetAnalyticsProductAvailabilityFailuresResponses[keyof GetAnalyticsProductAvailabilityFailuresResponses]; +export type ListBillingRestoreJobsResponse = ListBillingRestoreJobsResponses[keyof ListBillingRestoreJobsResponses]; -export type GetAnalyticsBreakdownData = { +export type GetBillingRestoreJobData = { body?: never; path: { projectId: string; environmentId: string; - dimension: 'platforms' | 'locales'; - }; - query: { - from: Timestamp; - to: Timestamp; - timezone: string; - metricBasis: 'event_count'; - platform?: 'ios' | 'android'; - locale?: string; - applicationVersion?: string; + restoreId: string; }; - url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/breakdowns/{dimension}'; + query?: never; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/restore-jobs/{restoreId}'; }; -export type GetAnalyticsBreakdownErrors = { +export type GetBillingRestoreJobErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7342,95 +10883,94 @@ export type GetAnalyticsBreakdownErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type GetAnalyticsBreakdownError = GetAnalyticsBreakdownErrors[keyof GetAnalyticsBreakdownErrors]; +export type GetBillingRestoreJobError = GetBillingRestoreJobErrors[keyof GetBillingRestoreJobErrors]; -export type GetAnalyticsBreakdownResponses = { +export type GetBillingRestoreJobResponses = { /** - * Event-count analytics result. + * The restore job. */ - 200: AnalyticsResultEnvelope; + 200: { + data?: BillingRestoreJob; + }; }; -export type GetAnalyticsBreakdownResponse = GetAnalyticsBreakdownResponses[keyof GetAnalyticsBreakdownResponses]; +export type GetBillingRestoreJobResponse = GetBillingRestoreJobResponses[keyof GetBillingRestoreJobResponses]; -export type GetAnalyticsFreshnessData = { +export type ListOperatorBillingIdentityConflictsData = { body?: never; path: { projectId: string; - environmentId: string; }; - query: { - from: Timestamp; - to: Timestamp; - timezone: string; - metricBasis: 'event_count'; + query?: { + status?: 'open' | 'resolved'; }; - url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/freshness'; + url: '/v1/projects/{projectId}/billing/identity-conflicts'; }; -export type GetAnalyticsFreshnessErrors = { +export type ListOperatorBillingIdentityConflictsErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; -}; - -export type GetAnalyticsFreshnessError = GetAnalyticsFreshnessErrors[keyof GetAnalyticsFreshnessErrors]; - -export type GetAnalyticsFreshnessResponses = { + 401: ErrorEnvelope; /** - * Event-count analytics result. + * Stable machine-readable failure. */ - 200: AnalyticsResultEnvelope; -}; - -export type GetAnalyticsFreshnessResponse = GetAnalyticsFreshnessResponses[keyof GetAnalyticsFreshnessResponses]; - -export type CreateAnalyticsEventExportData = { - body: CreateAnalyticsEventExportRequest; - path: { - projectId: string; - environmentId: string; - }; - query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/analytics/exports'; -}; - -export type CreateAnalyticsEventExportErrors = { + 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 409: ErrorEnvelope; /** * Stable machine-readable failure. */ 422: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type CreateAnalyticsEventExportError = CreateAnalyticsEventExportErrors[keyof CreateAnalyticsEventExportErrors]; +export type ListOperatorBillingIdentityConflictsError = ListOperatorBillingIdentityConflictsErrors[keyof ListOperatorBillingIdentityConflictsErrors]; -export type CreateAnalyticsEventExportResponses = { +export type ListOperatorBillingIdentityConflictsResponses = { /** - * Asynchronous analytics job. + * Identity conflicts. */ - 202: AnalyticsJobEnvelope; + 200: { + data?: { + items?: Array; + }; + }; }; -export type CreateAnalyticsEventExportResponse = CreateAnalyticsEventExportResponses[keyof CreateAnalyticsEventExportResponses]; +export type ListOperatorBillingIdentityConflictsResponse = ListOperatorBillingIdentityConflictsResponses[keyof ListOperatorBillingIdentityConflictsResponses]; -export type PreviewAnalyticsPrivacyRequestData = { - body: AnalyticsIdentityRequest; +export type GetOperatorBillingIdentityConflictData = { + body?: never; path: { projectId: string; + conflictId: string; }; query?: never; - url: '/v1/projects/{projectId}/analytics/privacy/preview'; + url: '/v1/projects/{projectId}/billing/identity-conflicts/{conflictId}'; }; -export type PreviewAnalyticsPrivacyRequestErrors = { +export type GetOperatorBillingIdentityConflictErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7439,64 +10979,116 @@ export type PreviewAnalyticsPrivacyRequestErrors = { * Stable machine-readable failure. */ 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type PreviewAnalyticsPrivacyRequestError = PreviewAnalyticsPrivacyRequestErrors[keyof PreviewAnalyticsPrivacyRequestErrors]; +export type GetOperatorBillingIdentityConflictError = GetOperatorBillingIdentityConflictErrors[keyof GetOperatorBillingIdentityConflictErrors]; -export type PreviewAnalyticsPrivacyRequestResponses = { +export type GetOperatorBillingIdentityConflictResponses = { /** - * Privacy impact preview. + * The conflict. */ - 200: AnalyticsPrivacyPreviewEnvelope; + 200: { + data?: OperatorBillingIdentityConflictDetail; + }; }; -export type PreviewAnalyticsPrivacyRequestResponse = PreviewAnalyticsPrivacyRequestResponses[keyof PreviewAnalyticsPrivacyRequestResponses]; +export type GetOperatorBillingIdentityConflictResponse = GetOperatorBillingIdentityConflictResponses[keyof GetOperatorBillingIdentityConflictResponses]; -export type CreateAnalyticsPrivacyExportData = { - body: CreateAnalyticsPrivacyExportRequest; +export type ResolveBillingIdentityConflictData = { + body: ResolveIdentityConflictRequest; path: { projectId: string; + conflictId: string; }; query?: never; - url: '/v1/projects/{projectId}/analytics/privacy/exports'; + url: '/v1/projects/{projectId}/billing/identity-conflicts/{conflictId}/resolution'; }; -export type CreateAnalyticsPrivacyExportErrors = { +export type ResolveBillingIdentityConflictErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; /** * Stable machine-readable failure. */ 422: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 429: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type CreateAnalyticsPrivacyExportError = CreateAnalyticsPrivacyExportErrors[keyof CreateAnalyticsPrivacyExportErrors]; +export type ResolveBillingIdentityConflictError = ResolveBillingIdentityConflictErrors[keyof ResolveBillingIdentityConflictErrors]; -export type CreateAnalyticsPrivacyExportResponses = { +export type ResolveBillingIdentityConflictResponses = { /** - * Asynchronous analytics job. + * The resolved conflict. */ - 202: AnalyticsJobEnvelope; + 200: { + data?: OperatorBillingIdentityConflict; + }; }; -export type CreateAnalyticsPrivacyExportResponse = CreateAnalyticsPrivacyExportResponses[keyof CreateAnalyticsPrivacyExportResponses]; +export type ResolveBillingIdentityConflictResponse = ResolveBillingIdentityConflictResponses[keyof ResolveBillingIdentityConflictResponses]; -export type CreateAnalyticsPrivacyDeletionData = { - body: CreateAnalyticsPrivacyDeletionRequest; +export type ListProductEntitlementGrantVersionsData = { + body?: never; path: { projectId: string; }; - query?: never; - url: '/v1/projects/{projectId}/analytics/privacy/deletions'; + query: { + productId: string; + /** + * Restricts the history to one (Product + */ + entitlementId?: string; + /** + * Return only the open-ended version in force now. + */ + currentOnly?: boolean; + limit?: number; + }; + url: '/v1/projects/{projectId}/billing/grant-versions'; }; -export type CreateAnalyticsPrivacyDeletionErrors = { +export type ListProductEntitlementGrantVersionsErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 403: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7504,31 +11096,38 @@ export type CreateAnalyticsPrivacyDeletionErrors = { /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 503: ErrorEnvelope; }; -export type CreateAnalyticsPrivacyDeletionError = CreateAnalyticsPrivacyDeletionErrors[keyof CreateAnalyticsPrivacyDeletionErrors]; +export type ListProductEntitlementGrantVersionsError = ListProductEntitlementGrantVersionsErrors[keyof ListProductEntitlementGrantVersionsErrors]; -export type CreateAnalyticsPrivacyDeletionResponses = { +export type ListProductEntitlementGrantVersionsResponses = { /** - * Asynchronous analytics job. + * Grant versions. */ - 202: AnalyticsJobEnvelope; + 200: { + data?: { + items?: Array; + }; + }; }; -export type CreateAnalyticsPrivacyDeletionResponse = CreateAnalyticsPrivacyDeletionResponses[keyof CreateAnalyticsPrivacyDeletionResponses]; +export type ListProductEntitlementGrantVersionsResponse = ListProductEntitlementGrantVersionsResponses[keyof ListProductEntitlementGrantVersionsResponses]; -export type GetAnalyticsJobData = { - body?: never; +export type PublishProductEntitlementGrantVersionData = { + body: PublishGrantVersionRequest; path: { projectId: string; - jobId: string; }; query?: never; - url: '/v1/projects/{projectId}/analytics/jobs/{jobId}'; + url: '/v1/projects/{projectId}/billing/grant-versions'; }; -export type GetAnalyticsJobErrors = { +export type PublishProductEntitlementGrantVersionErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7537,30 +11136,47 @@ export type GetAnalyticsJobErrors = { * Stable machine-readable failure. */ 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 503: ErrorEnvelope; }; -export type GetAnalyticsJobError = GetAnalyticsJobErrors[keyof GetAnalyticsJobErrors]; +export type PublishProductEntitlementGrantVersionError = PublishProductEntitlementGrantVersionErrors[keyof PublishProductEntitlementGrantVersionErrors]; -export type GetAnalyticsJobResponses = { +export type PublishProductEntitlementGrantVersionResponses = { /** - * Asynchronous analytics job. + * The published grant version. */ - 200: AnalyticsJobEnvelope; + 201: { + data?: ProductEntitlementGrantVersion; + }; }; -export type GetAnalyticsJobResponse = GetAnalyticsJobResponses[keyof GetAnalyticsJobResponses]; +export type PublishProductEntitlementGrantVersionResponse = PublishProductEntitlementGrantVersionResponses[keyof PublishProductEntitlementGrantVersionResponses]; -export type DownloadAnalyticsJobData = { - body?: never; +export type PreviewProductEntitlementGrantImpactData = { + body: PublishGrantVersionRequest; path: { projectId: string; - jobId: string; }; query?: never; - url: '/v1/projects/{projectId}/analytics/jobs/{jobId}/download'; + url: '/v1/projects/{projectId}/billing/grant-versions/impact-preview'; }; -export type DownloadAnalyticsJobErrors = { +export type PreviewProductEntitlementGrantImpactErrors = { + /** + * Stable machine-readable failure. + */ + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7569,307 +11185,281 @@ export type DownloadAnalyticsJobErrors = { * Stable machine-readable failure. */ 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type DownloadAnalyticsJobError = DownloadAnalyticsJobErrors[keyof DownloadAnalyticsJobErrors]; +export type PreviewProductEntitlementGrantImpactError = PreviewProductEntitlementGrantImpactErrors[keyof PreviewProductEntitlementGrantImpactErrors]; -export type DownloadAnalyticsJobResponses = { +export type PreviewProductEntitlementGrantImpactResponses = { /** - * Private export artifact. + * The impact preview. */ - 200: Blob | File; + 200: { + data?: GrantVersionImpact; + }; }; -export type DownloadAnalyticsJobResponse = DownloadAnalyticsJobResponses[keyof DownloadAnalyticsJobResponses]; +export type PreviewProductEntitlementGrantImpactResponse = PreviewProductEntitlementGrantImpactResponses[keyof PreviewProductEntitlementGrantImpactResponses]; -export type ReceiveAppleStoreNotificationData = { - body: { - /** - * Apple JWS notification payload. Never logged or echoed. - */ - signedPayload: string; - }; +export type GetProductEntitlementGrantVersionData = { + body?: never; path: { - /** - * One-time intake token issued with the credential. - */ - intakeToken: string; + projectId: string; + grantVersionId: string; }; query?: never; - url: '/v1/billing/apple/notifications/{intakeToken}'; + url: '/v1/projects/{projectId}/billing/grant-versions/{grantVersionId}'; }; -export type ReceiveAppleStoreNotificationErrors = { +export type GetProductEntitlementGrantVersionErrors = { /** * Stable machine-readable failure. */ - 404: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ - 503: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type ReceiveAppleStoreNotificationError = ReceiveAppleStoreNotificationErrors[keyof ReceiveAppleStoreNotificationErrors]; +export type GetProductEntitlementGrantVersionError = GetProductEntitlementGrantVersionErrors[keyof GetProductEntitlementGrantVersionErrors]; -export type ReceiveAppleStoreNotificationResponses = { +export type GetProductEntitlementGrantVersionResponses = { /** - * The notification was durably recorded and queued for validation. + * The grant version. */ - 202: { - data?: { - status?: 'accepted'; - }; + 200: { + data?: ProductEntitlementGrantVersion; }; }; -export type ReceiveAppleStoreNotificationResponse = ReceiveAppleStoreNotificationResponses[keyof ReceiveAppleStoreNotificationResponses]; +export type GetProductEntitlementGrantVersionResponse = GetProductEntitlementGrantVersionResponses[keyof GetProductEntitlementGrantVersionResponses]; -export type SubmitTransactionObservationData = { - body: ClientTransactionObservationRecord; - path?: never; +export type UpdateProductEntitlementGrantVersionData = { + body?: never; + path: { + projectId: string; + grantVersionId: string; + }; query?: never; - url: '/v1/sdk/billing/observations'; + url: '/v1/projects/{projectId}/billing/grant-versions/{grantVersionId}'; }; -export type SubmitTransactionObservationErrors = { +export type UpdateProductEntitlementGrantVersionErrors = { /** * Stable machine-readable failure. */ - 401: ErrorEnvelope; - /** - * Resubmitting the identical document cannot succeed. The SDK queue should drop it. - */ - 422: ObservationSubmissionResultRecord; - /** - * Transient. Resubmit the identical document later; Retry-After carries the hint. - */ - 429: ObservationSubmissionResultRecord; - /** - * Transient. Resubmit the identical document later; Retry-After carries the hint. - */ - 503: ObservationSubmissionResultRecord; -}; - -export type SubmitTransactionObservationError = SubmitTransactionObservationErrors[keyof SubmitTransactionObservationErrors]; - -export type SubmitTransactionObservationResponses = { - /** - * The submission was already recorded. A duplicate is idempotent, not an error. - */ - 200: ObservationSubmissionResultRecord; - /** - * The observation is well formed and queued for validation. Nothing more: the store has not been consulted when this response is written. - */ - 202: ObservationSubmissionResultRecord; + 409: ErrorEnvelope; }; -export type SubmitTransactionObservationResponse = SubmitTransactionObservationResponses[keyof SubmitTransactionObservationResponses]; +export type UpdateProductEntitlementGrantVersionError = UpdateProductEntitlementGrantVersionErrors[keyof UpdateProductEntitlementGrantVersionErrors]; -export type SubmitServerTransactionObservationData = { - body: ServerTransactionObservationRecord; - path?: never; +export type ListWebhookDestinationsData = { + body?: never; + path: { + projectId: string; + environmentId: string; + }; query?: never; - url: '/v1/billing/server/observations'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/webhook-destinations'; }; -export type SubmitServerTransactionObservationErrors = { +export type ListWebhookDestinationsErrors = { /** * Stable machine-readable failure. */ 401: ErrorEnvelope; /** - * Resubmitting the identical document cannot succeed. The SDK queue should drop it. - */ - 422: ObservationSubmissionResultRecord; - /** - * Transient. Resubmit the identical document later; Retry-After carries the hint. - */ - 429: ObservationSubmissionResultRecord; - /** - * Transient. Resubmit the identical document later; Retry-After carries the hint. - */ - 503: ObservationSubmissionResultRecord; -}; - -export type SubmitServerTransactionObservationError = SubmitServerTransactionObservationErrors[keyof SubmitServerTransactionObservationErrors]; - -export type SubmitServerTransactionObservationResponses = { - /** - * The submission was already recorded. A duplicate is idempotent, not an error. + * Stable machine-readable failure. */ - 200: ObservationSubmissionResultRecord; + 403: ErrorEnvelope; /** - * The observation is well formed and queued for validation. Nothing more: the store has not been consulted when this response is written. + * Stable machine-readable failure. */ - 202: ObservationSubmissionResultRecord; + 404: ErrorEnvelope; }; -export type SubmitServerTransactionObservationResponse = SubmitServerTransactionObservationResponses[keyof SubmitServerTransactionObservationResponses]; +export type ListWebhookDestinationsError = ListWebhookDestinationsErrors[keyof ListWebhookDestinationsErrors]; -export type UpdateBillingSettingsData = { - body: { - billingEnabled: boolean; +export type ListWebhookDestinationsResponses = { + /** + * Destinations. + */ + 200: { + data?: Array; }; +}; + +export type ListWebhookDestinationsResponse = ListWebhookDestinationsResponses[keyof ListWebhookDestinationsResponses]; + +export type CreateWebhookDestinationData = { + body: CreateWebhookDestinationRequest; path: { projectId: string; + environmentId: string; }; query?: never; - url: '/v1/projects/{projectId}/billing/settings'; + url: '/v1/projects/{projectId}/environments/{environmentId}/billing/webhook-destinations'; }; -export type UpdateBillingSettingsErrors = { +export type CreateWebhookDestinationErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ - 404: ErrorEnvelope; + 403: ErrorEnvelope; /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 422: ErrorEnvelope; }; -export type UpdateBillingSettingsError = UpdateBillingSettingsErrors[keyof UpdateBillingSettingsErrors]; +export type CreateWebhookDestinationError = CreateWebhookDestinationErrors[keyof CreateWebhookDestinationErrors]; -export type UpdateBillingSettingsResponses = { +export type CreateWebhookDestinationResponses = { /** - * Updated settings. + * The destination and its one-time signing secret. */ - 200: { - data?: { - billingEnabled?: boolean; - }; + 201: { + data?: WebhookDestinationWithSecret; }; }; -export type UpdateBillingSettingsResponse = UpdateBillingSettingsResponses[keyof UpdateBillingSettingsResponses]; +export type CreateWebhookDestinationResponse = CreateWebhookDestinationResponses[keyof CreateWebhookDestinationResponses]; -export type ListStoreServerCredentialsData = { +export type DeleteWebhookDestinationData = { body?: never; path: { projectId: string; + destinationId: string; }; query?: never; - url: '/v1/projects/{projectId}/billing/store-credentials'; + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}'; }; -export type ListStoreServerCredentialsErrors = { +export type DeleteWebhookDestinationErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; }; -export type ListStoreServerCredentialsError = ListStoreServerCredentialsErrors[keyof ListStoreServerCredentialsErrors]; +export type DeleteWebhookDestinationError = DeleteWebhookDestinationErrors[keyof DeleteWebhookDestinationErrors]; -export type ListStoreServerCredentialsResponses = { +export type DeleteWebhookDestinationResponses = { /** - * Store Server Credentials. + * The destination was removed. */ - 200: { - data?: { - items?: Array; - }; - }; + 204: void; }; -export type ListStoreServerCredentialsResponse = ListStoreServerCredentialsResponses[keyof ListStoreServerCredentialsResponses]; +export type DeleteWebhookDestinationResponse = DeleteWebhookDestinationResponses[keyof DeleteWebhookDestinationResponses]; -export type CreateStoreServerCredentialData = { - body: CreateStoreServerCredentialRequest; +export type GetWebhookDestinationData = { + body?: never; path: { projectId: string; + destinationId: string; }; query?: never; - url: '/v1/projects/{projectId}/billing/store-credentials'; + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}'; }; -export type CreateStoreServerCredentialErrors = { - /** - * Stable machine-readable failure. - */ - 403: ErrorEnvelope; +export type GetWebhookDestinationErrors = { /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ - 422: ErrorEnvelope; + 404: ErrorEnvelope; }; -export type CreateStoreServerCredentialError = CreateStoreServerCredentialErrors[keyof CreateStoreServerCredentialErrors]; +export type GetWebhookDestinationError = GetWebhookDestinationErrors[keyof GetWebhookDestinationErrors]; -export type CreateStoreServerCredentialResponses = { +export type GetWebhookDestinationResponses = { /** - * Store Server Credential including the one-time notification endpoint URL. This is the only response that ever carries it. + * The destination. */ - 201: { - data?: StoreServerCredentialWithEndpoint; + 200: { + data?: WebhookDestination; }; }; -export type CreateStoreServerCredentialResponse = CreateStoreServerCredentialResponses[keyof CreateStoreServerCredentialResponses]; +export type GetWebhookDestinationResponse = GetWebhookDestinationResponses[keyof GetWebhookDestinationResponses]; -export type GetStoreServerCredentialData = { - body?: never; +export type UpdateWebhookDestinationData = { + body: UpdateWebhookDestinationRequest; path: { projectId: string; - credentialId: string; + destinationId: string; }; query?: never; - url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}'; + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}'; }; -export type GetStoreServerCredentialErrors = { +export type UpdateWebhookDestinationErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 422: ErrorEnvelope; }; -export type GetStoreServerCredentialError = GetStoreServerCredentialErrors[keyof GetStoreServerCredentialErrors]; +export type UpdateWebhookDestinationError = UpdateWebhookDestinationErrors[keyof UpdateWebhookDestinationErrors]; -export type GetStoreServerCredentialResponses = { +export type UpdateWebhookDestinationResponses = { /** - * Store Server Credential without secret material or endpoint URL. + * The updated destination. */ 200: { - data?: StoreServerCredential; + data?: WebhookDestination; }; }; -export type GetStoreServerCredentialResponse = GetStoreServerCredentialResponses[keyof GetStoreServerCredentialResponses]; +export type UpdateWebhookDestinationResponse = UpdateWebhookDestinationResponses[keyof UpdateWebhookDestinationResponses]; -export type RotateStoreServerCredentialData = { - body: { - /** - * Write-only. Never returned. - */ - secret: string; - }; +export type SetWebhookDestinationStatusData = { + body: SetWebhookDestinationStatusRequest; path: { projectId: string; - credentialId: string; + destinationId: string; }; query?: never; - url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/rotate'; + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/status'; }; -export type RotateStoreServerCredentialErrors = { +export type SetWebhookDestinationStatusErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ @@ -7877,312 +11467,273 @@ export type RotateStoreServerCredentialErrors = { /** * Stable machine-readable failure. */ - 409: ErrorEnvelope; + 422: ErrorEnvelope; }; -export type RotateStoreServerCredentialError = RotateStoreServerCredentialErrors[keyof RotateStoreServerCredentialErrors]; +export type SetWebhookDestinationStatusError = SetWebhookDestinationStatusErrors[keyof SetWebhookDestinationStatusErrors]; -export type RotateStoreServerCredentialResponses = { +export type SetWebhookDestinationStatusResponses = { /** - * Store Server Credential including the one-time notification endpoint URL. This is the only response that ever carries it. + * The updated destination. */ 200: { - data?: StoreServerCredentialWithEndpoint; + data?: WebhookDestination; }; }; -export type RotateStoreServerCredentialResponse = RotateStoreServerCredentialResponses[keyof RotateStoreServerCredentialResponses]; +export type SetWebhookDestinationStatusResponse = SetWebhookDestinationStatusResponses[keyof SetWebhookDestinationStatusResponses]; -export type RevokeStoreServerCredentialData = { +export type ListWebhookSigningSecretsData = { body?: never; path: { projectId: string; - credentialId: string; + destinationId: string; }; query?: never; - url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/revoke'; + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/secrets'; }; -export type RevokeStoreServerCredentialErrors = { +export type ListWebhookSigningSecretsErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; }; -export type RevokeStoreServerCredentialError = RevokeStoreServerCredentialErrors[keyof RevokeStoreServerCredentialErrors]; +export type ListWebhookSigningSecretsError = ListWebhookSigningSecretsErrors[keyof ListWebhookSigningSecretsErrors]; -export type RevokeStoreServerCredentialResponses = { +export type ListWebhookSigningSecretsResponses = { /** - * Store Server Credential without secret material or endpoint URL. + * Secret metadata. */ 200: { - data?: StoreServerCredential; + data?: Array; }; }; -export type RevokeStoreServerCredentialResponse = RevokeStoreServerCredentialResponses[keyof RevokeStoreServerCredentialResponses]; +export type ListWebhookSigningSecretsResponse = ListWebhookSigningSecretsResponses[keyof ListWebhookSigningSecretsResponses]; -export type TestStoreServerCredentialData = { +export type RotateWebhookSigningSecretData = { body?: never; path: { projectId: string; - credentialId: string; + destinationId: string; }; query?: never; - url: '/v1/projects/{projectId}/billing/store-credentials/{credentialId}/test'; + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/secrets/rotate'; }; -export type TestStoreServerCredentialErrors = { +export type RotateWebhookSigningSecretErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; - /** - * Stable machine-readable failure. - */ - 429: ErrorEnvelope; }; -export type TestStoreServerCredentialError = TestStoreServerCredentialErrors[keyof TestStoreServerCredentialErrors]; +export type RotateWebhookSigningSecretError = RotateWebhookSigningSecretErrors[keyof RotateWebhookSigningSecretErrors]; -export type TestStoreServerCredentialResponses = { +export type RotateWebhookSigningSecretResponses = { /** - * Store Server Credential without secret material or endpoint URL. + * The new secret and the overlap deadline. */ - 200: { - data?: StoreServerCredential; + 201: { + data?: WebhookDestinationWithSecret; }; }; -export type TestStoreServerCredentialResponse = TestStoreServerCredentialResponses[keyof TestStoreServerCredentialResponses]; +export type RotateWebhookSigningSecretResponse = RotateWebhookSigningSecretResponses[keyof RotateWebhookSigningSecretResponses]; -export type ListTransactionFactsData = { +export type RetireWebhookSigningSecretData = { body?: never; path: { projectId: string; - environmentId: string; - }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; - provider?: 'app_store' | 'google_play'; - from?: string; - to?: string; + destinationId: string; + secretId: string; }; - url: '/v1/projects/{projectId}/environments/{environmentId}/billing/facts'; + query?: never; + url: '/v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/secrets/{secretId}/retire'; }; -export type ListTransactionFactsErrors = { +export type RetireWebhookSigningSecretErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; }; -export type ListTransactionFactsError = ListTransactionFactsErrors[keyof ListTransactionFactsErrors]; +export type RetireWebhookSigningSecretError = RetireWebhookSigningSecretErrors[keyof RetireWebhookSigningSecretErrors]; -export type ListTransactionFactsResponses = { +export type RetireWebhookSigningSecretResponses = { /** - * Transaction Facts. + * The retired secret's metadata. */ 200: { - data?: { - items?: Array; - nextCursor?: string; - }; + data?: WebhookSigningSecretMetadata; }; }; -export type ListTransactionFactsResponse = ListTransactionFactsResponses[keyof ListTransactionFactsResponses]; +export type RetireWebhookSigningSecretResponse = RetireWebhookSigningSecretResponses[keyof RetireWebhookSigningSecretResponses]; -export type ListValidationAttemptsData = { +export type ListWebhookDeliveriesData = { body?: never; path: { projectId: string; - environmentId: string; }; query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; + environmentId?: string; + eventId?: string; + destinationId?: string; + status?: 'pending' | 'succeeded' | 'failed' | 'exhausted' | 'skipped'; limit?: number; - status?: 'validated' | 'recorded_no_fact' | 'quarantined' | 'retryable_failure' | 'permanently_failed'; - /** - * Narrow the list to one input's attempt history. - */ - rawInputId?: string; }; - url: '/v1/projects/{projectId}/environments/{environmentId}/billing/validation-attempts'; + url: '/v1/projects/{projectId}/billing/webhook-deliveries'; }; -export type ListValidationAttemptsErrors = { +export type ListWebhookDeliveriesErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; }; -export type ListValidationAttemptsError = ListValidationAttemptsErrors[keyof ListValidationAttemptsErrors]; +export type ListWebhookDeliveriesError = ListWebhookDeliveriesErrors[keyof ListWebhookDeliveriesErrors]; -export type ListValidationAttemptsResponses = { +export type ListWebhookDeliveriesResponses = { /** - * Validation Attempts. + * Deliveries. */ 200: { - data?: { - items?: Array; - nextCursor?: string; - }; + data?: Array; }; }; -export type ListValidationAttemptsResponse = ListValidationAttemptsResponses[keyof ListValidationAttemptsResponses]; +export type ListWebhookDeliveriesResponse = ListWebhookDeliveriesResponses[keyof ListWebhookDeliveriesResponses]; -export type ListBillingLedgerData = { +export type GetWebhookDeliveryData = { body?: never; path: { projectId: string; - environmentId: string; - }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; - from?: string; - to?: string; + deliveryId: string; }; - url: '/v1/projects/{projectId}/environments/{environmentId}/billing/ledger'; + query?: never; + url: '/v1/projects/{projectId}/billing/webhook-deliveries/{deliveryId}'; }; -export type ListBillingLedgerErrors = { +export type GetWebhookDeliveryErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; }; -export type ListBillingLedgerError = ListBillingLedgerErrors[keyof ListBillingLedgerErrors]; +export type GetWebhookDeliveryError = GetWebhookDeliveryErrors[keyof GetWebhookDeliveryErrors]; -export type ListBillingLedgerResponses = { +export type GetWebhookDeliveryResponses = { /** - * Billing Ledger Entries. + * The delivery. */ 200: { - data?: { - items?: Array; - nextCursor?: string; - }; + data?: WebhookDelivery; }; }; -export type ListBillingLedgerResponse = ListBillingLedgerResponses[keyof ListBillingLedgerResponses]; +export type GetWebhookDeliveryResponse = GetWebhookDeliveryResponses[keyof GetWebhookDeliveryResponses]; -export type ListBillingQuarantineData = { +export type ListWebhookDeliveryAttemptsData = { body?: never; path: { projectId: string; - environmentId: string; - }; - query?: { - /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. - */ - cursor?: string; - limit?: number; - status?: 'open' | 'retrying' | 'closed_after_success' | 'closed_superseded'; - reasonCode?: string; - provider?: 'app_store' | 'google_play'; + deliveryId: string; }; - url: '/v1/projects/{projectId}/environments/{environmentId}/billing/quarantine'; + query?: never; + url: '/v1/projects/{projectId}/billing/webhook-deliveries/{deliveryId}/attempts'; }; -export type ListBillingQuarantineErrors = { +export type ListWebhookDeliveryAttemptsErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; }; -export type ListBillingQuarantineError = ListBillingQuarantineErrors[keyof ListBillingQuarantineErrors]; +export type ListWebhookDeliveryAttemptsError = ListWebhookDeliveryAttemptsErrors[keyof ListWebhookDeliveryAttemptsErrors]; -export type ListBillingQuarantineResponses = { +export type ListWebhookDeliveryAttemptsResponses = { /** - * Quarantine Records. + * Attempts. */ 200: { - data?: { - items?: Array; - nextCursor?: string; - }; + data?: Array; }; }; -export type ListBillingQuarantineResponse = ListBillingQuarantineResponses[keyof ListBillingQuarantineResponses]; +export type ListWebhookDeliveryAttemptsResponse = ListWebhookDeliveryAttemptsResponses[keyof ListWebhookDeliveryAttemptsResponses]; -export type GetBillingHealthData = { +export type ReplayWebhookDeliveryData = { body?: never; path: { projectId: string; - environmentId: string; + deliveryId: string; }; query?: never; - url: '/v1/projects/{projectId}/environments/{environmentId}/billing/health'; + url: '/v1/projects/{projectId}/billing/webhook-deliveries/{deliveryId}/replay'; }; -export type GetBillingHealthErrors = { +export type ReplayWebhookDeliveryErrors = { /** * Stable machine-readable failure. */ - 403: ErrorEnvelope; + 401: ErrorEnvelope; /** * Stable machine-readable failure. */ 404: ErrorEnvelope; + /** + * Stable machine-readable failure. + */ + 409: ErrorEnvelope; }; -export type GetBillingHealthError = GetBillingHealthErrors[keyof GetBillingHealthErrors]; +export type ReplayWebhookDeliveryError = ReplayWebhookDeliveryErrors[keyof ReplayWebhookDeliveryErrors]; -export type GetBillingHealthResponses = { +export type ReplayWebhookDeliveryResponses = { /** - * Billing health summary. + * The delivery was queued. */ - 200: { - data?: BillingHealth; + 202: { + data?: WebhookDelivery; }; }; -export type GetBillingHealthResponse = GetBillingHealthResponses[keyof GetBillingHealthResponses]; +export type ReplayWebhookDeliveryResponse = ReplayWebhookDeliveryResponses[keyof ReplayWebhookDeliveryResponses]; export type ListReconciliationRunsData = { body?: never; @@ -8192,7 +11743,15 @@ export type ListReconciliationRunsData = { }; query?: { /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + * Opaque cursor from the immediately preceding list response. Forward it unchanged; do not + * construct or parse one — it encodes both the ordering timestamp and the row id, because + * billing identifiers are not time-ordered and an id alone cannot express a position in a + * timestamp ordering. + * + * A malformed or stale value starts from the first page rather than erroring, so a mangled + * cursor cannot silently truncate a list. Omit it for the first page; a response with no + * `nextCursor` is the last page. + * */ cursor?: string; limit?: number; @@ -8274,7 +11833,15 @@ export type ListReplayJobsData = { }; query?: { /** - * Opaque cursor from the immediately preceding list response. Malformed or stale values return validation_failed. + * Opaque cursor from the immediately preceding list response. Forward it unchanged; do not + * construct or parse one — it encodes both the ordering timestamp and the row id, because + * billing identifiers are not time-ordered and an id alone cannot express a position in a + * timestamp ordering. + * + * A malformed or stale value starts from the first page rather than erroring, so a mangled + * cursor cannot silently truncate a list. Omit it for the first page; a response with no + * `nextCursor` is the last page. + * */ cursor?: string; limit?: number; diff --git a/apps/dashboard/src/lib/routing/workspace-hrefs.ts b/apps/dashboard/src/lib/routing/workspace-hrefs.ts index 94c7bbe2..8d39ca06 100644 --- a/apps/dashboard/src/lib/routing/workspace-hrefs.ts +++ b/apps/dashboard/src/lib/routing/workspace-hrefs.ts @@ -58,6 +58,11 @@ export function catalogProductsHref(scope: WorkspaceScope) { return base ? `${base}/catalog/products` : undefined } +export function catalogProductHref(scope: WorkspaceScope, productId: string) { + const base = catalogProductsHref(scope) + return base ? `${base}/${encodeURIComponent(productId)}` : undefined +} + export function providersHref(scope: WorkspaceScope) { const base = projectBase(scope) return base ? `${base}/catalog/providers` : undefined @@ -112,6 +117,74 @@ export function billingHealthHref(scope: WorkspaceScope) { return base ? `${base}/health` : undefined } +/** + * Authoritative customer access. + * + * Environment-scoped like every other billing destination, even though a + * Billing Customer's *identity* is Project-scoped: everything Mosaic computes + * about their access — snapshots, subscriptions, entitlements — belongs to one + * Environment, and the customer header states the Project scope explicitly so + * the two are not confused. + */ +export function billingCustomersHref(scope: WorkspaceScope) { + const base = billingEnvironmentBase(scope) + return base ? `${base}/customers` : undefined +} + +export function billingCustomerHref(scope: WorkspaceScope, customerId: string) { + const base = billingCustomersHref(scope) + return base ? `${base}/${encodeURIComponent(customerId)}` : undefined +} + +export function billingSubscriptionHref(scope: WorkspaceScope, instanceId: string) { + const base = billingEnvironmentBase(scope) + return base ? `${base}/subscriptions/${encodeURIComponent(instanceId)}` : undefined +} + +export function billingRestoresHref(scope: WorkspaceScope) { + const base = billingEnvironmentBase(scope) + return base ? `${base}/restores` : undefined +} + +/** + * Identity conflicts are Project-scoped data reached through an + * Environment-scoped route, for consistency with the rest of the Billing nav. + * The page itself says so rather than pretending to be filtered. + */ +export function billingIdentityConflictsHref(scope: WorkspaceScope) { + const base = billingEnvironmentBase(scope) + return base ? `${base}/identity-conflicts` : undefined +} + +export function billingIdentityConflictHref(scope: WorkspaceScope, conflictId: string) { + const base = billingIdentityConflictsHref(scope) + return base ? `${base}/${encodeURIComponent(conflictId)}` : undefined +} + +/** + * Projection health is a sibling of billing health, not a tab inside it. The + * two answer different questions — "is store input still becoming facts?" and + * "is the access answer still current?" — and either can be red while the other + * is green. + */ +export function billingProjectionHealthHref(scope: WorkspaceScope) { + const base = billingEnvironmentBase(scope) + return base ? `${base}/projection-health` : undefined +} + +/** + * Grant versions are Project-scoped, like the Products and Entitlements they + * relate, so they live in Catalog rather than under an Environment. + */ +export function grantVersionsHref( + scope: WorkspaceScope, + filters: { entitlementId?: string; productId?: string } = {}, +) { + const base = projectBase(scope) + if (!base) return undefined + return appendSearch(`${base}/catalog/grant-versions`, filters) +} + /** * Names the destination a `returnTo` points back to. * @@ -125,7 +198,11 @@ export function describeReturnDestination(href: string | undefined) { if (href.includes("/billing/") && href.includes("/quarantine/")) { return "Return to the quarantine record" } + if (href.includes("/billing/") && href.includes("/projection-health")) { + return "Return to projection health" + } if (href.includes("/billing/")) return "Return to Mosaic Billing" + if (href.includes("/catalog/grant-versions")) return "Return to grant versions" if (href.includes("/studio-hosted/")) return "Return to Publish review" return "Return to where you started" } diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 4c9b4a97..c35c2725 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -27,6 +27,7 @@ import { Route as Studio_layoutStudioHostedOrganizationIdProjectIdEnvironmentIdP import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdSettingsEnvironmentsRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/settings/environments' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdSettingsApiKeysRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/settings/api-keys' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProvidersRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/catalog/providers' +import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/catalog/grant-versions' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProductsIndexRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/catalog/products/index' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansIndexRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/catalog/plans/index' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsIndexRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/catalog/entitlements/index' @@ -41,18 +42,25 @@ import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProdu import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansPlanIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/catalog/plans/$planId' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsEntitlementIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/catalog/entitlements/$entitlementId' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingConnectionsCredentialIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/connections/$credentialId' +import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores' +import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdHealthRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/health' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdAnalyticsEnvironmentIdSurfaceRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/analytics/$environmentId/$surface' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsIndexRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/index' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationIndexRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/index' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineIndexRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/index' +import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/index' +import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/index' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdPlacementsPlacementIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/placements/$placementId' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdPaywallsPaywallIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/paywalls/$paywallId' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdExperimentsNewRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/new' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdExperimentsExperimentIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/$experimentId' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsFactIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/$factId' +import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationRunIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/$runId' import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineRecordIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/$recordId' +import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId' +import { Route as HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRouteImport } from './routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId' const SignupRoute = SignupRouteImport.update({ id: '/signup', @@ -159,6 +167,14 @@ const HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProvidersRoute = getParentRoute: () => HostedRoute, } as any, ) +const HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRoute = + HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRouteImport.update( + { + id: '/organizations/$organizationId/projects/$projectId/catalog/grant-versions', + path: '/organizations/$organizationId/projects/$projectId/catalog/grant-versions', + getParentRoute: () => HostedRoute, + } as any, + ) const HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProductsIndexRoute = HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProductsIndexRouteImport.update( { @@ -272,6 +288,22 @@ const HostedOrganizationsOrganizationIdProjectsProjectIdBillingConnectionsCreden getParentRoute: () => HostedRoute, } as any, ) +const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRoute = + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRouteImport.update( + { + id: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores', + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores', + getParentRoute: () => HostedRoute, + } as any, + ) +const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRoute = + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRouteImport.update( + { + id: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health', + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health', + getParentRoute: () => HostedRoute, + } as any, + ) const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdHealthRoute = HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdHealthRouteImport.update( { @@ -312,6 +344,22 @@ const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuar getParentRoute: () => HostedRoute, } as any, ) +const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRoute = + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRouteImport.update( + { + id: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/', + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/', + getParentRoute: () => HostedRoute, + } as any, + ) +const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRoute = + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRouteImport.update( + { + id: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/', + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/', + getParentRoute: () => HostedRoute, + } as any, + ) const HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdPlacementsPlacementIdRoute = HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdPlacementsPlacementIdRouteImport.update( { @@ -356,6 +404,14 @@ const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTran getParentRoute: () => HostedRoute, } as any, ) +const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRoute = + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRouteImport.update( + { + id: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId', + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId', + getParentRoute: () => HostedRoute, + } as any, + ) const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationRunIdRoute = HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationRunIdRouteImport.update( { @@ -372,6 +428,22 @@ const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuar getParentRoute: () => HostedRoute, } as any, ) +const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRoute = + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRouteImport.update( + { + id: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId', + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId', + getParentRoute: () => HostedRoute, + } as any, + ) +const HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRoute = + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRouteImport.update( + { + id: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId', + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId', + getParentRoute: () => HostedRoute, + } as any, + ) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -386,12 +458,15 @@ export interface FileRoutesByFullPath { '/organizations/$organizationId/projects/new': typeof HostedOrganizationsOrganizationIdProjectsNewRoute '/organizations/$organizationId/projects/$projectId/apps': typeof HostedOrganizationsOrganizationIdProjectsProjectIdAppsRoute '/organizations/$organizationId/projects/$projectId/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdIndexRoute + '/organizations/$organizationId/projects/$projectId/catalog/grant-versions': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRoute '/organizations/$organizationId/projects/$projectId/catalog/providers': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProvidersRouteWithChildren '/organizations/$organizationId/projects/$projectId/settings/api-keys': typeof HostedOrganizationsOrganizationIdProjectsProjectIdSettingsApiKeysRoute '/organizations/$organizationId/projects/$projectId/settings/environments': typeof HostedOrganizationsOrganizationIdProjectsProjectIdSettingsEnvironmentsRoute '/studio-hosted/$organizationId/$projectId/$environmentId/$paywallId/$draftId': typeof Studio_layoutStudioHostedOrganizationIdProjectIdEnvironmentIdPaywallIdDraftIdRoute '/organizations/$organizationId/projects/$projectId/analytics/$environmentId/$surface': typeof HostedOrganizationsOrganizationIdProjectsProjectIdAnalyticsEnvironmentIdSurfaceRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/health': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdHealthRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRoute '/organizations/$organizationId/projects/$projectId/billing/connections/$credentialId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingConnectionsCredentialIdRoute '/organizations/$organizationId/projects/$projectId/catalog/entitlements/$entitlementId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsEntitlementIdRoute '/organizations/$organizationId/projects/$projectId/catalog/plans/$planId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansPlanIdRoute @@ -406,13 +481,18 @@ export interface FileRoutesByFullPath { '/organizations/$organizationId/projects/$projectId/catalog/entitlements/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsIndexRoute '/organizations/$organizationId/projects/$projectId/catalog/plans/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansIndexRoute '/organizations/$organizationId/projects/$projectId/catalog/products/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProductsIndexRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/$recordId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineRecordIdRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/$runId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationRunIdRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/$factId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsFactIdRoute '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/$experimentId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdExperimentsExperimentIdRoute '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/new': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdExperimentsNewRoute '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/paywalls/$paywallId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdPaywallsPaywallIdRoute '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/placements/$placementId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdPlacementsPlacementIdRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineIndexRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationIndexRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsIndexRoute @@ -430,12 +510,15 @@ export interface FileRoutesByTo { '/organizations/$organizationId/projects/new': typeof HostedOrganizationsOrganizationIdProjectsNewRoute '/organizations/$organizationId/projects/$projectId/apps': typeof HostedOrganizationsOrganizationIdProjectsProjectIdAppsRoute '/organizations/$organizationId/projects/$projectId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdIndexRoute + '/organizations/$organizationId/projects/$projectId/catalog/grant-versions': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRoute '/organizations/$organizationId/projects/$projectId/catalog/providers': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProvidersRouteWithChildren '/organizations/$organizationId/projects/$projectId/settings/api-keys': typeof HostedOrganizationsOrganizationIdProjectsProjectIdSettingsApiKeysRoute '/organizations/$organizationId/projects/$projectId/settings/environments': typeof HostedOrganizationsOrganizationIdProjectsProjectIdSettingsEnvironmentsRoute '/studio-hosted/$organizationId/$projectId/$environmentId/$paywallId/$draftId': typeof Studio_layoutStudioHostedOrganizationIdProjectIdEnvironmentIdPaywallIdDraftIdRoute '/organizations/$organizationId/projects/$projectId/analytics/$environmentId/$surface': typeof HostedOrganizationsOrganizationIdProjectsProjectIdAnalyticsEnvironmentIdSurfaceRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/health': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdHealthRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRoute '/organizations/$organizationId/projects/$projectId/billing/connections/$credentialId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingConnectionsCredentialIdRoute '/organizations/$organizationId/projects/$projectId/catalog/entitlements/$entitlementId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsEntitlementIdRoute '/organizations/$organizationId/projects/$projectId/catalog/plans/$planId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansPlanIdRoute @@ -450,13 +533,18 @@ export interface FileRoutesByTo { '/organizations/$organizationId/projects/$projectId/catalog/entitlements': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsIndexRoute '/organizations/$organizationId/projects/$projectId/catalog/plans': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansIndexRoute '/organizations/$organizationId/projects/$projectId/catalog/products': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProductsIndexRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/$recordId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineRecordIdRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/$runId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationRunIdRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/$factId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsFactIdRoute '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/$experimentId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdExperimentsExperimentIdRoute '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/new': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdExperimentsNewRoute '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/paywalls/$paywallId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdPaywallsPaywallIdRoute '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/placements/$placementId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdPlacementsPlacementIdRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRoute + '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineIndexRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationIndexRoute '/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsIndexRoute @@ -477,12 +565,15 @@ export interface FileRoutesById { '/_hosted/organizations/$organizationId/projects/new': typeof HostedOrganizationsOrganizationIdProjectsNewRoute '/_hosted/organizations/$organizationId/projects/$projectId/apps': typeof HostedOrganizationsOrganizationIdProjectsProjectIdAppsRoute '/_hosted/organizations/$organizationId/projects/$projectId/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdIndexRoute + '/_hosted/organizations/$organizationId/projects/$projectId/catalog/grant-versions': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRoute '/_hosted/organizations/$organizationId/projects/$projectId/catalog/providers': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProvidersRouteWithChildren '/_hosted/organizations/$organizationId/projects/$projectId/settings/api-keys': typeof HostedOrganizationsOrganizationIdProjectsProjectIdSettingsApiKeysRoute '/_hosted/organizations/$organizationId/projects/$projectId/settings/environments': typeof HostedOrganizationsOrganizationIdProjectsProjectIdSettingsEnvironmentsRoute '/_studio_layout/studio-hosted/$organizationId/$projectId/$environmentId/$paywallId/$draftId': typeof Studio_layoutStudioHostedOrganizationIdProjectIdEnvironmentIdPaywallIdDraftIdRoute '/_hosted/organizations/$organizationId/projects/$projectId/analytics/$environmentId/$surface': typeof HostedOrganizationsOrganizationIdProjectsProjectIdAnalyticsEnvironmentIdSurfaceRoute '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/health': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdHealthRoute + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRoute + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRoute '/_hosted/organizations/$organizationId/projects/$projectId/billing/connections/$credentialId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingConnectionsCredentialIdRoute '/_hosted/organizations/$organizationId/projects/$projectId/catalog/entitlements/$entitlementId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsEntitlementIdRoute '/_hosted/organizations/$organizationId/projects/$projectId/catalog/plans/$planId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansPlanIdRoute @@ -497,13 +588,18 @@ export interface FileRoutesById { '/_hosted/organizations/$organizationId/projects/$projectId/catalog/entitlements/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsIndexRoute '/_hosted/organizations/$organizationId/projects/$projectId/catalog/plans/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansIndexRoute '/_hosted/organizations/$organizationId/projects/$projectId/catalog/products/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProductsIndexRoute + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRoute + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRoute '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/$recordId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineRecordIdRoute '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/$runId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationRunIdRoute + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRoute '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/$factId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsFactIdRoute '/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/$experimentId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdExperimentsExperimentIdRoute '/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/new': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdExperimentsNewRoute '/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/paywalls/$paywallId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdPaywallsPaywallIdRoute '/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/placements/$placementId': typeof HostedOrganizationsOrganizationIdProjectsProjectIdMonetizationEnvironmentIdPlacementsPlacementIdRoute + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRoute + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRoute '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineIndexRoute '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationIndexRoute '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/': typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsIndexRoute @@ -523,12 +619,15 @@ export interface FileRouteTypes { | '/organizations/$organizationId/projects/new' | '/organizations/$organizationId/projects/$projectId/apps' | '/organizations/$organizationId/projects/$projectId/' + | '/organizations/$organizationId/projects/$projectId/catalog/grant-versions' | '/organizations/$organizationId/projects/$projectId/catalog/providers' | '/organizations/$organizationId/projects/$projectId/settings/api-keys' | '/organizations/$organizationId/projects/$projectId/settings/environments' | '/studio-hosted/$organizationId/$projectId/$environmentId/$paywallId/$draftId' | '/organizations/$organizationId/projects/$projectId/analytics/$environmentId/$surface' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/health' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores' | '/organizations/$organizationId/projects/$projectId/billing/connections/$credentialId' | '/organizations/$organizationId/projects/$projectId/catalog/entitlements/$entitlementId' | '/organizations/$organizationId/projects/$projectId/catalog/plans/$planId' @@ -543,13 +642,18 @@ export interface FileRouteTypes { | '/organizations/$organizationId/projects/$projectId/catalog/entitlements/' | '/organizations/$organizationId/projects/$projectId/catalog/plans/' | '/organizations/$organizationId/projects/$projectId/catalog/products/' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/$recordId' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/$runId' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/$factId' | '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/$experimentId' | '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/new' | '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/paywalls/$paywallId' | '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/placements/$placementId' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/' @@ -567,12 +671,15 @@ export interface FileRouteTypes { | '/organizations/$organizationId/projects/new' | '/organizations/$organizationId/projects/$projectId/apps' | '/organizations/$organizationId/projects/$projectId' + | '/organizations/$organizationId/projects/$projectId/catalog/grant-versions' | '/organizations/$organizationId/projects/$projectId/catalog/providers' | '/organizations/$organizationId/projects/$projectId/settings/api-keys' | '/organizations/$organizationId/projects/$projectId/settings/environments' | '/studio-hosted/$organizationId/$projectId/$environmentId/$paywallId/$draftId' | '/organizations/$organizationId/projects/$projectId/analytics/$environmentId/$surface' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/health' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores' | '/organizations/$organizationId/projects/$projectId/billing/connections/$credentialId' | '/organizations/$organizationId/projects/$projectId/catalog/entitlements/$entitlementId' | '/organizations/$organizationId/projects/$projectId/catalog/plans/$planId' @@ -587,13 +694,18 @@ export interface FileRouteTypes { | '/organizations/$organizationId/projects/$projectId/catalog/entitlements' | '/organizations/$organizationId/projects/$projectId/catalog/plans' | '/organizations/$organizationId/projects/$projectId/catalog/products' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/$recordId' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/$runId' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/$factId' | '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/$experimentId' | '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/new' | '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/paywalls/$paywallId' | '/organizations/$organizationId/projects/$projectId/monetization/$environmentId/placements/$placementId' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers' + | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation' | '/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions' @@ -613,12 +725,15 @@ export interface FileRouteTypes { | '/_hosted/organizations/$organizationId/projects/new' | '/_hosted/organizations/$organizationId/projects/$projectId/apps' | '/_hosted/organizations/$organizationId/projects/$projectId/' + | '/_hosted/organizations/$organizationId/projects/$projectId/catalog/grant-versions' | '/_hosted/organizations/$organizationId/projects/$projectId/catalog/providers' | '/_hosted/organizations/$organizationId/projects/$projectId/settings/api-keys' | '/_hosted/organizations/$organizationId/projects/$projectId/settings/environments' | '/_studio_layout/studio-hosted/$organizationId/$projectId/$environmentId/$paywallId/$draftId' | '/_hosted/organizations/$organizationId/projects/$projectId/analytics/$environmentId/$surface' | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/health' + | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health' + | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores' | '/_hosted/organizations/$organizationId/projects/$projectId/billing/connections/$credentialId' | '/_hosted/organizations/$organizationId/projects/$projectId/catalog/entitlements/$entitlementId' | '/_hosted/organizations/$organizationId/projects/$projectId/catalog/plans/$planId' @@ -633,13 +748,18 @@ export interface FileRouteTypes { | '/_hosted/organizations/$organizationId/projects/$projectId/catalog/entitlements/' | '/_hosted/organizations/$organizationId/projects/$projectId/catalog/plans/' | '/_hosted/organizations/$organizationId/projects/$projectId/catalog/products/' + | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId' + | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId' | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/$recordId' | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/$runId' + | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId' | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/$factId' | '/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/$experimentId' | '/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/experiments/new' | '/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/paywalls/$paywallId' | '/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/placements/$placementId' + | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/' + | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/' | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/quarantine/' | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/' | '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/transactions/' @@ -782,6 +902,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProvidersRouteImport parentRoute: typeof HostedRoute } + '/_hosted/organizations/$organizationId/projects/$projectId/catalog/grant-versions': { + id: '/_hosted/organizations/$organizationId/projects/$projectId/catalog/grant-versions' + path: '/organizations/$organizationId/projects/$projectId/catalog/grant-versions' + fullPath: '/organizations/$organizationId/projects/$projectId/catalog/grant-versions' + preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRouteImport + parentRoute: typeof HostedRoute + } '/_hosted/organizations/$organizationId/projects/$projectId/catalog/products/': { id: '/_hosted/organizations/$organizationId/projects/$projectId/catalog/products/' path: '/organizations/$organizationId/projects/$projectId/catalog/products' @@ -880,6 +1007,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingConnectionsCredentialIdRouteImport parentRoute: typeof HostedRoute } + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores': { + id: '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores' + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores' + fullPath: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores' + preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRouteImport + parentRoute: typeof HostedRoute + } + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health': { + id: '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health' + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health' + fullPath: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health' + preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRouteImport + parentRoute: typeof HostedRoute + } '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/health': { id: '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/health' path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/health' @@ -915,6 +1056,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineIndexRouteImport parentRoute: typeof HostedRoute } + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/': { + id: '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/' + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts' + fullPath: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/' + preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRouteImport + parentRoute: typeof HostedRoute + } + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/': { + id: '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/' + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers' + fullPath: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/' + preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRouteImport + parentRoute: typeof HostedRoute + } '/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/placements/$placementId': { id: '/_hosted/organizations/$organizationId/projects/$projectId/monetization/$environmentId/placements/$placementId' path: '/$placementId' @@ -950,6 +1105,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsFactIdRouteImport parentRoute: typeof HostedRoute } + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId': { + id: '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId' + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId' + fullPath: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId' + preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRouteImport + parentRoute: typeof HostedRoute + } '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/$runId': { id: '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/$runId' path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/reconciliation/$runId' @@ -964,6 +1126,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineRecordIdRouteImport parentRoute: typeof HostedRoute } + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId': { + id: '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId' + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId' + fullPath: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId' + preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRouteImport + parentRoute: typeof HostedRoute + } + '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId': { + id: '/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId' + path: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId' + fullPath: '/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId' + preLoaderRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRouteImport + parentRoute: typeof HostedRoute + } } } @@ -1038,11 +1214,14 @@ interface HostedRouteChildren { HostedOrganizationsOrganizationIdProjectsNewRoute: typeof HostedOrganizationsOrganizationIdProjectsNewRoute HostedOrganizationsOrganizationIdProjectsProjectIdAppsRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdAppsRoute HostedOrganizationsOrganizationIdProjectsProjectIdIndexRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdIndexRoute + HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRoute HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProvidersRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProvidersRouteWithChildren HostedOrganizationsOrganizationIdProjectsProjectIdSettingsApiKeysRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdSettingsApiKeysRoute HostedOrganizationsOrganizationIdProjectsProjectIdSettingsEnvironmentsRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdSettingsEnvironmentsRoute HostedOrganizationsOrganizationIdProjectsProjectIdAnalyticsEnvironmentIdSurfaceRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdAnalyticsEnvironmentIdSurfaceRoute HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdHealthRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdHealthRoute + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRoute + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRoute HostedOrganizationsOrganizationIdProjectsProjectIdBillingConnectionsCredentialIdRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingConnectionsCredentialIdRoute HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsEntitlementIdRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsEntitlementIdRoute HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansPlanIdRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansPlanIdRoute @@ -1056,9 +1235,14 @@ interface HostedRouteChildren { HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsIndexRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsIndexRoute HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansIndexRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansIndexRoute HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProductsIndexRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProductsIndexRoute + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRoute + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRoute HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineRecordIdRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineRecordIdRoute HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationRunIdRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationRunIdRoute + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRoute HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsFactIdRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsFactIdRoute + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRoute + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRoute HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineIndexRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineIndexRoute HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationIndexRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationIndexRoute HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsIndexRoute: typeof HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsIndexRoute @@ -1077,6 +1261,8 @@ const HostedRouteChildren: HostedRouteChildren = { HostedOrganizationsOrganizationIdProjectsProjectIdAppsRoute, HostedOrganizationsOrganizationIdProjectsProjectIdIndexRoute: HostedOrganizationsOrganizationIdProjectsProjectIdIndexRoute, + HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRoute: + HostedOrganizationsOrganizationIdProjectsProjectIdCatalogGrantVersionsRoute, HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProvidersRoute: HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProvidersRouteWithChildren, HostedOrganizationsOrganizationIdProjectsProjectIdSettingsApiKeysRoute: @@ -1087,6 +1273,10 @@ const HostedRouteChildren: HostedRouteChildren = { HostedOrganizationsOrganizationIdProjectsProjectIdAnalyticsEnvironmentIdSurfaceRoute, HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdHealthRoute: HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdHealthRoute, + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRoute: + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdProjectionHealthRoute, + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRoute: + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdRestoresRoute, HostedOrganizationsOrganizationIdProjectsProjectIdBillingConnectionsCredentialIdRoute: HostedOrganizationsOrganizationIdProjectsProjectIdBillingConnectionsCredentialIdRoute, HostedOrganizationsOrganizationIdProjectsProjectIdCatalogEntitlementsEntitlementIdRoute: @@ -1113,12 +1303,22 @@ const HostedRouteChildren: HostedRouteChildren = { HostedOrganizationsOrganizationIdProjectsProjectIdCatalogPlansIndexRoute, HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProductsIndexRoute: HostedOrganizationsOrganizationIdProjectsProjectIdCatalogProductsIndexRoute, + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRoute: + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersCustomerIdRoute, + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRoute: + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsConflictIdRoute, HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineRecordIdRoute: HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineRecordIdRoute, HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationRunIdRoute: HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationRunIdRoute, + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRoute: + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdSubscriptionsInstanceIdRoute, HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsFactIdRoute: HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdTransactionsFactIdRoute, + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRoute: + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdCustomersIndexRoute, + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRoute: + HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdIdentityConflictsIndexRoute, HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineIndexRoute: HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdQuarantineIndexRoute, HostedOrganizationsOrganizationIdProjectsProjectIdBillingEnvironmentIdReconciliationIndexRoute: diff --git a/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId.tsx b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId.tsx new file mode 100644 index 00000000..b31adfe0 --- /dev/null +++ b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { RoutePendingState } from "@/components/feedback/route-feedback" + +import { CustomerDetailPage } from "@/features/billing-customers/components/customer-detail-page" + +export const Route = createFileRoute( + "/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId", +)({ + component: CustomerDetailRoute, + pendingComponent: RoutePendingState, +}) + +function CustomerDetailRoute() { + const { customerId, environmentId, organizationId, projectId } = Route.useParams() + return ( + + ) +} diff --git a/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/index.tsx b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/index.tsx new file mode 100644 index 00000000..b74a3c02 --- /dev/null +++ b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/index.tsx @@ -0,0 +1,57 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { RoutePendingState } from "@/components/feedback/route-feedback" + +import { BillingCustomersPage } from "@/features/billing-customers/components/billing-customers-page" + +interface CustomersSearch { + conflictedOnly?: boolean + cursor?: string +} + +export const Route = createFileRoute( + "/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/", +)({ + component: BillingCustomersRoute, + pendingComponent: RoutePendingState, + validateSearch: (search: Record): CustomersSearch => ({ + conflictedOnly: + search.conflictedOnly === true || search.conflictedOnly === "true" ? true : undefined, + cursor: + typeof search.cursor === "string" && search.cursor.length > 0 && search.cursor.length <= 512 + ? search.cursor + : undefined, + }), +}) + +function BillingCustomersRoute() { + const { environmentId, organizationId, projectId } = Route.useParams() + const { conflictedOnly, cursor } = Route.useSearch() + const navigate = Route.useNavigate() + + return ( + { + void navigate({ + params: { customerId, environmentId, organizationId, projectId }, + search: {}, + to: "/organizations/$organizationId/projects/$projectId/billing/$environmentId/customers/$customerId", + }) + }} + onFiltersChange={(filters) => { + void navigate({ + replace: true, + search: { + ...(filters.conflictedOnly ? { conflictedOnly: true } : {}), + ...(filters.cursor ? { cursor: filters.cursor } : {}), + }, + }) + }} + organizationId={organizationId} + projectId={projectId} + /> + ) +} diff --git a/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId.tsx b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId.tsx new file mode 100644 index 00000000..66a84cad --- /dev/null +++ b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { RoutePendingState } from "@/components/feedback/route-feedback" + +import { IdentityConflictDetailPage } from "@/features/billing-customers/components/identity-conflict-detail-page" + +export const Route = createFileRoute( + "/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/$conflictId", +)({ + component: IdentityConflictDetailRoute, + pendingComponent: RoutePendingState, +}) + +function IdentityConflictDetailRoute() { + const { conflictId, environmentId, organizationId, projectId } = Route.useParams() + return ( + + ) +} diff --git a/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/index.tsx b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/index.tsx new file mode 100644 index 00000000..1d57d4f0 --- /dev/null +++ b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/index.tsx @@ -0,0 +1,37 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { RoutePendingState } from "@/components/feedback/route-feedback" + +import { IdentityConflictsPage } from "@/features/billing-customers/components/identity-conflicts-page" + +interface ConflictsSearch { + status?: "open" | "resolved" +} + +export const Route = createFileRoute( + "/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/identity-conflicts/", +)({ + component: IdentityConflictsRoute, + pendingComponent: RoutePendingState, + validateSearch: (search: Record): ConflictsSearch => ({ + status: search.status === "resolved" ? "resolved" : undefined, + }), +}) + +function IdentityConflictsRoute() { + const { environmentId, organizationId, projectId } = Route.useParams() + const { status } = Route.useSearch() + const navigate = Route.useNavigate() + + return ( + { + void navigate({ replace: true, search: next === "resolved" ? { status: next } : {} }) + }} + organizationId={organizationId} + projectId={projectId} + status={status ?? "open"} + /> + ) +} diff --git a/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health.tsx b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health.tsx new file mode 100644 index 00000000..ebc5dfe5 --- /dev/null +++ b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health.tsx @@ -0,0 +1,23 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { RoutePendingState } from "@/components/feedback/route-feedback" + +import { ProjectionHealthPage } from "@/features/billing-projection/components/projection-health-page" + +export const Route = createFileRoute( + "/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/projection-health", +)({ + component: ProjectionHealthRoute, + pendingComponent: RoutePendingState, +}) + +function ProjectionHealthRoute() { + const { environmentId, organizationId, projectId } = Route.useParams() + return ( + + ) +} diff --git a/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores.tsx b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores.tsx new file mode 100644 index 00000000..ac0e0717 --- /dev/null +++ b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores.tsx @@ -0,0 +1,40 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { RoutePendingState } from "@/components/feedback/route-feedback" + +import { RestoreJobsPage } from "@/features/billing-customers/components/restore-jobs-page" + +interface RestoresSearch { + cursor?: string +} + +export const Route = createFileRoute( + "/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/restores", +)({ + component: RestoreJobsRoute, + pendingComponent: RoutePendingState, + validateSearch: (search: Record): RestoresSearch => ({ + cursor: + typeof search.cursor === "string" && search.cursor.length > 0 && search.cursor.length <= 512 + ? search.cursor + : undefined, + }), +}) + +function RestoreJobsRoute() { + const { environmentId, organizationId, projectId } = Route.useParams() + const { cursor } = Route.useSearch() + const navigate = Route.useNavigate() + + return ( + { + void navigate({ replace: true, search: next ? { cursor: next } : {} }) + }} + organizationId={organizationId} + projectId={projectId} + /> + ) +} diff --git a/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId.tsx b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId.tsx new file mode 100644 index 00000000..52b4193b --- /dev/null +++ b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId.tsx @@ -0,0 +1,41 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { RoutePendingState } from "@/components/feedback/route-feedback" + +import { SubscriptionDetailPage } from "@/features/billing-customers/components/subscription-detail-page" + +interface SubscriptionSearch { + cursor?: string +} + +export const Route = createFileRoute( + "/_hosted/organizations/$organizationId/projects/$projectId/billing/$environmentId/subscriptions/$instanceId", +)({ + component: SubscriptionDetailRoute, + pendingComponent: RoutePendingState, + validateSearch: (search: Record): SubscriptionSearch => ({ + cursor: + typeof search.cursor === "string" && search.cursor.length > 0 && search.cursor.length <= 512 + ? search.cursor + : undefined, + }), +}) + +function SubscriptionDetailRoute() { + const { environmentId, instanceId, organizationId, projectId } = Route.useParams() + const { cursor } = Route.useSearch() + const navigate = Route.useNavigate() + + return ( + { + void navigate({ replace: true, search: next ? { cursor: next } : {} }) + }} + organizationId={organizationId} + projectId={projectId} + /> + ) +} diff --git a/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/catalog/grant-versions.tsx b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/catalog/grant-versions.tsx new file mode 100644 index 00000000..e4e9849c --- /dev/null +++ b/apps/dashboard/src/routes/_hosted/organizations/$organizationId/projects/$projectId/catalog/grant-versions.tsx @@ -0,0 +1,49 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { RoutePendingState } from "@/components/feedback/route-feedback" + +import { GrantVersionsPage } from "@/features/entitlement-grants/components/grant-versions-page" + +interface GrantVersionsSearch { + entitlementId?: string + productId?: string +} + +function readIdentifier(value: unknown) { + return typeof value === "string" && value.length > 0 && value.length <= 128 ? value : undefined +} + +export const Route = createFileRoute( + "/_hosted/organizations/$organizationId/projects/$projectId/catalog/grant-versions", +)({ + component: GrantVersionsRoute, + pendingComponent: RoutePendingState, + validateSearch: (search: Record): GrantVersionsSearch => ({ + entitlementId: readIdentifier(search.entitlementId), + productId: readIdentifier(search.productId), + }), +}) + +function GrantVersionsRoute() { + const { organizationId, projectId } = Route.useParams() + const { entitlementId, productId } = Route.useSearch() + const navigate = Route.useNavigate() + + return ( + { + void navigate({ + replace: true, + search: { + ...(scope.entitlementId ? { entitlementId: scope.entitlementId } : {}), + ...(scope.productId ? { productId: scope.productId } : {}), + }, + }) + }} + organizationId={organizationId} + {...(productId ? { productId } : {})} + projectId={projectId} + /> + ) +} diff --git a/docs/architecture/decisions/0024-sign-application-webhooks-with-hmac-sha256.md b/docs/architecture/decisions/0024-sign-application-webhooks-with-hmac-sha256.md new file mode 100644 index 00000000..2843e22d --- /dev/null +++ b/docs/architecture/decisions/0024-sign-application-webhooks-with-hmac-sha256.md @@ -0,0 +1,140 @@ +# ADR-0024: Sign Application Webhooks with HMAC-SHA256 and Constrain Destinations + +## Status + +Accepted + +## Date + +2026-07-28 + +## Context + +Phase 9B makes Mosaic authoritative over customer access. An application backend that needs to +react when access changes has two options: poll the Access Decision API, or receive a webhook. +The orchestration prompt assumed a "webhook-signing system" already existed; the Phase 9B Stage 1 +inspection established that no webhook or signing infrastructure exists anywhere in the +repository, so the mechanism has to be chosen rather than reused. + +Owner decision OD-1(b) scopes 9B to a minimal slice: one event type +(`customer.entitlements.changed`), at-least-once delivery, attempt history, API-managed +destinations, no dashboard UI. The roadmap is amended to record the split with the remainder in +9C. + +A webhook receiver has to answer one question before it acts: did Mosaic send this? Without a +verifiable answer, an entitlement-change webhook is an unauthenticated instruction to grant +someone access — a receiver that trusts it grants access to whoever can reach the URL. Mosaic +also accepts an operator-supplied URL and makes outbound requests to it, which is a +server-side request forgery primitive unless it is bounded. + +## Decision + +### 1. Signature: HMAC-SHA256 over a versioned, timestamped, event-bound string + +Every delivery carries: + +```text +Mosaic-Signature: t=, v1= +``` + +The signed payload begins with the literal `v1`, matching the header element name and the +reference vectors in `packages/test-fixtures/src/webhook-signature-vectors.json`. This ADR was +drafted with a bare `1` before the Billing State Webhook Contract was frozen; the contract and +its vectors are the authority, and the literal above is corrected to agree with them. + +The signed string binds four things deliberately: + +- the **scheme version** (`v1`), so a future scheme change is a new `v` element rather than a + silent reinterpretation of the same bytes; +- the **timestamp**, so a receiver can reject replays outside its tolerance window; +- the **event ID**, so a captured signature cannot be re-attached to a different event body; +- the **exact body bytes**, so nothing in the payload can be altered in flight. + +HMAC-SHA256 rather than an asymmetric signature: the receiver is the tenant that owns the +secret, there is no third party who must verify without being able to sign, and a shared secret +keeps verification to a few lines in any language. Asymmetric signing would add key +distribution and rotation surface that buys nothing here. + +Receivers must compare in constant time and must reject a delivery whose timestamp is outside +their tolerance. Both are documented in the consumer guidance. + +### 2. Multiple active secrets during rotation + +A destination may hold more than one active signing secret. Delivery signs with every active +secret and sends the resulting `v1` elements together, so a receiver that has adopted the new +secret and one that has not both verify during the overlap. Retirement is explicit and audited; +a retired secret stops signing immediately. + +Without an overlap window, rotating a secret means a rotation that is simultaneous on both +sides or a period of rejected deliveries — neither of which an operator can actually achieve. + +### 3. Secrets are sealed under a new v2 AAD SubjectKind + +Webhook signing secrets are sealed with the existing AES-GCM envelope from ADR-0019 under a new +`SubjectKind` of `webhook_signing_secret`, with the destination ID as the subject ID. The +additional authenticated data therefore binds each ciphertext to the exact destination row it +belongs to, so a sealed secret moved to another destination — by a bug or by a database +compromise that can write but not decrypt — fails to open rather than signing for the wrong +tenant. + +This is a v2 AAD addition, not a change to any existing subject kind; credentials sealed under +9A's subject kinds are unaffected. + +### 4. SSRF policy for destinations + +A destination URL is operator-supplied and Mosaic makes outbound requests to it. The policy: + +- **HTTPS only.** Plaintext delivery of entitlement state is not offered at any tier. +- **Denied address space**, evaluated against the *resolved* address: RFC1918 private ranges, + loopback, link-local (including the cloud metadata address), CGNAT (100.64.0.0/10), IPv6 + unique-local, IPv4-mapped IPv6, and the unspecified address. +- **Resolve and pin per attempt.** The hostname is resolved, the resolved address is checked, + and the connection is made to that address. Checking the hostname and then letting the HTTP + client resolve again is the classic DNS-rebinding hole: the second resolution can return an + address the first check would have refused. +- **No redirects.** A redirect is a second destination the operator never approved. +- **Bounded time and size**: a connect and total timeout, and a response-body read ceiling. A + webhook receiver's response body is never used for anything, so the ceiling can be small. +- **Self-hosted exception behind an environment flag.** Operators running Mosaic and their + application backend on one private network legitimately need a private destination. The + exception is an explicit deployment-level flag, never a per-destination toggle an operator + could set from the API — a per-destination override would let anyone with destination-write + permission reach the internal network. + +### 5. Delivery never rolls back committed state + +An access-change webhook is created inside the projection transaction, so an event exists only +for state that was committed. Delivery happens outside it. A destination that is down, slow, or +returning errors produces retries and eventually an exhausted delivery record; it never rolls +back an entitlement change, and it never blocks a projection. + +Retries reuse the same event ID. A retry is a new delivery attempt, never a new logical event, +so a receiver deduplicating on event ID sees each change once. + +## Consequences + +- Receivers need a shared secret and constant-time HMAC verification; the delivery contract + documents the exact signed string and a reference vector set lives in `packages/test-fixtures`. +- Rotation is operationally safe but requires an explicit retire step, which is audited. +- The SSRF policy will refuse some destinations operators expect to work (localhost during + development). The self-hosted flag is the supported answer; a per-destination bypass is not. +- Consumers are documented as tolerant (ignore unknown fields and unknown event types, re-read + the snapshot) while the producer stays strict — a deliberate, recorded departure from the + repository-wide fail-closed posture (OD-16), because a webhook consumer that rejects an + unrecognized field breaks on every additive change Mosaic makes. +- The delivery worker, destination management API, and signing implementation land after the + 9B schema; this ADR is the contract they implement. + +## Alternatives Considered + +**Asymmetric signatures (Ed25519 / JWS).** Rejected: no verifier exists who cannot also be +trusted with a shared secret, and it adds public-key distribution and rotation surface for no +gain in this trust model. + +**No signing; TLS plus a secret path segment.** Rejected: a secret in a URL leaks through +proxy logs, browser history, and error reports, and it cannot be rotated without changing the +destination. + +**Polling only, deferring webhooks entirely to 9C (OD-1(a)).** Rejected by the owner in favour +of the minimal slice, because polling the Access Decision API for change detection is the +pattern the authoritative-entitlement design exists to remove. diff --git a/docs/backend/openapi.yaml b/docs/backend/openapi.yaml index c4be4055..35dbcb56 100644 --- a/docs/backend/openapi.yaml +++ b/docs/backend/openapi.yaml @@ -1728,6 +1728,8 @@ paths: Every outcome, including a rejection, is returned as the contract's observationSubmissionResult record so an SDK decodes one shape. This endpoint may return 429; SDKs hold a durable queue and retry. + parameters: + - { in: header, name: Mosaic-Customer-Token, required: false, schema: { type: string }, description: Optional Customer Access Token binding the observation to its customer as token-bound public-client evidence. It can attach an unowned lineage but cannot reassign or freeze an attached lineage. } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ClientTransactionObservationRecord" } } } } responses: "202": { $ref: "#/components/responses/SubmissionAccepted" } @@ -1750,6 +1752,8 @@ paths: A trusted caller is more accountable, not more authoritative: the observation is still subject to complete provider validation. Responses use the same observationSubmissionResult record as the public endpoint. + parameters: + - { in: header, name: Mosaic-Customer-Token, required: false, schema: { type: string }, description: Optional Customer Access Token naming the customer. Because this request is authenticated by the secret server key, accepted submission-context evidence carries trusted-server authority. } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ServerTransactionObservationRecord" } } } } responses: "202": { $ref: "#/components/responses/SubmissionAccepted" } @@ -1758,6 +1762,471 @@ paths: "422": { $ref: "#/components/responses/SubmissionRejected" } "429": { $ref: "#/components/responses/SubmissionRetryable" } "503": { $ref: "#/components/responses/SubmissionRetryable" } + /v1/sdk/billing/entitlements: + get: + security: [{ CustomerAccessToken: [] }] + operationId: syncCustomerEntitlements + tags: [Authoritative Entitlements] + description: | + The Access Decision Snapshot for the Billing Customer the Customer Access Token names, + as the Authoritative Entitlement Contract v1 customerEntitlementSnapshot record. + + Two credentials are required and they answer different questions: the token decides + WHICH customer is read, and the public SDK key in Mosaic-SDK-Key decides which + Environment is asking. A public SDK key alone can never select a customer — it ships + inside an application binary. A token presented with another Environment's key is 403. + + The response body is the contract's canonical serialization, byte for byte, because an + SDK recomputes contentDigest over exactly the bytes it received. + + Freshness travels as headers as well as inside the record, so a confirmed-current + snapshot never expires merely because it was confirmed instead of resent. The negotiated + POST form carries the refreshed window in the snapshotUnchanged record body; the headers + are what a bodyless conditional GET would have. The combined horizon (validUntil minus + issuedAt, plus staleGraceSeconds) never exceeds thirty days. + + A customer with no committed projection in this Environment is answered with a valid + snapshot carrying no entries and projectionStatus.state `pending`. That is `unknown`, + never `inactive`: absence of evidence is not evidence of absence. + parameters: + - { in: header, name: Mosaic-SDK-Key, required: true, schema: { type: string }, description: Public SDK key identifying the Environment and Application. } + responses: + "200": + description: The current Customer Entitlement Snapshot. + headers: + ETag: { schema: { type: string }, description: Opaque equality validator. It carries no ordering; snapshot monotonicity is decided by snapshotVersion alone. } + Mosaic-Refresh-After: { schema: { type: string, format: date-time }, description: After this instant a reader should refresh. The snapshot stays fully valid. } + Mosaic-Valid-Until: { schema: { type: string, format: date-time }, description: Hard end of authoritative validity. } + Mosaic-Stale-Grace-Seconds: { schema: { type: integer }, description: "Bounded window past validUntil in which previously active Entitlements may still be served, clearly marked stale. Zero is a strict policy." } + content: { application/json: { schema: { $ref: "#/components/schemas/CustomerEntitlementSnapshotRecord" } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "429": { $ref: "#/components/responses/Error" } + post: + security: [{ CustomerAccessToken: [] }] + operationId: syncCustomerEntitlementsWithNegotiation + tags: [Authoritative Entitlements] + description: | + The conditional and negotiated form of the sync. The body is the contract's + entitlementSyncRequest: supported contract versions, the snapshot version the caller + already holds, its entity tag, and optionally the Entitlement keys to narrow to. + + billingCustomerId in the body is a hint only. The server derives the customer from the + token and verifies the hint against it; a mismatch is refused rather than ignored, + because silently ignoring it would let a client believe it had read a customer it had + not. A caller can never widen access by asserting an identifier. + + Narrowing removes both entries and the sources no remaining entry references, so a + narrowed snapshot never carries an orphan source. + + This is the ratified cross-SDK flow, and knownSnapshotVersion is the only conditional + mechanism this surface has. A matching knownSnapshotVersion always answers 200 with the + snapshotUnchanged record, never a bare 304, because the contract cannot guarantee + freshness that lives only in undocumented headers. + parameters: + - { in: header, name: Mosaic-SDK-Key, required: true, schema: { type: string } } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/EntitlementSyncRequestRecord" } } } } + responses: + "200": + description: | + The current Customer Entitlement Snapshot, or — when the stated knownSnapshotVersion + and entity tag both match — the canonical snapshotUnchanged record. This form never + answers 304: a bodyless response would force freshness into header names no frozen + schema defines, so the unchanged record carries refreshAfter, validUntil, and + staleGraceSeconds in the body instead, recomputed at the instant it was answered. + headers: + ETag: { schema: { type: string }, description: Opaque equality validator. Snapshot monotonicity is decided by snapshotVersion alone. } + content: { application/json: { schema: { $ref: "#/components/schemas/CustomerEntitlementSnapshotRecord" } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "406": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + "429": { $ref: "#/components/responses/Error" } + /v1/billing/server/customer-tokens: + post: + security: [{ ServerSecretKey: [] }] + operationId: issueCustomerAccessToken + tags: [Authoritative Entitlements] + description: | + Mint a Customer Access Token for a customer the calling backend has already + authenticated. This is the second consumer of secret server key authentication. + + The request carries no Project and no Environment: tenant scope comes entirely from the + authenticated key, so a compromised or careless caller cannot mint a token into a tenant + it does not own. + + The token is opaque — 256 bits of randomness behind an mcat_ prefix, with no claims and + no parseable structure. Mosaic stores only its SHA-256 digest, so the value in this + response is the only time it exists outside the caller's process: it is never logged, + never stored, and never returned again. + + requestedTtlSeconds is a request, not an instruction. The default is one hour and the + contract maximum is twenty-four; a caller may shorten a token's life and can never + lengthen it past the maximum, which the schema enforces independently. + + Only the sdk_sync audience is issued in Phase 9B. server_check is declared by the + contract so adding it later costs no contract version, and is refused with + validation_failed until the surface it names exists. + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CustomerAccessTokenIssuanceRequest" } } } } + responses: + "201": { description: The token and its metadata. The token value appears here and nowhere else., content: { application/json: { schema: { $ref: "#/components/schemas/CustomerAccessTokenIssuanceResult" } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + get: + security: [{ ServerSecretKey: [] }] + operationId: listCustomerAccessTokens + tags: [Authoritative Entitlements] + description: | + Token metadata for one Billing Customer. No token value is ever returned, because Mosaic + does not have one to return — only digests are stored. + parameters: + - { in: query, name: billingCustomerId, required: true, schema: { type: string } } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Token metadata., content: { application/json: { schema: { type: object, properties: { data: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/CustomerAccessTokenMetadata" } } } } } } } } } + "401": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + /v1/billing/server/customer-tokens/{tokenId}/revoke: + post: + security: [{ ServerSecretKey: [] }] + operationId: revokeCustomerAccessToken + tags: [Authoritative Entitlements] + description: | + Revoke a token immediately. Revocation is one row update — the practical advantage of an + opaque credential over a signed one is that there is nothing to wait out — and takes + effect on the next presentation regardless of remaining lifetime. The revocation is + audited. + + A token belonging to another tenant matches no row and is reported as not found rather + than forbidden, so a caller cannot probe for the existence of another tenant's tokens. + parameters: + - { in: path, name: tokenId, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [revocationReason] + properties: + revocationReason: + type: string + enum: [customer_signed_out, identity_changed, operator_revoked, customer_deleted, key_rotated, suspected_compromise, superseded_by_new_token] + responses: + "200": { description: The revoked token's metadata., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/CustomerAccessTokenMetadata" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + /v1/sdk/billing/restores: + post: + security: [] + operationId: submitSDKRestore + tags: [Authoritative Entitlements] + description: | + Report a completed native restore and the observations it produced. The public SDK key + proves which Environment is asking and nothing more: the request names no customer, and + `billingCustomerId` is dropped unconditionally on this surface. Identity is resolved + server-side from validated store lineage, never from anything a client asserts. + + The body carries **observation submission ids**, not provider transaction references. A + Google purchase-token digest is computable by anyone holding the token, so accepting + caller-supplied digests would let a caller attach someone else's input to its own + restore. + + `202` and the body already carries the honest current answer, which is what a caller + polls against. `restored` is never reported until an accepted snapshot reflects it. + parameters: + - { in: header, name: Mosaic-SDK-Key, required: true, schema: { type: string }, description: Public SDK key identifying the Environment and Application. It proves which Environment is asking and can never select a customer. } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/RestoreRequestRecord" } } } } + responses: + "202": { description: The restore was recorded and the chain is running., content: { application/json: { schema: { $ref: "#/components/schemas/RestoreResultRecord" } } } } + "401": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + "429": { $ref: "#/components/responses/Error" } + /v1/sdk/billing/restores/{restoreId}: + get: + security: [] + operationId: getSDKRestore + tags: [Authoritative Entitlements] + description: | + Poll one restore. The record carries two axes that are never merged: Mosaic's `outcome` + and the native `providerOutcome`. A native restore that succeeded while Mosaic is still + validating is `providerOutcome: completed` with `outcome: validation_pending`, which is + the honest answer and the reason the two axes exist. + + Restore ids are 128-bit and server-minted. The read is Environment-scoped rather than + customer-scoped, because a restore may have no customer yet. + parameters: + - { in: path, name: restoreId, required: true, schema: { type: string } } + - { in: header, name: Mosaic-SDK-Key, required: true, schema: { type: string } } + responses: + "200": { description: The restore record., content: { application/json: { schema: { $ref: "#/components/schemas/RestoreResultRecord" } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/billing/server/restores: + post: + security: [{ ServerSecretKey: [] }] + operationId: submitServerRestore + tags: [Authoritative Entitlements] + description: | + The trusted equivalent of the SDK restore submission. This surface may name a + `billingCustomerId`, because the caller is the application backend that already + authenticated the user. + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/RestoreRequestRecord" } } } } + responses: + "202": { description: The restore was recorded and the chain is running., content: { application/json: { schema: { $ref: "#/components/schemas/RestoreResultRecord" } } } } + "401": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + /v1/billing/server/restores/{restoreId}: + get: + security: [{ ServerSecretKey: [] }] + operationId: getServerRestore + tags: [Authoritative Entitlements] + parameters: [{ in: path, name: restoreId, required: true, schema: { type: string } }] + responses: + "200": { description: The restore record., content: { application/json: { schema: { $ref: "#/components/schemas/RestoreResultRecord" } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/billing/identity/customers: + post: + security: [{ ServerSecretKey: [] }] + operationId: identifyBillingCustomer + tags: [Billing Identity] + description: | + Create-or-get the Billing Customer for one of your users. This is one of exactly two ways + a Billing Customer comes into existence (plan section 5a); the other is a validated + purchase fact that needs somewhere to attach. SDK initialization and installation + registration create nothing, which is what keeps Mosaic clear of the duplicate-customer + trap that client-anchored systems fall into. + + The body accepts exactly one field, so a caller cannot smuggle an installation + identifier, a Project, or an Environment into the creation path: the tenant comes from + the authenticated secret key. Answers 201 when a customer was created and 200 when an + existing one was returned. + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/IdentifyBillingCustomerRequest" } } } } + responses: + "200": { description: The existing Billing Customer., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingIdentityCustomer" } } } } } } + "201": { description: A Billing Customer was created., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingIdentityCustomer" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + /v1/billing/identity/customers/{customerId}/aliases: + get: + security: [{ ServerSecretKey: [] }] + operationId: listBillingCustomerAliases + tags: [Billing Identity] + description: | + A customer's alias history. No response in this family carries an alias value or an alias + digest: a digest is still a stable per-person identifier and nothing on a server surface + needs one. + parameters: [{ in: path, name: customerId, required: true, schema: { type: string } }] + responses: + "200": { description: Aliases., content: { application/json: { schema: { type: object, properties: { data: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/BillingCustomerAlias" } } } } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + post: + security: [{ ServerSecretKey: [] }] + operationId: attachBillingCustomerAlias + tags: [Billing Identity] + description: | + Attach an application-user alias to an existing customer. Login attaches; it never merges + (plan section 5a rule 3). When the alias already resolves to a different customer the + result is 409 `identity_conflict`: an identity conflict is opened, the named customer is + frozen, and an operator resolves it. Neither candidate is granted anything automatically + (OD-10). + parameters: [{ in: path, name: customerId, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/AttachBillingCustomerAliasRequest" } } } } + responses: + "201": { description: The attached alias., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingCustomerAlias" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + /v1/billing/identity/aliases/{aliasId}/revoke: + post: + security: [{ ServerSecretKey: [] }] + operationId: revokeBillingCustomerAlias + tags: [Billing Identity] + description: | + End-date an alias. The alias is the person-to-purchase link and therefore the erasable + personal data in Mosaic Billing; the purchase evidence it pointed at is exempt and + survives (see the privacy guide). Audited. + parameters: [{ in: path, name: aliasId, required: true, schema: { type: string } }] + responses: + "204": { description: The alias was revoked. } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/billing/identity/customers/{customerId}/sync-requests: + post: + security: [{ ServerSecretKey: [] }] + operationId: requestBillingCustomerSync + tags: [Billing Identity] + description: | + Schedule a projection for one customer. Deliberately not a read: the answer is "this has + been queued", and a caller that needs the result reads the entitlement surfaces once the + snapshot version moves. Triggers coalesce onto the customer scope, so a burst of requests + produces one projection rather than a job storm. + parameters: [{ in: path, name: customerId, required: true, schema: { type: string } }] + responses: + "202": { description: A projection was queued., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingSyncRequest" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/billing/identity/conflicts: + get: + security: [{ ServerSecretKey: [] }] + operationId: listBillingIdentityConflicts + tags: [Billing Identity] + description: | + Open identity conflicts awaiting operator resolution. A conflict freezes its disputed + subject and grants neither candidate anything (OD-10). There is deliberately no automatic + merge: automatic merge stays an ADR checkpoint, not something a heuristic reaches on its + own. + parameters: [{ in: query, name: status, schema: { type: string, enum: [open, resolved] } }] + responses: + "200": { description: Identity conflicts., content: { application/json: { schema: { type: object, properties: { data: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/BillingIdentityConflict" } } } } } } } } } + "401": { $ref: "#/components/responses/Error" } + /v1/billing/identity/conflicts/{conflictId}: + get: + security: [{ ServerSecretKey: [] }] + operationId: getBillingIdentityConflict + tags: [Billing Identity] + description: | + One conflict with the disputed Purchase Lineage, where the conflict is lineage-scoped. + The disputed alias *type* is reported; the disputed alias digest is not rendered under + any scope. + parameters: [{ in: path, name: conflictId, required: true, schema: { type: string } }] + responses: + "200": { description: The conflict., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingIdentityConflictDetail" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/billing/server/customers/{customerId}: + get: + security: [{ ServerSecretKey: [] }] + operationId: getBillingCustomer + tags: [Authoritative Entitlements] + description: | + Read one Billing Customer directly. The response carries no alias values: aliases are + stored as SHA-256 digests and the digest is never a read-side field. + + `identified` distinguishes a customer an application backend has named from one anchored + only to a purchase — the distinction an operator needs first when a customer list looks + larger than the user base. + parameters: + - { in: path, name: customerId, required: true, schema: { type: string } } + responses: + "200": { description: The Billing Customer., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingCustomer" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/billing/server/customers/{customerId}/entitlements: + get: + security: [{ ServerSecretKey: [] }] + operationId: getCustomerEntitlementSnapshot + tags: [Authoritative Entitlements] + description: | + The customer's current Customer Entitlement Snapshot, as the contract record. The read is + audited: an operator credential reading a named customer's entitlement state is exactly + the access a later investigation needs to be able to reconstruct. + parameters: + - { in: path, name: customerId, required: true, schema: { type: string } } + - { in: query, name: environmentId, required: false, schema: { type: string }, description: Defaults to the Environment the secret key belongs to. Any other value is 403. } + responses: + "200": { description: The current snapshot., content: { application/json: { schema: { $ref: "#/components/schemas/CustomerEntitlementSnapshotRecord" } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/billing/server/customers/{customerId}/entitlement-checks: + post: + security: [{ ServerSecretKey: [] }] + operationId: checkCustomerEntitlements + tags: [Authoritative Entitlements] + description: | + The focused multi-key access question, answered as the contract's entitlementCheckResult. + + The answer is never a bare boolean. Every requested key carries a state, a primary + explanation, whether its end is known, and the contributing source count; the result + carries the snapshot version, rule version, and asOf instant it was derived from. A + caller that acts on the answer can say afterwards exactly which committed state it acted + on, which a boolean makes impossible. + + This is the only surface on which `unavailable` is admissible for an Entitlement, and it + means Mosaic could not answer — not that the customer lacks access. Billing being + disabled for the Project is answered here with 200 and every key `unavailable`, rather + than with an HTTP error, so a caller handles one shape. + parameters: + - { in: path, name: customerId, required: true, schema: { type: string } } + - { in: query, name: environmentId, required: false, schema: { type: string } } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/EntitlementCheckRequestRecord" } } } } + responses: + "200": { description: The check result., content: { application/json: { schema: { $ref: "#/components/schemas/EntitlementCheckResultRecord" } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "406": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + /v1/billing/server/customers/{customerId}/subscriptions: + get: + security: [{ ServerSecretKey: [] }] + operationId: listCustomerSubscriptions + tags: [Authoritative Entitlements] + description: | + The customer's projected Subscription Instances with their current four-axis state. + Keyset-paginated; the cursor is opaque and carries one value. + parameters: + - { in: path, name: customerId, required: true, schema: { type: string } } + - { in: query, name: environmentId, required: false, schema: { type: string } } + - { $ref: "#/components/parameters/Limit" } + - { $ref: "#/components/parameters/Cursor" } + responses: + "200": { description: Projected subscriptions., content: { application/json: { schema: { type: object, properties: { data: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/SubscriptionSummary" } }, nextCursor: { type: string } } } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/billing/server/subscriptions/{instanceId}: + get: + security: [{ ServerSecretKey: [] }] + operationId: getSubscriptionSnapshot + tags: [Authoritative Entitlements] + description: | + One Subscription Instance's current projected state as the contract's + subscriptionSnapshot record: four state axes, every provider-derived effective + timestamp, and a checksum over the canonical serialization. No provider status string is + admissible anywhere in the record. + parameters: + - { in: path, name: instanceId, required: true, schema: { type: string } } + responses: + "200": { description: The subscription snapshot., content: { application/json: { schema: { $ref: "#/components/schemas/SubscriptionSnapshotRecord" } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/billing/server/subscriptions/{instanceId}/timeline: + get: + security: [{ ServerSecretKey: [] }] + operationId: listSubscriptionTimeline + tags: [Authoritative Entitlements] + description: | + The append-only explanation history for one Subscription Instance. Entries restate what a + provider said and when it took effect; detail passes the same ledger safety guard as the + 9A ledger, so no provider payload fragment can appear here. + parameters: + - { in: path, name: instanceId, required: true, schema: { type: string } } + - { $ref: "#/components/parameters/Limit" } + - { $ref: "#/components/parameters/Cursor" } + responses: + "200": { description: Timeline entries., content: { application/json: { schema: { type: object, properties: { data: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/SubscriptionTimelineEntry" } }, nextCursor: { type: string } } } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } /v1/projects/{projectId}/billing/settings: get: operationId: getBillingSettings @@ -1875,17 +2344,765 @@ paths: responses: { "200": { description: Billing Ledger Entries., content: { application/json: { schema: { type: object, properties: { data: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/BillingLedgerEntry" } }, nextCursor: { type: string } } } } } } } }, "403": { $ref: "#/components/responses/Error" }, "404": { $ref: "#/components/responses/Error" } } /v1/projects/{projectId}/environments/{environmentId}/billing/quarantine: get: - operationId: listBillingQuarantine - tags: [Billing Operations] - description: Inputs and facts that cannot safely proceed. - parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/EnvironmentID" }, { $ref: "#/components/parameters/BillingCursor" }, { $ref: "#/components/parameters/Limit" }, { in: query, name: status, schema: { type: string, enum: [open, retrying, closed_after_success, closed_superseded] } }, { in: query, name: reasonCode, schema: { type: string } }, { $ref: "#/components/parameters/BillingProviderFilter" }] - responses: { "200": { description: Quarantine Records., content: { application/json: { schema: { type: object, properties: { data: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/QuarantineRecord" } }, nextCursor: { type: string } } } } } } } }, "403": { $ref: "#/components/responses/Error" }, "404": { $ref: "#/components/responses/Error" } } - /v1/projects/{projectId}/environments/{environmentId}/billing/health: + operationId: listBillingQuarantine + tags: [Billing Operations] + description: Inputs and facts that cannot safely proceed. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/EnvironmentID" }, { $ref: "#/components/parameters/BillingCursor" }, { $ref: "#/components/parameters/Limit" }, { in: query, name: status, schema: { type: string, enum: [open, retrying, closed_after_success, closed_superseded] } }, { in: query, name: reasonCode, schema: { type: string } }, { $ref: "#/components/parameters/BillingProviderFilter" }] + responses: { "200": { description: Quarantine Records., content: { application/json: { schema: { type: object, properties: { data: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/QuarantineRecord" } }, nextCursor: { type: string } } } } } } } }, "403": { $ref: "#/components/responses/Error" }, "404": { $ref: "#/components/responses/Error" } } + /v1/projects/{projectId}/environments/{environmentId}/billing/health: + get: + operationId: getBillingHealth + tags: [Billing Operations] + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/EnvironmentID" }] + responses: { "200": { description: Billing health summary., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingHealth" } } } } } }, "403": { $ref: "#/components/responses/Error" }, "404": { $ref: "#/components/responses/Error" } } + /v1/projects/{projectId}/environments/{environmentId}/billing/projection-health: + get: + operationId: getBillingProjectionHealth + tags: [Billing Operations] + description: | + Phase 9B projection health. A sibling of billing health rather than a field on it: billing + health answers whether Mosaic can still turn store notifications into facts, while this + answers whether the authoritative answer Mosaic gives about a customer's access is still + current. Every value is a count or a timestamp; nothing here can carry a customer value, + an alias digest, a provider token, or a secret. Owner or admin only. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/EnvironmentID" }] + responses: { "200": { description: Projection health summary., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingProjectionHealth" } } } } } }, "401": { $ref: "#/components/responses/Error" }, "403": { $ref: "#/components/responses/Error" }, "404": { $ref: "#/components/responses/Error" }, "503": { $ref: "#/components/responses/Error" } } + /v1/projects/{projectId}/environments/{environmentId}/billing/projection-replays: + post: + operationId: createBillingProjectionReplay + tags: [Billing Operations] + description: | + Recompute committed entitlement state from the immutable facts and report what moved. + Replay is the operational expression of principle 2: a projection is derived state, so a + corrupt checkpoint, a promoted rule version, or a mapping repair is answered by + recomputing rather than by patching what was derived. It reuses the ordinary projection + command, so replayed state goes through the same lock, compare-and-swap, and atomic + commit as live projection, and prior snapshots are never deleted. + + The request must be **bounded** — one subscription instance, one customer, or a fact + window. There is deliberately no "replay everything" member: an unbounded replay is a + migration, and bulk migration tooling is out of Phase 9B. + + `projectionRuleVersion` selects the semantics. A version this build does not derive under + is refused with 422 rather than recomputed under the active engine and labelled with the + requested number, because a checksum produced by the wrong engine is indistinguishable + from a genuine determinism result. + + Provider asymmetry, stated rather than hidden: Apple replay is input-sourced, because a + stored Apple payload re-validates to the same transaction. Google replay is fact-sourced, + because Google validation re-queries live provider state and a re-query today does not + reproduce what the provider said last month. Owner or admin only. Audited. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/EnvironmentID" }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreateProjectionReplayRequest" } } } } + responses: + "200": { description: The replay ran., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/ProjectionReplayResult" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/customers: + get: + operationId: listBillingCustomers + tags: [Billing Customers] + security: [{ BrowserSession: [] }] + description: | + The Environment's Billing Customers, newest first. Owner or admin only, enforced + server-side: this is the most sensitive read surface Mosaic has, because it names who + bought what. + + `identified` and `purchaseAnchored` are separate booleans rather than one state, because + the interesting customers are the ones where they disagree — a purchase-anchored customer + who never identified is real revenue with no person attached, and an identified customer + with no purchase is a person with no revenue. + + The Environment filter admits a customer holding a pointer or a lineage here, and + additionally a customer holding a lineage in no Environment at all: a customer created by + a trusted identify and not yet party to any purchase belongs to the Project and to no + Environment, and hiding it everywhere would make a just-created customer invisible. + + No alias value and no alias digest appears anywhere in the response. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/EnvironmentID" } + - { in: query, name: status, schema: { type: string, enum: [active, frozen, anonymized, absorbed] } } + - { in: query, name: identified, description: Restrict to identified or to purchase-anchored-only customers., schema: { type: boolean } } + - { in: query, name: conflictedOnly, description: Only customers party to an open identity conflict., schema: { type: boolean } } + - { $ref: "#/components/parameters/Limit" } + - { $ref: "#/components/parameters/Cursor" } + responses: + "200": + description: Billing Customers. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: { type: array, items: { $ref: "#/components/schemas/BillingCustomerSummary" } } + nextCursor: { type: string } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/customer-lookups: + post: + operationId: lookupBillingCustomer + tags: [Billing Customers] + security: [{ BrowserSession: [] }] + description: | + Read-only typed-identifier search. It resolves at most one Billing Customer and is + structurally incapable of creating one: the read model behind it declares no writer at + all. That distinction matters — the trusted identify endpoint is create-or-get, and using + it as a search would mint one Billing Customer per mistyped support query. + + `application_user_id` resolves through the active alias resolution; + `installation_id` resolves through association evidence, because an installation + identifier is evidence and never an anchor and therefore has no alias resolution to read. + The submitted value is digested server-side and is never stored, never logged, and never + echoed — which is also why this is a POST with a body rather than a GET with a query + string that would reach access logs, proxy logs, and browser history. + + A miss answers `200` with `found: false` rather than `404`: "no customer holds this + identifier" is a true answer to a support question. Rate limited. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/EnvironmentID" }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/BillingCustomerLookupRequest" } + responses: + "200": { description: The lookup result., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingCustomerLookupResult" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + "429": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}: + get: + operationId: getOperatorBillingCustomer + tags: [Billing Customers] + security: [{ BrowserSession: [] }] + description: | + One Billing Customer with everything the customer page shows: lifecycle, the Environment's + current snapshot version and as-of instant, aliases as protected representations, purchase + lineages, subscriptions, one-time purchases, identity conflicts, the current entitlement + entries with their sources, and projection status. + + It is one read rather than eight so the page describes one instant. `currentSnapshot` is + absent — not empty — when the customer has never been projected in this Environment: "no + answer yet" and "no entitlements" are different states and stay different. + + Aliases carry `aliasId`, `aliasType`, authority, and validity dates. There is no value + field and no digest field: the alias id is the protected representation, and an alias + digest is still a stable per-person identifier. Owner or admin only. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/EnvironmentID" } + - { $ref: "#/components/parameters/BillingCustomerID" } + responses: + "200": { description: The Billing Customer., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingCustomerDetail" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}/entitlements: + get: + operationId: getBillingCustomerEntitlementSnapshot + tags: [Billing Customers] + security: [{ BrowserSession: [] }] + description: | + The customer's current committed Customer Entitlement Snapshot in this Environment, read + through the same repository the trusted-server access API reads through — so the dashboard + and an application backend see one answer derived once. + + `snapshotVersion` is the per-customer monotonic cache-monotonicity key. The snapshot + checksum is deliberately not on this surface: it is a determinism control the replay + surface compares, and an operator reading it can only mistake it for a state. + Owner or admin only. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/EnvironmentID" } + - { $ref: "#/components/parameters/BillingCustomerID" } + responses: + "200": + description: The current snapshot. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + snapshot: { $ref: "#/components/schemas/BillingEntitlementSnapshot" } + projectionStatus: { $ref: "#/components/schemas/BillingProjectionStatus" } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}/subscriptions: + get: + operationId: listBillingCustomerSubscriptions + tags: [Billing Customers] + security: [{ BrowserSession: [] }] + description: The customer's projected Subscription Instances in this Environment, keyset-paginated. Owner or admin only. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/EnvironmentID" } + - { $ref: "#/components/parameters/BillingCustomerID" } + - { $ref: "#/components/parameters/Limit" } + - { $ref: "#/components/parameters/Cursor" } + responses: + "200": + description: Subscriptions. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: { type: array, items: { $ref: "#/components/schemas/BillingSubscriptionSnapshot" } } + nextCursor: { type: string } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/customers/{customerId}/sync-requests: + post: + operationId: createBillingCustomerSyncRequest + tags: [Billing Customers] + security: [{ BrowserSession: [] }] + description: | + Enqueue a recomputation of the customer's committed entitlement aggregate. It reaches the + same enqueue the trusted-server surface does, so an operator's "sync now" and a backend's + produce one job on one queue rather than two answers, and it computes nothing itself. + + It is deliberately not a restore: a restore needs a device to ask its store for purchases, + which no operator can do on a customer's behalf, and a control claiming to would report a + native outcome nobody produced. `202` — queued, not done. Rate limited. Audited. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/EnvironmentID" } + - { $ref: "#/components/parameters/BillingCustomerID" } + responses: + "202": { description: The projection was queued., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/OperatorBillingSyncRequest" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "429": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/subscriptions/{instanceId}: + get: + operationId: getBillingSubscription + tags: [Billing Customers] + security: [{ BrowserSession: [] }] + description: | + One projected Subscription Instance. An instance belonging to another Environment is + reported as absent rather than forbidden: a staging URL that happens to name a production + instance must not confirm that the instance exists. Owner or admin only. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/EnvironmentID" } + - { $ref: "#/components/parameters/SubscriptionInstanceID" } + responses: + "200": { description: The Subscription Instance., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingSubscriptionSnapshot" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/subscriptions/{instanceId}/timeline: + get: + operationId: listBillingSubscriptionTimeline + tags: [Billing Customers] + security: [{ BrowserSession: [] }] + description: | + The append-only explanation history for one Subscription Instance, newest first. `detail` + passes through the ledger guard function, which is what keeps a provider token or a raw + payload fragment out of an explanation. Owner or admin only. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/EnvironmentID" } + - { $ref: "#/components/parameters/SubscriptionInstanceID" } + - { $ref: "#/components/parameters/Limit" } + - { $ref: "#/components/parameters/Cursor" } + responses: + "200": + description: Timeline entries. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: { type: array, items: { $ref: "#/components/schemas/BillingTimelineEntry" } } + nextCursor: { type: string } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/restore-jobs: + get: + operationId: listBillingRestoreJobs + tags: [Billing Customers] + security: [{ BrowserSession: [] }] + description: | + Restore and sync jobs in this Environment, newest first. Mosaic's `outcome` and the native + `providerOutcome` are separate axes and are never merged: a completed native restore whose + facts have not reached a snapshot is not restored access. Owner or admin only. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/EnvironmentID" } + - { in: query, name: billingCustomerId, schema: { type: string } } + - { $ref: "#/components/parameters/Limit" } + - { $ref: "#/components/parameters/Cursor" } + responses: + "200": + description: Restore jobs. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: { type: array, items: { $ref: "#/components/schemas/BillingRestoreJob" } } + nextCursor: { type: string } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/restore-jobs/{restoreId}: + get: + operationId: getBillingRestoreJob + tags: [Billing Customers] + security: [{ BrowserSession: [] }] + description: One restore or sync job's status. Owner or admin only. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/EnvironmentID" } + - { $ref: "#/components/parameters/RestoreJobID" } + responses: + "200": { description: The restore job., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingRestoreJob" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/identity-conflicts: + get: + operationId: listOperatorBillingIdentityConflicts + tags: [Billing Identity Conflicts] + security: [{ BrowserSession: [] }] + description: | + Identity conflicts awaiting or holding an operator decision. They are Project-scoped, not + Environment-scoped, and the route says so: a conflict is a dispute about who a person is, + and identity in Mosaic belongs to the Project. Filing it under an Environment would imply + it could be resolved differently in staging than in production. + + A conflict carries the disputed alias *family* and never the disputed alias digest. + Owner or admin only. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { in: query, name: status, schema: { type: string, enum: [open, resolved] } } + responses: + "200": + description: Identity conflicts. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: { type: array, items: { $ref: "#/components/schemas/OperatorBillingIdentityConflict" } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/identity-conflicts/{conflictId}: + get: + operationId: getOperatorBillingIdentityConflict + tags: [Billing Identity Conflicts] + security: [{ BrowserSession: [] }] + description: One conflict with the purchase lineage it disputes, which is what an operator needs before choosing a resolution. Owner or admin only. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/IdentityConflictID" } + responses: + "200": { description: The conflict., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/OperatorBillingIdentityConflictDetail" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/identity-conflicts/{conflictId}/resolution: + post: + operationId: resolveBillingIdentityConflict + tags: [Billing Identity Conflicts] + security: [{ BrowserSession: [] }] + description: | + Apply an operator's decision and release the freeze. + + The three actions are the whole vocabulary. `keep_existing` awards the disputed subject to + the incumbent; `reassign_to_candidate` awards it to the challenger the evidence proposed; + `operator_split` awards it to neither — the operator has decided these are two people and + the disputed link is removed rather than moved. There is deliberately no automatic-merge + action: automatic merge remains an ADR checkpoint, not something a control reaches on its + own. + + `reason` is required. Every action moves committed access for at least one paying + customer, and the audit entry an investigation reads months later is worth nothing without + the why. The reason is written to the conflict and to the audit event. + + Resolving unfreezes the disputed subject and reprojects **both** candidates, not only the + assigned one: whichever customer loses the lineage is the one holding a committed snapshot + that still grants it. `assignedBillingCustomerId` is optional and, when present, must name + the party the action already implies — a resolution surface accepting an arbitrary + customer would be an unaudited "give this purchase to anyone" control. + + Owner or admin only. Audited. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { $ref: "#/components/parameters/IdentityConflictID" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ResolveIdentityConflictRequest" } + responses: + "200": { description: The resolved conflict., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/OperatorBillingIdentityConflict" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + "429": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/grant-versions: + get: + operationId: listProductEntitlementGrantVersions + tags: [Billing Grant Versions] + description: | + The recorded history of what one Product grants, newest version first. + + Grant versions exist because the projection engine selects a version by the **purchase's + own effective time**, not by "now". Reading the history is therefore how an operator + answers "why is this customer entitled?" for a purchase made under a rule that has since + been replaced. Any member of the owning organization may read it: an operator who can see + a customer's entitlements but not the rule that produced them has been given a fact with + no explanation. + + Intervals are half-open `[effectiveStart, effectiveEnd)` and abut exactly, so every + instant is covered by at most one version per Entitlement. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { in: query, name: productId, required: true, schema: { type: string, maxLength: 128 } } + - { in: query, name: entitlementId, schema: { type: string, maxLength: 128 }, description: Restricts the history to one (Product, Entitlement) pair. } + - { in: query, name: currentOnly, schema: { type: boolean }, description: Return only the open-ended version in force now. } + - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 200 } } + responses: + "200": { description: Grant versions., content: { application/json: { schema: { type: object, properties: { data: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/ProductEntitlementGrantVersion" } } } } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + post: + operationId: publishProductEntitlementGrantVersion + tags: [Billing Grant Versions] + description: | + Publish a new immutable grant version. This is the **only** call on this surface that + changes what a Product grants, and it is deliberately separate from the impact preview: + previewing is free and repeatable, publishing requires an actor, a `reason`, and an owner + or admin role, and writes an audit event in the same transaction as the version. + + **Prospective by default (OD-8).** `effectiveStart` must be now or later. Publishing a + change that silently applies to yesterday is the failure grant versioning exists to + prevent, so backdating requires `retroactive: true` — and a retroactive version is then + held to the additive-superset rule: it may add Entitlements or widen access policy, never + remove or narrow either. Retroactive change is the one operation that can take access + from a customer who did nothing wrong, so the only retroactive shape Mosaic accepts is the + one that cannot. + + **Replacement, not edit.** Publishing closes the current version at exactly the new + version's start, so the two intervals abut: never a gap (which would strand purchases made + inside it with no applicable grant) and never an overlap (which would make the applicable + version a function of row order). A proposal that reaches into an interval that has + already closed is refused with `grant_interval_overlap`. + + **The change is applied, not just recorded.** The same transaction enqueues a reprojection + for every Billing Customer whose current snapshot cites the Product. A grant version that + is recorded but never applied is worse than one never published: every surface would + report the new meaning while every customer kept the old access, and nothing would retry. + + `grantsInPaused` is accepted only so it can be refused: Google's pause never grants access + and the policy is not overridable. `grantsInBillingRetry` contradicts both providers' + documentation and requires an organization **owner**. + parameters: [{ $ref: "#/components/parameters/ProjectID" }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PublishGrantVersionRequest" } } } } + responses: + "201": { description: The published grant version., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/ProductEntitlementGrantVersion" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/grant-versions/impact-preview: + post: + operationId: previewProductEntitlementGrantImpact + tags: [Billing Grant Versions] + description: | + Report what a proposed grant change would touch. **Nothing is written, including the audit + trail**: an operator comparing three candidate policies before choosing one has not made + three changes, and an audit trail suggesting otherwise would be worse than none. + + It takes the same body as the publish call, so an operator previews exactly what they are + about to publish rather than something adjacent to it. + + Every count is from *current* committed state — the snapshot each customer's pointer names, + not the whole snapshot history. Counting superseded snapshots would report a much larger + number that no operator action can change, which is the worst possible combination for a + confirmation dialog. `impactedEntitlements` and `impactedProducts` count everything a + reprojection of the affected customers would re-derive, not only the pair being changed, + because that is the actual blast radius of the confirmation being given. + + A preview never refuses a retroactive narrowing; it reports `additiveSuperset: false` with + the `narrowingCode`, so the operator sees *why* the publish would be rejected before they + attempt it. It is gated on the publish permission even though it writes nothing: the counts + describe how much damage the change could do, and an actor who may not make the change has + no reason to be shown the blast radius. + parameters: [{ $ref: "#/components/parameters/ProjectID" }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PublishGrantVersionRequest" } } } } + responses: + "200": { description: The impact preview., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/GrantVersionImpact" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/grant-versions/{grantVersionId}: + get: + operationId: getProductEntitlementGrantVersion + tags: [Billing Grant Versions] + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { in: path, name: grantVersionId, required: true, schema: { type: string } }] + responses: + "200": { description: The grant version., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/ProductEntitlementGrantVersion" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + patch: + operationId: updateProductEntitlementGrantVersion + tags: [Billing Grant Versions] + description: | + Always answers `409 grant_version_immutable`. The route exists so the answer is a sentence + an integrator can act on rather than a bare 405 that reads like a routing mistake. + + A published grant version is a historical fact: the projection engine selects it by the + purchase's own effective time, so rewriting its policy or moving its boundary would change + what a customer was entitled to at a moment that has already passed. The database enforces + this too — the only permitted update to a grant version row is closing an open interval + once — so no future repository method or migration can bypass it either. `PUT` and + `DELETE` answer identically. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { in: path, name: grantVersionId, required: true, schema: { type: string } }] + responses: + "409": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/environments/{environmentId}/billing/webhook-destinations: + get: + operationId: listWebhookDestinations + tags: [Billing Webhooks] + description: Application webhook destinations for one Environment. No response ever carries a signing secret. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/EnvironmentID" }] + responses: + "200": { description: Destinations., content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: "#/components/schemas/WebhookDestination" } } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + post: + operationId: createWebhookDestination + tags: [Billing Webhooks] + description: | + Register a destination and mint its first signing secret. **The secret is in this + response and in no other**: Mosaic keeps only the sealed form, so there is no read that + returns it again. + + The URL is screened under the ADR-0024 SSRF policy at registration and again on every + delivery attempt: HTTPS only, no redirects, and RFC1918, loopback, link-local (including + the cloud metadata address), CGNAT, IPv6 unique-local, IPv4-mapped, and unspecified + addresses refused against the *resolved* address. Operators running Mosaic and their + application backend on one private network enable + `MOSAIC_BILLING_WEBHOOK_ALLOW_PRIVATE_DESTINATIONS`; there is deliberately no + per-destination override. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/EnvironmentID" }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreateWebhookDestinationRequest" } } } } + responses: + "201": { description: The destination and its one-time signing secret., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookDestinationWithSecret" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/webhook-destinations/{destinationId}: + get: + operationId: getWebhookDestination + tags: [Billing Webhooks] + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/WebhookDestinationID" }] + responses: + "200": { description: The destination., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookDestination" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + patch: + operationId: updateWebhookDestination + tags: [Billing Webhooks] + description: Change the URL, the enabled event types, or the description. A changed URL is re-screened. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/WebhookDestinationID" }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UpdateWebhookDestinationRequest" } } } } + responses: + "200": { description: The updated destination., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookDestination" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + delete: + operationId: deleteWebhookDestination + tags: [Billing Webhooks] + description: Remove a destination that has no delivery history. A destination with attempts is disabled rather than deleted, because the attempts are append-only evidence. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/WebhookDestinationID" }] + responses: + "204": { description: The destination was removed. } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/status: + post: + operationId: setWebhookDestinationStatus + tags: [Billing Webhooks] + description: | + Pause, resume, or disable a destination. Mosaic also disables a destination automatically + after a bounded run of consecutive exhausted deliveries; `autoDisableReason` distinguishes + that from an operator's own action, so "did Mosaic disable this, or did a person?" is + answerable without reading prose. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/WebhookDestinationID" }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/SetWebhookDestinationStatusRequest" } } } } + responses: + "200": { description: The updated destination., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookDestination" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/secrets: + get: + operationId: listWebhookSigningSecrets + tags: [Billing Webhooks] + description: Signing-secret metadata. The secret values are sealed and are never returned. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/WebhookDestinationID" }] + responses: + "200": { description: Secret metadata., content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: "#/components/schemas/WebhookSigningSecretMetadata" } } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/secrets/rotate: + post: + operationId: rotateWebhookSigningSecret + tags: [Billing Webhooks] + description: | + Mint a new signing secret and start an overlap window. During the overlap every delivery + carries one `v1` element per honoured secret, so a receiver that has adopted the new + secret and one that has not both verify. `previousSecretHonoredUntil` is when the + superseded secret stops signing, which is the whole information an integrator needs to + schedule their own side. Without an overlap, rotating means a simultaneous change on both + sides or a period of rejected deliveries, and an operator can achieve neither. + + The new secret is displayed once, here. Audited. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/WebhookDestinationID" }] + responses: + "201": { description: The new secret and the overlap deadline., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookDestinationWithSecret" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/webhook-destinations/{destinationId}/secrets/{secretId}/retire: + post: + operationId: retireWebhookSigningSecret + tags: [Billing Webhooks] + description: | + End a secret's overlap immediately — the response to a suspected compromise. Retiring the + last secret that can still sign is refused: a destination with no signing secret would + send unsigned deliveries, and an unsigned entitlement webhook is an unauthenticated + instruction to grant access. Audited. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/WebhookDestinationID" }, { in: path, name: secretId, required: true, schema: { type: string } }] + responses: + "200": { description: The retired secret's metadata., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookSigningSecretMetadata" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/webhook-deliveries: + get: + operationId: listWebhookDeliveries + tags: [Billing Webhooks] + description: One delivery per (event, destination). A retry is a further attempt on the same delivery, never a new logical event, which is what keeps the event id stable for consumer deduplication. + parameters: + - { $ref: "#/components/parameters/ProjectID" } + - { in: query, name: environmentId, schema: { type: string } } + - { in: query, name: eventId, schema: { type: string } } + - { in: query, name: destinationId, schema: { type: string } } + - { in: query, name: status, schema: { type: string, enum: [pending, succeeded, failed, exhausted, skipped] } } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Deliveries., content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: "#/components/schemas/WebhookDelivery" } } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/webhook-deliveries/{deliveryId}: get: - operationId: getBillingHealth - tags: [Billing Operations] - parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/EnvironmentID" }] - responses: { "200": { description: Billing health summary., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/BillingHealth" } } } } } }, "403": { $ref: "#/components/responses/Error" }, "404": { $ref: "#/components/responses/Error" } } + operationId: getWebhookDelivery + tags: [Billing Webhooks] + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/WebhookDeliveryID" }] + responses: + "200": { description: The delivery., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookDelivery" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/webhook-deliveries/{deliveryId}/attempts: + get: + operationId: listWebhookDeliveryAttempts + tags: [Billing Webhooks] + description: | + Append-only attempt history. It is API-facing only — it is never sent to a destination and + a destination never sees another tenant's attempts — so it carries no URL and no secret. + `responseExcerpt` is a bounded, control-character-free excerpt of the destination's own + response, kept only so an integrator can see why their endpoint refused; it is never + parsed and never influences Mosaic state. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/WebhookDeliveryID" }] + responses: + "200": { description: Attempts., content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: "#/components/schemas/WebhookDeliveryAttempt" } } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/projects/{projectId}/billing/webhook-deliveries/{deliveryId}/replay: + post: + operationId: replayWebhookDelivery + tags: [Billing Webhooks] + description: | + Re-queue an exhausted or failed delivery. It reuses the same delivery row and the same + event id, appending a further attempt: a receiver deduplicating on event id still sees + the change once. `202` — the delivery is queued, not performed; the worker owns the + attempt. Audited. + parameters: [{ $ref: "#/components/parameters/ProjectID" }, { $ref: "#/components/parameters/WebhookDeliveryID" }] + responses: + "202": { description: The delivery was queued., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookDelivery" } } } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } /v1/projects/{projectId}/environments/{environmentId}/billing/reconciliation-runs: get: operationId: listReconciliationRuns @@ -1964,6 +3181,15 @@ components: type: http scheme: bearer bearerFormat: Mosaic secret server key + CustomerAccessToken: + type: http + scheme: bearer + bearerFormat: Mosaic Customer Access Token (mcat_ + 43 base64url characters) + description: | + Opaque bearer credential scoping a read to one Billing Customer. It has no claims and + no parseable structure; Mosaic stores only its SHA-256 digest. It is presented together + with the public SDK key in Mosaic-SDK-Key, and the two must agree on Project and + Environment. parameters: Cursor: in: query @@ -1977,7 +3203,13 @@ components: OrganizationID: { in: path, name: organizationId, required: true, schema: { type: string } } ActorID: { in: path, name: actorId, required: true, schema: { type: string } } ProjectID: { in: path, name: projectId, required: true, schema: { type: string } } + WebhookDestinationID: { in: path, name: destinationId, required: true, schema: { type: string } } + WebhookDeliveryID: { in: path, name: deliveryId, required: true, schema: { type: string } } EnvironmentID: { in: path, name: environmentId, required: true, schema: { type: string } } + BillingCustomerID: { in: path, name: customerId, required: true, schema: { type: string } } + SubscriptionInstanceID: { in: path, name: instanceId, required: true, schema: { type: string } } + RestoreJobID: { in: path, name: restoreId, required: true, schema: { type: string } } + IdentityConflictID: { in: path, name: conflictId, required: true, schema: { type: string } } ApplicationID: { in: path, name: applicationId, required: true, schema: { type: string } } ProviderConnectionID: { in: path, name: connectionId, required: true, schema: { type: string } } ProviderMappingID: { in: path, name: mappingId, required: true, schema: { type: string } } @@ -2572,6 +3804,355 @@ components: windowStart: { type: string, format: date-time } windowEnd: { type: string, format: date-time } validatorVersion: { type: integer, minimum: 1 } + CustomerAccessTokenIssuanceRequest: + type: object + additionalProperties: false + required: [customerAccessTokenContractVersion, recordType, payload] + properties: + customerAccessTokenContractVersion: { type: string, const: "1" } + recordType: { type: string, const: customerAccessTokenIssuanceRequest } + payload: + type: object + additionalProperties: false + required: [billingCustomerId, audience, scopes, correlationId] + properties: + billingCustomerId: { type: string, minLength: 1, maxLength: 128 } + audience: { type: string, enum: [sdk_sync, server_check], description: "Only sdk_sync is issued in Phase 9B." } + scopes: { type: array, minItems: 1, maxItems: 3, uniqueItems: true, items: { type: string, enum: [entitlements.read, entitlements.sync, restore.request] } } + requestedTtlSeconds: { type: integer, minimum: 60, maximum: 86400, description: "A request, not an instruction. Clamped to the contract maximum." } + correlationId: { type: string, minLength: 1, maxLength: 128 } + CustomerAccessTokenIssuanceResult: + type: object + required: [customerAccessTokenContractVersion, recordType, payload] + properties: + customerAccessTokenContractVersion: { type: string, const: "1" } + recordType: { type: string, const: customerAccessTokenIssuanceResult } + payload: + type: object + required: [token, metadata, correlationId] + properties: + token: + type: string + pattern: "^mcat_[A-Za-z0-9_-]{43}$" + description: The opaque credential. Returned exactly once; Mosaic stores only its SHA-256 digest and can never reproduce it. + metadata: { $ref: "#/components/schemas/CustomerAccessTokenMetadata" } + correlationId: { type: string } + CustomerAccessTokenMetadata: + type: object + description: Everything Mosaic knows about a token, which is deliberately everything the token itself does not carry. + required: [tokenId, projectId, environmentId, billingCustomerId, audience, scopes, issuer, issuedAt, expiresAt, status, tokenPrefix, digestAlgorithm] + properties: + tokenId: { type: string, description: "A stable public handle, safe to log and to name in an audit event. It is not the token and cannot be presented as one." } + projectId: { type: string } + environmentId: { type: string } + billingCustomerId: { type: string } + audience: { type: string, enum: [sdk_sync, server_check] } + scopes: { type: array, items: { type: string, enum: [entitlements.read, entitlements.sync, restore.request] } } + issuer: { type: string } + issuedAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + status: { type: string, enum: [active, expired, revoked] } + revokedAt: { type: string, format: date-time } + revocationReason: { type: string, enum: [customer_signed_out, identity_changed, operator_revoked, customer_deleted, key_rotated, suspected_compromise, superseded_by_new_token] } + lastUsedAt: { type: string, format: date-time } + tokenPrefix: { type: string, const: "mcat_" } + digestAlgorithm: { type: string, const: sha256 } + EntitlementSyncRequestRecord: + type: object + additionalProperties: false + required: [authoritativeEntitlementContractVersion, recordType, payload] + properties: + authoritativeEntitlementContractVersion: { type: string, const: "1" } + recordType: { type: string, const: entitlementSyncRequest } + payload: + type: object + additionalProperties: false + required: [supportedAuthoritativeEntitlementContracts, correlationId] + properties: + billingCustomerId: { type: string, description: A hint only. Verified against the token; a mismatch is refused. } + knownSnapshotVersion: { type: integer, minimum: 1 } + entityTag: { type: string } + supportedAuthoritativeEntitlementContracts: { type: array, minItems: 1, maxItems: 8, uniqueItems: true, items: { type: string, enum: ["1"] } } + requestedEntitlementKeys: { type: array, minItems: 1, maxItems: 64, uniqueItems: true, items: { type: string, pattern: "^[a-z][a-z0-9_.-]*$" } } + correlationId: { type: string, minLength: 1, maxLength: 128 } + RestoreRequestRecord: + type: object + additionalProperties: false + description: | + The Authoritative Entitlement Contract v1 restoreRequest envelope. The normative shape is + protocol/schema/authoritative-entitlement/v1/restore.schema.json; this declaration exists + so the operation has a resolvable request schema and is deliberately not a second source + of truth for the contract. + required: [authoritativeEntitlementContractVersion, recordType, payload] + properties: + authoritativeEntitlementContractVersion: { type: string, const: "1" } + recordType: { type: string, const: restoreRequest } + payload: + type: object + additionalProperties: false + required: [storePlatform, providerOutcome, correlationId] + properties: + storePlatform: { type: string, enum: [apple_app_store, google_play] } + providerOutcome: { type: string, enum: [completed, no_purchases_found, cancelled, failed, unsupported, not_attempted], description: What the native restore itself did. Recorded verbatim and never merged into Mosaic's own outcome. } + observationSubmissionIds: { type: array, items: { type: string, minLength: 1, maxLength: 128 }, description: Observations already submitted for this restore. No provider transaction reference travels on this surface. } + billingCustomerId: { type: string, maxLength: 128, description: Honoured on the trusted surface only. The SDK surface drops it. } + correlationId: { type: string, minLength: 1, maxLength: 128 } + RestoreResultRecord: + type: object + description: | + The Authoritative Entitlement Contract v1 restoreResult envelope. Normative shape: + protocol/schema/authoritative-entitlement/v1/restore.schema.json. + required: [authoritativeEntitlementContractVersion, recordType, payload] + properties: + authoritativeEntitlementContractVersion: { type: string, const: "1" } + recordType: { type: string, const: restoreResult } + payload: + type: object + properties: + restoreId: { type: string } + billingCustomerId: { type: string, description: Absent while identity is unresolved, which is a first-class restore outcome rather than an error. } + projectId: { type: string } + environmentId: { type: string } + storePlatform: { type: string, enum: [apple_app_store, google_play] } + outcome: { type: string, enum: [restored, no_additional_purchases, validation_pending, identity_unresolved, product_unresolved, provider_unavailable, failed], description: Mosaic's own answer. `restored` is only ever written together with the accepted snapshot version that demonstrates it. } + providerOutcome: { type: string, enum: [completed, no_purchases_found, cancelled, failed, unsupported, not_attempted] } + snapshotVersion: { type: integer, description: The accepted snapshot that reflects the restore. Present only for `restored`, and it is the evidence for that outcome. } + observedTransactionCount: { type: integer, description: Inputs actually linked, never the number the caller claimed. } + pendingValidationCount: { type: integer } + uncertainty: + type: object + properties: + reason: { type: string, enum: [none, provider_unavailable, missing_fact, identity_unresolved, product_unresolved, conflicting_facts, projection_failed, stale_validation, unsupported_provider_state] } + since: { type: string, format: date-time } + diagnosticCode: { type: string } + requestedAt: { type: string, format: date-time } + completedAt: { type: string, format: date-time } + correlationId: { type: string } + CustomerEntitlementSnapshotRecord: + type: object + description: | + The Authoritative Entitlement Contract v1 customerEntitlementSnapshot envelope. The + normative shape is protocol/schema/authoritative-entitlement/v1/snapshot.schema.json; + this declaration exists so the operation has a resolvable response schema and is + deliberately not a second source of truth for the contract. + required: [authoritativeEntitlementContractVersion, recordType, payload] + properties: + authoritativeEntitlementContractVersion: { type: string, const: "1" } + recordType: { type: string, enum: [customerEntitlementSnapshot, snapshotUnchanged] } + payload: + type: object + properties: + snapshotId: { type: string } + billingCustomerId: { type: string } + projectId: { type: string } + environmentId: { type: string } + snapshotVersion: { type: integer, description: Monotonic per (customer, Environment). The sole cache-monotonicity key. A no-change projection does not advance it. } + previousSnapshotVersion: { type: integer } + projectionRuleVersion: { type: integer } + issuedAt: { type: string, format: date-time } + asOf: { type: string, format: date-time } + refreshAfter: { type: string, format: date-time } + validUntil: { type: string, format: date-time } + staleGraceSeconds: { type: integer, minimum: 0, maximum: 2592000 } + entityTag: { type: string } + contentDigest: { type: string, pattern: "^sha256:[a-f0-9]{64}$" } + entries: { type: array, items: { $ref: "#/components/schemas/EntitlementEntry" } } + sources: { type: array, items: { $ref: "#/components/schemas/EntitlementSourceSummary" } } + projectionStatus: { $ref: "#/components/schemas/ProjectionStatus" } + changeReason: { type: string } + correlationId: { type: string } + EntitlementEntry: + type: object + description: Product and Subscription Instance identity are deliberately absent; they live on the contributing source summaries, which sourceIds resolves against. + required: [entitlementId, entitlementKey, state, endKnown, sourceIds, sourceCount, primaryExplanation] + properties: + entitlementId: { type: string } + entitlementKey: { type: string, pattern: "^[a-z][a-z0-9_.-]*$" } + state: { type: string, enum: [active, inactive, unknown], description: "`unavailable` is deliberately absent: it describes Mosaic's ability to answer, never the customer's access." } + effectiveStart: { type: string, format: date-time } + effectiveEnd: { type: string, format: date-time } + endKnown: { type: boolean, description: False means an active source has an uncertain end, and a reader must not display or enforce any expiry. } + sourceIds: { type: array, items: { type: string } } + sourceCount: { type: integer } + primaryExplanation: { $ref: "#/components/schemas/PrimaryExplanation" } + uncertainty: { $ref: "#/components/schemas/Uncertainty" } + EntitlementSourceSummary: + type: object + required: [sourceId, sourceType, mosaicProductId, grantVersionId, sourceSnapshotId, start, sourceState, uncertainty, explanationCode, isTestSource] + properties: + sourceId: { type: string, description: Derived from (purchase lineage, Mosaic Product, grant version) and never from a transaction fact, so a second fact for one purchase cannot double-grant. } + sourceType: { type: string, enum: [active_subscription, trial, grace_period, billing_retry, one_time_non_consumable, family_shared] } + subscriptionInstanceId: { type: string } + oneTimePurchaseInstanceId: { type: string } + mosaicProductId: { type: string } + grantVersionId: { type: string } + sourceSnapshotId: { type: string } + storePlatform: { type: string, enum: [apple_app_store, google_play] } + start: { type: string, format: date-time } + end: { type: string, format: date-time, description: Absent means this source has no finite end Mosaic can state. } + sourceState: { type: string, enum: [granting, not_granting, unknown] } + uncertainty: { $ref: "#/components/schemas/Uncertainty" } + explanationCode: { type: string } + isTestSource: { type: boolean, description: True for an Apple sandbox transaction or a Google Play license-tester purchase, so a test-derived grant is never mistaken for a paid one. } + ProjectionStatus: + type: object + required: [state, lastProjectedAt] + properties: + state: { type: string, enum: [current, pending, stale, degraded, failed] } + lastProjectedAt: { type: string, format: date-time } + pendingFactCount: { type: integer } + diagnosticCode: { type: string } + Uncertainty: + type: object + description: Why a state is not definitive. reason `none` means the state is definitive, and a definitive state carries no since instant. + required: [reason] + properties: + reason: { type: string, enum: [none, provider_unavailable, missing_fact, identity_unresolved, product_unresolved, conflicting_facts, projection_failed, stale_validation, unsupported_provider_state] } + since: { type: string, format: date-time } + expectedResolution: { type: string, enum: [automatic_retry, next_provider_notification, next_projection_run, operator_action, customer_action, none_expected] } + diagnosticCode: { type: string } + PrimaryExplanation: + type: object + required: [code] + properties: + code: { type: string, description: A member of the contract's closed explanation vocabulary. A reader may render its own copy for a code but must never invent one. } + sourceId: { type: string } + safeSummary: { type: string, maxLength: 240 } + EntitlementCheckRequestRecord: + type: object + additionalProperties: false + required: [authoritativeEntitlementContractVersion, recordType, payload] + properties: + authoritativeEntitlementContractVersion: { type: string, const: "1" } + recordType: { type: string, const: entitlementCheckRequest } + payload: + type: object + additionalProperties: false + required: [billingCustomerId, entitlementKeys, supportedAuthoritativeEntitlementContracts, correlationId] + properties: + billingCustomerId: { type: string } + entitlementKeys: { type: array, minItems: 1, maxItems: 64, uniqueItems: true, items: { type: string, pattern: "^[a-z][a-z0-9_.-]*$" } } + expectedSnapshotVersion: { type: integer, minimum: 1 } + supportedAuthoritativeEntitlementContracts: { type: array, minItems: 1, items: { type: string, enum: ["1"] } } + correlationId: { type: string, minLength: 1, maxLength: 128 } + EntitlementCheckResultRecord: + type: object + required: [authoritativeEntitlementContractVersion, recordType, payload] + properties: + authoritativeEntitlementContractVersion: { type: string, const: "1" } + recordType: { type: string, const: entitlementCheckResult } + payload: + type: object + required: [billingCustomerId, projectId, environmentId, issuedAt, results, correlationId] + properties: + billingCustomerId: { type: string } + projectId: { type: string } + environmentId: { type: string } + snapshotVersion: { type: integer, description: Absent only when no snapshot could be read at all, in which case every result is unavailable. } + projectionRuleVersion: { type: integer } + issuedAt: { type: string, format: date-time } + asOf: { type: string, format: date-time } + results: + type: array + minItems: 1 + items: + type: object + required: [entitlementKey, state, sourceCount, endKnown, primaryExplanation] + properties: + entitlementKey: { type: string } + state: { type: string, enum: [active, inactive, unknown, unavailable], description: "This is the only place `unavailable` is admissible for an Entitlement: it says Mosaic could not answer, not that the customer lacks access." } + effectiveStart: { type: string, format: date-time } + effectiveEnd: { type: string, format: date-time } + endKnown: { type: boolean } + sourceCount: { type: integer } + sourceIds: { type: array, items: { type: string } } + primaryExplanation: { $ref: "#/components/schemas/PrimaryExplanation" } + uncertainty: { $ref: "#/components/schemas/Uncertainty" } + isTestSource: { type: boolean } + projectionStatus: { $ref: "#/components/schemas/ProjectionStatus" } + correlationId: { type: string } + SubscriptionSnapshotRecord: + type: object + description: | + The Authoritative Entitlement Contract v1 subscriptionSnapshot envelope. The normative + shape is protocol/schema/authoritative-entitlement/v1/subscription.schema.json. + required: [authoritativeEntitlementContractVersion, recordType, payload] + properties: + authoritativeEntitlementContractVersion: { type: string, const: "1" } + recordType: { type: string, const: subscriptionSnapshot } + payload: + type: object + properties: + subscriptionSnapshotId: { type: string } + subscriptionInstanceId: { type: string } + purchaseLineageId: { type: string } + billingCustomerId: { type: string } + projectId: { type: string } + environmentId: { type: string } + projectionVersion: { type: integer } + projectionRuleVersion: { type: integer } + computedAt: { type: string, format: date-time } + asOf: { type: string, format: date-time } + storePlatform: { type: string, enum: [apple_app_store, google_play] } + mosaicProductId: { type: string } + priorMosaicProductId: { type: string } + accessState: { type: string, enum: [active, inactive, unknown, unavailable] } + lifecycleState: { type: string, enum: [trialing, active, grace_period, billing_retry, paused, expired, revoked, refunded, superseded, unknown] } + renewalIntent: { type: string, enum: [auto_renew_enabled, auto_renew_disabled, provider_managed, paused, unknown] } + billingState: { type: string, enum: [current, retrying, grace, failed, refunded, revoked, unknown] } + uncertainty: { $ref: "#/components/schemas/Uncertainty" } + periodStart: { type: string, format: date-time } + periodEnd: { type: string, format: date-time } + gracePeriodEnd: { type: string, format: date-time } + billingRetryStart: { type: string, format: date-time } + pauseEffectiveAt: { type: string, format: date-time } + pauseResumeAt: { type: string, format: date-time } + cancellationEffectiveAt: { type: string, format: date-time, description: When the provider recorded the cancellation. It changes renewalIntent; it does not end access. } + expirationEffectiveAt: { type: string, format: date-time } + revocationEffectiveAt: { type: string, format: date-time } + refundEffectiveAt: { type: string, format: date-time } + supersededBySubscriptionInstanceId: { type: string } + isTestSource: { type: boolean } + sourceFactCount: { type: integer } + checksum: { type: string, pattern: "^sha256:[a-f0-9]{64}$" } + changeReason: { type: string } + explanationCode: { type: string } + correlationId: { type: string } + SubscriptionSummary: + type: object + properties: + subscriptionInstanceId: { type: string } + subscriptionSnapshotId: { type: string } + projectionVersion: { type: integer } + accessState: { type: string, enum: [active, inactive, unknown] } + lifecycleState: { type: string } + renewalIntent: { type: string } + billingState: { type: string } + isTestSource: { type: boolean } + asOf: { type: string, format: date-time } + SubscriptionTimelineEntry: + type: object + properties: + timelineEntryId: { type: string } + entryType: { type: string } + effectiveAt: { type: string, format: date-time } + observedAt: { type: string, format: date-time } + explanationCode: { type: string } + mosaicProductId: { type: string } + detail: { type: object, additionalProperties: { type: string } } + BillingCustomer: + type: object + description: "A Billing Customer as the trusted-server API reports it. Alias values never appear: aliases are stored as digests." + properties: + billingCustomerId: { type: string } + projectId: { type: string } + status: { type: string, enum: [active, frozen, anonymized, absorbed] } + diagnosticsStatus: { type: string, enum: [none, identity_conflict, projection_stale, projection_failed] } + currentProjectionVersion: { type: integer } + lastProjectedAt: { type: string, format: date-time } + identified: { type: boolean, description: True when an application backend has named this customer. False means purchase-anchored but never identified. } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } BillingSettings: type: object description: Per-Project Mosaic Billing configuration. Off by default. @@ -2600,6 +4181,555 @@ components: factCount: { type: integer, format: int64 } lastFactRecordedAt: { type: string, format: date-time } lastReconciliationAt: { type: string, format: date-time } + IdentifyBillingCustomerRequest: + type: object + additionalProperties: false + required: [applicationUserId] + properties: + applicationUserId: { type: string, minLength: 1, maxLength: 512, description: Your own identifier for the user. Stored only as a domain-separated SHA-256 digest. } + AttachBillingCustomerAliasRequest: + type: object + additionalProperties: false + required: [applicationUserId] + properties: + applicationUserId: { type: string, minLength: 1, maxLength: 512 } + BillingIdentityCustomer: + type: object + properties: + billingCustomerId: { type: string } + projectId: { type: string } + status: { type: string, enum: [active, frozen, anonymized, absorbed] } + diagnosticsStatus: { type: string } + currentProjectionVersion: { type: integer, format: int64 } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + lastProjectedAt: { type: string, format: date-time } + BillingCustomerAlias: + type: object + description: An alias record. The alias digest is never a member of this shape. + properties: + aliasId: { type: string } + billingCustomerId: { type: string } + aliasType: { type: string, enum: [application_user_id, installation_id, apple_app_account_token, google_obfuscated_account_id] } + sourceAuthority: { type: string } + verificationStatus: { type: string } + effectiveStart: { type: string, format: date-time } + effectiveEnd: { type: string, format: date-time } + createdAt: { type: string, format: date-time } + BillingIdentityConflict: + type: object + properties: + conflictId: { type: string } + projectId: { type: string } + scope: { type: string, enum: [lineage, alias] } + status: { type: string, enum: [open, resolved] } + firstCustomerId: { type: string, description: The incumbent. } + secondCustomerId: { type: string, description: The challenger. } + purchaseLineageId: { type: string, description: Present only for a lineage-scoped conflict. } + aliasType: { type: string, description: Present only for an alias-scoped conflict. The digest is never reported. } + diagnosticCode: { type: string } + openedAt: { type: string, format: date-time } + resolvedAt: { type: string, format: date-time } + resolutionAction: { type: string, enum: [assigned_first, assigned_second, detached_both] } + BillingIdentityConflictDetail: + type: object + properties: + conflict: { $ref: "#/components/schemas/BillingIdentityConflict" } + lineage: + type: object + description: The disputed Purchase Lineage, for a lineage-scoped conflict. + properties: + purchaseLineageId: { type: string } + environmentId: { type: string } + provider: { type: string, enum: [app_store, google_play] } + storeEnvironment: { type: string, enum: [sandbox, production] } + lineageType: { type: string, enum: [subscription, one_time] } + projectionFrozen: { type: boolean } + diagnosticStatus: { type: string } + BillingSyncRequest: + type: object + properties: + billingCustomerId: { type: string } + projectId: { type: string } + environmentId: { type: string } + projectionScopeKey: { type: string } + triggerKind: { type: string, enum: [manual_sync] } + requestedAt: { type: string, format: date-time } + status: { type: string, enum: [queued] } + CreateProjectionReplayRequest: + type: object + additionalProperties: false + description: Must be bounded by at least one of subscriptionInstanceId, billingCustomerId, or a complete window. + properties: + subscriptionInstanceId: { type: string, maxLength: 128 } + billingCustomerId: { type: string, maxLength: 128 } + windowStart: { type: string, format: date-time, description: Bounds on facts rather than on lineage creation - a scope is in scope when it holds a fact whose effective or recorded time falls inside the window. } + windowEnd: { type: string, format: date-time } + projectionRuleVersion: { type: integer, minimum: 0, maximum: 1000000, description: Zero selects the active version. A version this build does not derive under is refused rather than approximated. } + limit: { type: integer, minimum: 0, maximum: 500 } + ProjectionReplayResult: + type: object + properties: + projectionRuleVersion: { type: integer } + scopesReplayed: { type: integer } + scopesChanged: { type: integer } + outcomes: + type: array + items: + type: object + properties: + projectionScopeKey: { type: string } + comparison: { type: string, enum: [unchanged, changed], description: The whole point of a replay - proving determinism, or naming exactly what a rule change moved. } + materialized: { type: boolean, description: Whether a new snapshot was written. Materialization is changes-only, so an unchanged scope writes nothing. } + changedEntitlementIds: { type: array, items: { type: string } } + ProductEntitlementGrantVersion: + type: object + description: One immutable Product-to-Entitlement grant interval. Half-open - [effectiveStart, effectiveEnd). + properties: + grantVersionId: { type: string } + projectId: { type: string } + productId: { type: string } + productKey: { type: string } + entitlementId: { type: string } + entitlementKey: { type: string } + version: { type: integer, minimum: 1, description: Monotonic per (Product, Entitlement) pair. } + grantPolicyVersion: { type: integer, description: The access-policy vocabulary this version was written under, so a later vocabulary cannot silently reinterpret what an operator approved. } + effectiveStart: { type: string, format: date-time } + effectiveEnd: { type: string, format: date-time, description: Absent on the current version. Set once, to the successor's start, and never moved. } + current: { type: boolean } + retroactive: { type: boolean, description: Derived from effectiveStart preceding createdAt - a version whose meaning began before it existed was backdated. } + supportedPurchaseTypes: { type: array, items: { type: string, enum: [auto_renewable_subscription, non_consumable] } } + accessPolicy: { $ref: "#/components/schemas/GrantAccessPolicy" } + createdAt: { type: string, format: date-time } + createdByActorId: { type: string } + reason: { type: string } + GrantAccessPolicy: + type: object + description: Which subscription states this grant treats as granting access (plan policy version 1). + properties: + grantsInActive: { type: boolean } + grantsInTrial: { type: boolean } + grantsInGrace: { type: boolean, description: Both providers grant access during grace, so the default is true. } + grantsInBillingRetry: { type: boolean, description: Contradicts both providers' documentation. Closed by default and settable only by an organization owner. } + grantsInPaused: { type: boolean, description: Always false. Google's pause never grants access and the policy is not overridable. } + grantsInOneTimeOwnership: { type: boolean } + PublishGrantVersionRequest: + type: object + required: [productId, entitlementId, effectiveStart] + description: | + A proposed grant version. The same body is accepted by the impact preview and by publish, + so what is previewed is what is published. Omitted policy flags take their documented + defaults rather than false - a missing grantsInActive defaulting to false would publish a + version granting nothing during an active subscription, the opposite of what an operator + leaving the field out meant. + properties: + productId: { type: string, maxLength: 128 } + entitlementId: { type: string, maxLength: 128 } + effectiveStart: { type: string, format: date-time, description: Now or later unless retroactive is true. } + retroactive: { type: boolean, default: false, description: Backdate the change. Held to the additive-superset rule and confined to the currently open interval. } + supportedPurchaseTypes: { type: array, maxItems: 4, items: { type: string, enum: [auto_renewable_subscription, non_consumable] } } + grantsInActive: { type: boolean, default: true } + grantsInTrial: { type: boolean, default: true } + grantsInGrace: { type: boolean, default: true } + grantsInBillingRetry: { type: boolean, default: false } + grantsInPaused: { type: boolean, default: false, description: Accepted only so it can be refused with 422. } + grantsInOneTimeOwnership: { type: boolean, default: true } + reason: { type: string, maxLength: 512, description: Required to publish. It is what an investigation reads months later, when the operator who published is gone and the only remaining question is why access changed. } + GrantVersionImpact: + type: object + description: Read-only counts of what a proposed grant change would touch, from current committed state. + properties: + productId: { type: string } + entitlementId: { type: string } + impactedProducts: { type: integer, description: Products a reprojection of the affected customers would re-derive. } + impactedEntitlements: { type: integer, description: Entitlements a reprojection of the affected customers would re-derive. } + impactedCustomers: { type: integer, description: Billing Customers whose current snapshot cites this Product. } + impactedActiveSources: { type: integer, description: How many of those citations are currently granting access - the number that answers how many people could lose access. } + impactedLineages: { type: integer, description: Purchase lineages resolved to this Product, including ones with no customer resolved yet. } + retroactive: { type: boolean } + additiveSuperset: { type: boolean, description: Whether the proposal passes the widen-only rule. Meaningful for a retroactive change; a preview reports it, the publish enforces it. } + narrowingCode: { type: string, description: The first narrowing found when additiveSuperset is false, in the projection engine's own vocabulary. } + currentVersion: { $ref: "#/components/schemas/ProductEntitlementGrantVersion" } + observedAt: { type: string, format: date-time } + WebhookDestination: + type: object + properties: + id: { type: string } + projectId: { type: string } + environmentId: { type: string } + url: { type: string, description: HTTPS only. Screened against the resolved address at registration and again on every delivery attempt. } + status: { type: string, enum: [active, paused, disabled] } + eventTypes: { type: array, items: { type: string, enum: [customer.entitlements.changed] }, description: Phase 9B emits one event type. The other nine the contract declares are reserved names. } + description: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + secretLastRotatedAt: { type: string, format: date-time } + disabledReason: { type: string, description: What an operator wrote when they disabled it by hand. } + consecutiveFailureCount: { type: integer, description: Exhausted deliveries in a row. Any success resets it, so a recovering outage never trips the auto-disable policy. } + autoDisabledAt: { type: string, format: date-time } + autoDisableReason: { type: string, enum: [consecutive_exhausted_deliveries, destination_refused], description: Set only by the automatic path, so a Mosaic disable is distinguishable from a human one. } + WebhookDestinationWithSecret: + type: object + properties: + destination: { $ref: "#/components/schemas/WebhookDestination" } + secret: { type: string, description: Displayed exactly once. Mosaic keeps only the sealed form, so no read returns it again. } + secretId: { type: string } + previousSecretHonoredUntil: { type: string, format: date-time, description: Set by a rotation. Until this instant the superseded secret still signs. Absent on a create, which has no previous secret. } + WebhookSigningSecretMetadata: + type: object + properties: + id: { type: string } + status: { type: string, enum: [active, retired] } + createdAt: { type: string, format: date-time } + retiredAt: { type: string, format: date-time } + honoredUntil: { type: string, format: date-time, description: A retired secret keeps signing until this instant, which is the rotation overlap window. } + CreateWebhookDestinationRequest: + type: object + additionalProperties: false + required: [url] + properties: + url: { type: string, minLength: 1, maxLength: 2048 } + eventTypes: { type: array, maxItems: 10, items: { type: string, enum: [customer.entitlements.changed] } } + description: { type: string, maxLength: 500 } + UpdateWebhookDestinationRequest: + type: object + additionalProperties: false + properties: + url: { type: string, minLength: 1, maxLength: 2048 } + eventTypes: { type: array, maxItems: 10, items: { type: string, enum: [customer.entitlements.changed] } } + description: { type: string, maxLength: 500 } + SetWebhookDestinationStatusRequest: + type: object + additionalProperties: false + required: [status] + properties: + status: { type: string, enum: [active, paused, disabled] } + reason: { type: string, maxLength: 128 } + WebhookDelivery: + type: object + properties: + id: { type: string } + projectId: { type: string } + environmentId: { type: string } + eventId: { type: string, description: Stable across every attempt and every manual replay. It is the consumer's deduplication key. } + destinationId: { type: string } + status: { type: string, enum: [pending, succeeded, failed, exhausted, skipped] } + skippedReason: { type: string, enum: [destination_disabled, event_type_not_enabled, destination_deleted, tenant_suspended] } + attemptCount: { type: integer } + maxAttempts: { type: integer } + nextAttemptAt: { type: string, format: date-time } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + completedAt: { type: string, format: date-time } + WebhookDeliveryAttempt: + type: object + description: | + One recorded try. Mirrors the Billing State Webhook Contract v1 webhookDeliveryAttempt + record; the normative shape is protocol/schema/billing-state-webhook/v1/delivery.schema.json. + properties: + id: { type: string } + deliveryId: { type: string } + eventId: { type: string } + destinationId: { type: string } + attempt: { type: integer, minimum: 1, maximum: 32 } + maxAttempts: { type: integer, minimum: 1, maximum: 32 } + outcome: { type: string, enum: [delivered, retryable_failure, permanent_failure, exhausted, skipped] } + responseStatusCode: { type: integer, minimum: 100, maximum: 599 } + responseExcerpt: { type: string, maxLength: 240, description: Bounded and control-character-free. Never parsed, never influences Mosaic state. } + errorCode: { type: string } + latencyMs: { type: integer } + skippedReason: { type: string, enum: [destination_disabled, event_type_not_enabled, destination_deleted, tenant_suspended] } + requestedAt: { type: string, format: date-time } + respondedAt: { type: string, format: date-time } + nextAttemptAt: { type: string, format: date-time } + BillingProjectionHealth: + type: object + properties: + environmentId: { type: string } + billingEnabled: { type: boolean } + activeProjectionRuleVersion: { type: integer, description: The rule version new projections are computed under. } + projectionRuleVersionCount: { type: integer, description: A count above one with no replay in flight means a promotion was prepared and never run. } + projectionQueueDepth: { type: integer, format: int64 } + projectionOldestQueuedAgeSeconds: { type: number, description: The alerting signal. Depth alone cannot distinguish a busy queue from a stuck one. } + projectionFailedJobs: { type: integer, format: int64 } + projectionFailuresLastHour: { type: integer, format: int64, description: A rate signal the queue depth cannot give, because a scope that fails and requeues forever keeps the depth at one. } + staleCustomers: { type: integer, format: int64, description: Customers whose committed state in this Environment is older than the staleness threshold. } + neverProjectedCustomers: { type: integer, format: int64 } + openIdentityConflicts: { type: integer, format: int64, description: A spike here is a security-relevant signal, not a backlog. } + frozenLineages: { type: integer, format: int64 } + unresolvedLineages: { type: integer, format: int64 } + unknownEntitlementEntries: { type: integer, format: int64, description: Entries on current snapshots stating unknown - how often Mosaic is declining to answer. } + restoreBacklog: { type: integer, format: int64 } + restoreFailedJobs: { type: integer, format: int64 } + webhookDeliveryBacklog: { type: integer, format: int64 } + webhookDeliveriesExhausted: { type: integer, format: int64 } + activeWebhookDestinations: { type: integer, format: int64 } + lastProjectionCommittedAt: { type: string, format: date-time } + observedAt: { type: string, format: date-time } + BillingCustomerSummary: + type: object + description: | + One Billing Customer as the operator list and the detail header report it. It carries no + alias value and no alias digest, because Mosaic exposes neither on any surface. + properties: + billingCustomerId: { type: string } + projectId: { type: string } + environmentId: { type: string } + status: { type: string, enum: [active, frozen, anonymized, absorbed] } + diagnosticsStatus: { type: string, enum: [none, identity_conflict, projection_stale, projection_failed] } + identified: { type: boolean, description: An active application-user alias exists - a person is attached. } + purchaseAnchored: { type: boolean, description: A purchase lineage exists in this Environment - revenue is attached. } + hasOpenIdentityConflict: { type: boolean } + frozenLineageCount: { type: integer } + currentProjectionVersion: { type: integer, format: int64 } + lastProjectedAt: { type: string, format: date-time } + snapshotVersion: { type: integer, format: int64, description: "Absent when the customer has never been projected in this Environment, which is not the same as having no entitlements." } + snapshotUpdatedAt: { type: string, format: date-time } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + BillingCustomerLookupRequest: + type: object + additionalProperties: false + required: [identifierType, identifierValue] + properties: + identifierType: { type: string, enum: [billing_customer_id, application_user_id, installation_id] } + identifierValue: + type: string + minLength: 1 + maxLength: 512 + description: The raw identifier. It is digested server-side and is never stored, never logged, and never echoed back. + BillingCustomerLookupResult: + type: object + properties: + found: { type: boolean } + customer: { $ref: "#/components/schemas/BillingCustomerSummary" } + OperatorBillingCustomerAlias: + type: object + description: | + One accepted external identity bound to a customer, as a protected representation. There + is no value field and no digest field: the alias id identifies the row for a revocation + and reveals nothing about the person, whereas an alias digest is still a stable + per-person identifier. + properties: + aliasId: { type: string } + aliasType: { type: string, enum: [application_user_id, installation_id, apple_app_account_token, google_obfuscated_account_id] } + sourceAuthority: { type: string, enum: [trusted_server, sdk_installation, provider_payload, operator, restore] } + verificationStatus: { type: string, enum: [asserted, verified] } + active: { type: boolean } + effectiveStart: { type: string, format: date-time } + effectiveEnd: { type: string, format: date-time } + BillingPurchaseLineage: + type: object + description: One provider purchase chain. Supersession is an explicit edge; nothing is ever deleted. + properties: + purchaseLineageId: { type: string } + environmentId: { type: string } + provider: { type: string, enum: [app_store, google_play] } + storeEnvironment: { type: string, enum: [sandbox, production] } + lineageType: { type: string, enum: [subscription, one_time] } + projectionFrozen: { type: boolean, description: Set while an identity conflict is open - the projector skips it and the last committed state is preserved. } + diagnosticStatus: { type: string, enum: [none, identity_unresolved, identity_conflict, product_unresolved] } + supersededByLineageId: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + BillingOneTimePurchase: + type: object + description: Validated ownership of a non-consumable. Consumables are excluded from Mosaic Billing. + properties: + oneTimePurchaseInstanceId: { type: string } + purchaseLineageId: { type: string } + provider: { type: string, enum: [app_store, google_play] } + mosaicProductId: { type: string } + providerProductIdentifier: { type: string } + acquiredAt: { type: string, format: date-time } + validityState: { type: string, enum: [owned, refunded, revoked, unknown] } + refundEffectiveAt: { type: string, format: date-time } + revocationEffectiveAt: { type: string, format: date-time } + BillingProjectionStatus: + type: object + properties: + state: { type: string, enum: [current, pending, stale, degraded, failed] } + lastProjectedAt: { type: string, format: date-time } + pendingFactCount: { type: integer } + diagnosticCode: { type: string } + BillingEntitlementSnapshotEntry: + type: object + properties: + entitlementId: { type: string } + entitlementKey: { type: string } + state: { type: string, enum: [active, inactive, unknown], description: unavailable is a read-time service state and is never persisted on a snapshot. } + effectiveStart: { type: string, format: date-time } + effectiveEnd: { type: string, format: date-time } + endKnown: { type: boolean } + sourceCount: { type: integer } + uncertaintyReason: { type: string, enum: [none, provider_unavailable, missing_fact, identity_unresolved, product_unresolved, conflicting_facts, projection_failed, stale_validation, unsupported_provider_state] } + isTestSource: { type: boolean } + explanationCode: { type: string } + sourceIds: { type: array, items: { type: string } } + BillingEntitlementSource: + type: object + description: | + One reason the customer holds, or may hold, an Entitlement. Source identity is + (purchase lineage, Mosaic Product, grant version) and never a fact id, so multiple facts + describing one purchase cannot double-grant. + properties: + sourceId: { type: string } + entitlementId: { type: string } + purchaseLineageId: { type: string } + mosaicProductId: { type: string } + grantVersionId: { type: string } + subscriptionInstanceId: { type: string } + oneTimePurchaseInstanceId: { type: string } + storePlatform: { type: string } + sourceType: { type: string } + sourceState: { type: string } + sourceStart: { type: string, format: date-time } + sourceEnd: { type: string, format: date-time } + endKnown: { type: boolean } + uncertaintyReason: { type: string } + isTestSource: { type: boolean } + explanationCode: { type: string } + BillingEntitlementSnapshot: + type: object + properties: + snapshotId: { type: string } + snapshotVersion: { type: integer, format: int64, description: Per-customer monotonic. It is the sole cache-monotonicity key; a no-change projection does not advance it. } + previousSnapshotVersion: { type: integer, format: int64 } + projectionRuleVersion: { type: integer } + computedAt: { type: string, format: date-time } + asOf: { type: string, format: date-time } + changeReason: { type: string } + entries: { type: array, items: { $ref: "#/components/schemas/BillingEntitlementSnapshotEntry" } } + sources: { type: array, items: { $ref: "#/components/schemas/BillingEntitlementSource" } } + BillingSubscriptionSnapshot: + type: object + description: | + One projected Subscription Instance. Access and lifecycle are separate axes on purpose: + a cancelled subscription keeps access until its validated period end, so cancellation + moves renewal intent and nothing else. + properties: + subscriptionInstanceId: { type: string } + purchaseLineageId: { type: string } + billingCustomerId: { type: string } + environmentId: { type: string } + storePlatform: { type: string } + mosaicProductId: { type: string } + priorMosaicProductId: { type: string } + accessState: { type: string, enum: [active, inactive, unknown], description: unavailable is a read-time service state and is never persisted on a snapshot. } + lifecycleState: { type: string, enum: [trialing, active, grace_period, billing_retry, paused, expired, revoked, refunded, superseded, unknown] } + renewalIntent: { type: string } + billingState: { type: string } + uncertaintyReason: { type: string } + projectionVersion: { type: integer, format: int64 } + projectionRuleVersion: { type: integer } + computedAt: { type: string, format: date-time } + asOf: { type: string, format: date-time } + periodStart: { type: string, format: date-time } + periodEnd: { type: string, format: date-time } + gracePeriodEnd: { type: string, format: date-time } + billingRetryStart: { type: string, format: date-time } + pauseEffectiveAt: { type: string, format: date-time } + pauseResumeAt: { type: string, format: date-time } + cancellationEffectiveAt: { type: string, format: date-time } + expirationEffectiveAt: { type: string, format: date-time } + revocationEffectiveAt: { type: string, format: date-time } + refundEffectiveAt: { type: string, format: date-time } + supersededBySubscriptionInstanceId: { type: string } + isTestSource: { type: boolean } + sourceFactCount: { type: integer } + changeReason: { type: string } + explanationCode: { type: string } + BillingTimelineEntry: + type: object + properties: + timelineEntryId: { type: string } + entryType: { type: string } + effectiveAt: { type: string, format: date-time } + observedAt: { type: string, format: date-time } + subscriptionInstanceId: { type: string } + mosaicProductId: { type: string } + priorMosaicProductId: { type: string } + explanationCode: { type: string } + detail: { type: object, additionalProperties: { type: string }, description: "Passed through the ledger guard function, so no provider token or raw payload fragment can appear here." } + BillingCustomerDetail: + type: object + properties: + customer: { $ref: "#/components/schemas/BillingCustomerSummary" } + aliases: { type: array, items: { $ref: "#/components/schemas/OperatorBillingCustomerAlias" } } + purchaseLineages: { type: array, items: { $ref: "#/components/schemas/BillingPurchaseLineage" } } + subscriptions: { type: array, items: { $ref: "#/components/schemas/BillingSubscriptionSnapshot" } } + oneTimePurchases: { type: array, items: { $ref: "#/components/schemas/BillingOneTimePurchase" } } + identityConflicts: { type: array, items: { $ref: "#/components/schemas/OperatorBillingIdentityConflict" } } + currentSnapshot: { $ref: "#/components/schemas/BillingEntitlementSnapshot" } + projectionStatus: { $ref: "#/components/schemas/BillingProjectionStatus" } + OperatorBillingIdentityConflict: + type: object + description: | + One disputed association held open for operator resolution. Access is granted to neither + candidate while it is open: the disputed subject is frozen and the last committed state is + preserved. The disputed alias digest is never part of this shape. + properties: + conflictId: { type: string } + projectId: { type: string } + scope: { type: string, enum: [lineage, alias] } + status: { type: string, enum: [open, resolved] } + purchaseLineageId: { type: string } + aliasType: { type: string } + firstCustomerId: { type: string, description: The incumbent. } + secondCustomerId: { type: string, description: The challenger the evidence proposed. } + diagnosticCode: { type: string, enum: [multiple_customers_claim_lineage, reassignment_requires_operator_resolution, application_user_alias_claims_two_customers] } + openedAt: { type: string, format: date-time } + resolvedAt: { type: string, format: date-time } + resolutionAction: { type: string, enum: [keep_existing, reassign_to_candidate, operator_split] } + resolutionReason: { type: string } + OperatorBillingIdentityConflictDetail: + type: object + properties: + conflict: { $ref: "#/components/schemas/OperatorBillingIdentityConflict" } + lineage: { $ref: "#/components/schemas/BillingPurchaseLineage" } + ResolveIdentityConflictRequest: + type: object + additionalProperties: false + required: [action, reason] + properties: + action: { type: string, enum: [keep_existing, reassign_to_candidate, operator_split] } + assignedBillingCustomerId: { type: string, maxLength: 128, description: Optional. When present it must name the party the action already implies. } + reason: { type: string, minLength: 1, maxLength: 500, description: Required. Recorded on the conflict and on the audit event. } + OperatorBillingSyncRequest: + type: object + properties: + billingCustomerId: { type: string } + projectId: { type: string } + environmentId: { type: string } + projectionScopeKey: { type: string, description: What the projection queue coalesces on. } + triggerKind: { type: string } + requestedAt: { type: string, format: date-time } + status: { type: string, enum: [queued] } + BillingRestoreJob: + type: object + description: | + One restore or sync job. Mosaic's authoritative `outcome` and the native `providerOutcome` + are separate axes and are never merged: a completed native restore whose facts have not + reached a snapshot is not restored access. `restored` is admissible only together with the + accepted `snapshotVersion` that demonstrates it. + properties: + restoreId: { type: string } + environmentId: { type: string } + billingCustomerId: { type: string, description: Empty until identity resolves - which is exactly the identity_unresolved outcome. } + storePlatform: { type: string, enum: [apple_app_store, google_play] } + status: { type: string, enum: [queued, leased, completed, failed] } + outcome: { type: string, enum: [restored, no_additional_purchases, validation_pending, identity_unresolved, product_unresolved, provider_unavailable, failed] } + providerOutcome: { type: string, enum: [completed, no_purchases_found, cancelled, failed, unsupported, not_attempted] } + uncertaintyReason: { type: string } + observedTransactionCount: { type: integer } + pendingValidationCount: { type: integer } + baselineSnapshotVersion: { type: integer, format: int64 } + snapshotVersion: { type: integer, format: int64 } + attemptCount: { type: integer } + maxAttempts: { type: integer } + requestedAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + completedAt: { type: string, format: date-time } CreateExperimentRequest: type: object additionalProperties: false diff --git a/docs/backend/phase-9b-authoritative-entitlements.md b/docs/backend/phase-9b-authoritative-entitlements.md new file mode 100644 index 00000000..c4d11b38 --- /dev/null +++ b/docs/backend/phase-9b-authoritative-entitlements.md @@ -0,0 +1,982 @@ +# Phase 9B: Authoritative Entitlements and Access Surfaces (Backend) + +Phase 9A proved a provider transaction was authentic and refused to decide anything about +access. Phase 9B is where Mosaic starts deciding: validated Transaction Facts are projected +into subscription state and Customer Entitlement Snapshots, and three surfaces read those +snapshots — the SDK entitlement sync endpoint, the trusted-server entitlement APIs, and (per +owner decision OD-1(b)) an application webhook slice. + +The public contracts are the Authoritative Entitlement Contract v1, the Customer Access Token +Contract v1, and the Billing State Webhook Contract v1, all born `draft` (OD-15). Webhook +signing and destination policy are recorded in ADR-0024. + +## Terminology + +| Term | Meaning | +| --- | --- | +| Billing Customer | Project-scoped identity a purchase attaches to. Created lazily, never by SDK init. | +| Purchase Lineage | Environment-scoped provider purchase chain, keyed on its **root**. | +| Subscription Snapshot | Immutable projected state of one Subscription Instance at one projection version. | +| Customer Entitlement Snapshot | Immutable authoritative state of every Entitlement one customer holds, per Environment. | +| Snapshot Version | Monotonic integer per (customer, Environment). The sole cache-monotonicity key. | +| Entity Tag | Opaque HTTP validator. Equality only; it carries no ordering. | +| Entitlement Source | One reason a customer holds an Entitlement. Identity is (purchase lineage, Mosaic Product, grant version). | +| Customer Access Token | Opaque bearer credential scoping an SDK read to one customer. | + +## Two axes that are never merged + +Mosaic Environment and Store Environment stay separate, exactly as in 9A. Phase 9B adds a +second pair that is equally never merged: + +- **`accessState`** is what the customer may do. It is `active`, `inactive`, `unknown`, or + `unavailable`. +- **`unavailable` is a statement about Mosaic**, not about the customer. Billing disabled, a + projection that has not run, storage that cannot be read — all of these are `unavailable` or + `unknown`, and never `inactive`. Reporting them as `inactive` tells a paying customer they + lost access because Mosaic had a bad minute. + +Every non-definite state carries an `uncertainty` object naming why, and every uncertainty +other than `none` carries the instant it began. + +## Server authentication + +Two credentials exist on these surfaces, and they answer different questions. + +| Credential | Header | Answers | +| --- | --- | --- | +| Secret server key | `Authorization: Bearer sk_…` | Which tenant is calling, and may it act on a named customer? | +| Public SDK key | `Mosaic-SDK-Key` | Which Environment and Application is this client? | +| Customer Access Token | `Authorization: Bearer mcat_…` | **Which customer** is being read? | + +The public SDK key alone can never select a customer. That is the whole reason the sync surface +requires a token as well: a public key ships inside an application binary, and a surface that +let it name a customer would let anyone read anyone's entitlements. + +Trusted APIs derive their Project and Environment entirely from the secret key. No request body +on these surfaces carries a Project or an Environment, so a careless or compromised caller +cannot address a tenant it does not own. + +## Customer Access Tokens + +Per OD-14(a) the token is **opaque**: `mcat_` followed by 43 base64url characters, which is 256 +bits of randomness. It has no claims, no header, no signature, and nothing may be inferred from +it. Mosaic stores only its SHA-256 digest alongside the columns that scope it — Project, +Environment, Billing Customer, audience, scopes — so authorization is a row read rather than a +claims validation, and revocation is one `UPDATE`. + +``` +POST /v1/billing/server/customer-tokens +Authorization: Bearer + +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenIssuanceRequest", + "payload": { + "billingCustomerId": "bcu_…", + "audience": "sdk_sync", + "scopes": ["entitlements.read"], + "requestedTtlSeconds": 3600, + "correlationId": "…" + } +} +``` + +- **Lifetime**: one hour by default, twenty-four hours maximum. A caller may shorten it and can + never lengthen it past the maximum; the schema enforces the same ceiling independently, so a + service bug cannot mint a long-lived credential. +- **Audience**: only `sdk_sync` is issued in Phase 9B. `server_check` is declared by the + contract so adding it later costs no contract version, and refusing to mint it now means no + credential exists for a surface that has not been built. +- **Revocation**: `POST /v1/billing/server/customer-tokens/{tokenId}/revoke` with a closed + `revocationReason`. It takes effect on the next presentation regardless of remaining life. +- **Audit**: issuance and revocation are written in the same transaction as the token row. +- **The value appears exactly once**, in the issuance response. It is never logged, never + stored, never returned again, and no branch of the issuing path can reach a logger with it. + The `tokenId` is a public handle and is what appears in logs, spans, and audit events. + +## SDK entitlement sync + +``` +GET /v1/sdk/billing/entitlements +POST /v1/sdk/billing/entitlements +Authorization: Bearer mcat_… +Mosaic-SDK-Key: +``` + +`POST` carries the contract's `entitlementSyncRequest` so a caller can negotiate contract +versions, state the version it already holds, and narrow the response to specific Entitlement +keys. `GET` is the unconditional form. + +The response is an Access Decision Snapshot: the contract's `customerEntitlementSnapshot` +record, carrying `issuedAt`, `asOf`, `refreshAfter`, `validUntil`, `staleGraceSeconds`, the +Entitlement entries, the source summaries that explain them (each with `isTestSource`), the +projection status, and a correlation id. + +The **response body is the contract's canonical serialization**. An SDK recomputes +`contentDigest` over exactly the bytes it received; producing the body and the digested bytes +by two different code paths would surface a serialization bug to users as cache corruption. +Go's agreement with the other four implementations is pinned by +`packages/test-fixtures/src/entitlement-snapshot-digest-vectors.json`. + +### Conditional requests and freshness + +A snapshot is reported unchanged only when **both** the caller's stated snapshot version and its +entity tag match. The entity tag is an opaque equality token with no ordering; confirming on it +alone would confirm a cache whose monotonicity nobody checked. The version is stated in the +`entitlementSyncRequest` body, so only the POST form can state one. + +**The negotiated POST form always answers `200` with the canonical `snapshotUnchanged` record — +never a bare `304`.** This is the ratified +cross-SDK flow, and the reason is the contract rather than HTTP: a 304 carries no body, so the +freshness window would have to travel in `Mosaic-…` headers that **no frozen schema defines**. +Three SDKs each reading freshness out of undocumented header names is freshness the +Authoritative Entitlement Contract cannot guarantee, and the first platform to mistype one +silently expires a paying customer's cache while the device is demonstrably in contact with the +server. The `snapshotUnchanged` record carries `refreshAfter`, `validUntil`, and +`staleGraceSeconds` inside the schema every SDK already validates: + +```json +{ "authoritativeEntitlementContractVersion": "1", + "recordType": "snapshotUnchanged", + "payload": { "snapshotVersion": 7, "entityTag": "ces.…", + "refreshAfter": "…", "validUntil": "…", "staleGraceSeconds": 86400, + "projectionStatus": { "state": "current", … } } } +``` + +Those three values are recomputed from the instant the request was answered, not echoed back +from whatever the caller last held. That is what "confirming slides freshness" means: a device +that keeps confirming the same version never expires while it is in contact with the server. + +**The `GET` form is not conditional.** It is a plain full-snapshot read: it carries no way to +state a snapshot version, and version equality is a precondition of an unchanged answer, so it +returns `200` and the whole snapshot however the caller frames the request. It accepts no +`If-None-Match` parameter and never answers `304`. + +The freshness window is also set as headers on every response, so an intermediary or an operator +can read it without parsing the body: + +| Header | Meaning | +| --- | --- | +| `ETag` | Strong validator for this representation. | +| `Mosaic-Refresh-After` | After this instant a reader should refresh. The snapshot stays fully valid. | +| `Mosaic-Valid-Until` | Hard end of authoritative validity. | +| `Mosaic-Stale-Grace-Seconds` | Bounded window past `validUntil` in which previously active Entitlements may still be served, clearly marked stale. | + +> **Resolved (defect D-5).** The `GET` form previously advertised a conditional `304` that the +> version precondition made unreachable on every request that could have taken it. The dead +> branch, the `If-None-Match` parameter, and the `304` response are removed from the handler and +> from the OpenAPI document. `knownSnapshotVersion` in the POST body is the one conditional +> mechanism this surface has, and it is the one all three SDKs already use. Making the `GET` +> conditional instead would have meant either dropping the monotonicity precondition for one +> verb or inventing an unratified query parameter; neither is taken unilaterally. +> +> `docs/protocol/authoritative-entitlement-v1.md` still describes conditional `GET` as a +> server-side option. That file is protocol-owned and is flagged for a one-line correction. + +Defaults are one hour to refresh, seven days of validity, and twenty-four hours of bounded +grace (OD-5), configurable per deployment. **The combined horizon — validity plus grace — is +capped at thirty days**, in code as well as in the contract. Past the grace window a reader +reports `unknown`, never `inactive`. + +### States a reader must handle + +| Condition | Answer | +| --- | --- | +| Customer never projected in this Environment | A valid snapshot with no entries, `snapshotId: pending.`, `snapshotVersion: 0`, and `projectionStatus.state: pending`. Not an error, not `inactive`. Version 0 is the sentinel for "nothing committed"; committed snapshots start at 1, so the first real projection is always strictly newer than the placeholder a device cached. | +| Billing disabled for the Project | `unavailable` with `uncertainty.reason: provider_unavailable` and explanation `billing_disabled`. | +| Identity conflict open (OD-10) | Entitlements report `unknown` with `identity_unresolved`; neither candidate customer is granted anything. | +| Product mapping missing | `unknown` with `product_unresolved`. | +| Token expired, revoked, or unknown | `401`, indistinguishably. Telling a caller which half of a guess was right is how a credential gets brute-forced. | +| Token presented with another Environment's SDK key | `403`, and an operator warning line. | + +## Trusted-server entitlement APIs + +All are authenticated by the secret server key and scoped to its Environment. + +| Operation | Route | +| --- | --- | +| Read a customer | `GET /v1/billing/server/customers/{customerId}` | +| Read the current snapshot | `GET /v1/billing/server/customers/{customerId}/entitlements` | +| Multi-key access check | `POST /v1/billing/server/customers/{customerId}/entitlement-checks` | +| List projected subscriptions | `GET /v1/billing/server/customers/{customerId}/subscriptions` | +| Read one subscription snapshot | `GET /v1/billing/server/subscriptions/{instanceId}` | +| Read a subscription timeline | `GET /v1/billing/server/subscriptions/{instanceId}/timeline` | +| Issue / list / revoke tokens | `…/customer-tokens` | + +The check endpoint **never answers with a bare boolean**. Every requested key comes back with a +state, a primary explanation, whether the end is known, the contributing source count, and — at +the result level — the snapshot version, rule version, and `asOf` instant the answer was derived +from. A caller that acts on the answer can say afterwards exactly which committed state it acted +on, which a boolean makes impossible. + +Snapshot reads through this surface are audited: an operator credential reading a named +customer's entitlement state is exactly the access a later investigation needs to reconstruct. + +List endpoints page by keyset. The cursor is opaque and carries one value — the last id of the +previous page — so callers cannot come to depend on its shape. + +## Product-to-Entitlement Grant Versions (WP9) + +What a Product grants is versioned, and the projection engine selects the version in force at the +**purchase's own effective time**, never at "now". Selecting by current time would silently rewrite +historical access meaning every time an operator edits their catalog, which is the failure +versioning exists to prevent. Three routes, mounted on the Project-scoped dashboard-authenticated +subtree: + +| Operation | Route | Who | +| --- | --- | --- | +| Read a Product's grant history | `GET /v1/projects/{projectId}/billing/grant-versions?productId=…` | any organization member | +| Preview the impact of a change | `POST …/billing/grant-versions/impact-preview` | owner or admin | +| Publish a new version | `POST …/billing/grant-versions` | owner or admin | +| Edit a published version | `PATCH`/`PUT`/`DELETE …/billing/grant-versions/{id}` | always `409` | + +**Publishing is the separate, explicit act.** Previewing changes nothing — not even the audit +trail, because an operator comparing three candidate policies before choosing one has not made +three changes. Publishing requires an actor, a `reason`, and an admin role, and writes the version, +the audit event, and the reprojection work in one transaction. + +**Prospective by default, retroactive only as an additive superset (OD-8).** `effectiveStart` must +be now or later unless the caller sets `retroactive: true`, and a retroactive version is then held +to the widen-only rule — it may add Entitlements or widen policy, never remove or narrow either. +The comparison is `billingprojection.ValidateAdditiveSuperset`, the same function access is derived +under, rather than a second copy in the management package that could drift from it. + +**Replacement, not edit.** Publishing closes the current version at exactly the new version's +start, so the two intervals abut: never a gap (purchases made inside it would strand with no +applicable grant and project as `unknown`) and never an overlap (which version applies would become +a function of row order). A proposal reaching into an interval that has already closed is refused +with `grant_interval_overlap`; a retroactive correction is confined to the currently open interval, +because a closed interval is what a historical purchase already selected. + +Migration `00047` is what makes this expressible. `00033` gave the table a blanket append-only +trigger *and* a partial unique index permitting one open-ended version per pair, which together +made replacement impossible — closing requires an UPDATE the trigger refused, and a second +open-ended row the index refused. `00047` replaces the blanket trigger with one that permits +exactly one change: setting `effective_end` once, from NULL, to a later instant, with every other +column byte-identical. Reopening, re-closing at a different instant, rewriting a policy, and DELETE +are all still refused by the database, so no future repository method or migration bypasses the +guarantee either. + +**The change is applied, not merely recorded.** The publish transaction enqueues a projection job +for every Billing Customer whose current snapshot cites the Product, coalescing on the existing +scope-key uniqueness. A grant version that is recorded but never applied is worse than one never +published: every surface would report the new meaning while every customer kept the old access, and +nothing in the system would ever retry. + +Two policy flags are special. `grantsInPaused` is accepted only so it can be refused with a +sentence — Google's pause never grants access and the policy is not overridable. `grantsInBillingRetry` +contradicts both providers' documentation, so it is closed by default and settable only by an +organization **owner**, not by an admin. + +The impact preview counts from *current* committed state only — the snapshot each customer's +pointer names. `impactedCustomers` is who would be recomputed; `impactedActiveSources` is how many +of those citations are currently granting, which is the number that answers "how many people could +lose access if I get this wrong?"; `impactedLineages` includes purchases with no customer resolved +yet, which the customer count cannot see. `impactedEntitlements` and `impactedProducts` count +everything a reprojection of the affected customers re-derives, not only the pair being changed, +because that is the real blast radius of the confirmation being given. + +## Test transactions: the Apple/Google asymmetry (OD-17) + +The two providers are structurally different here, and the difference is a fraud control rather +than an inconvenience. + +- **Apple sandbox transactions cannot reach a production-mode Environment at all.** The + environment-alignment CHECK from migration 00024 and the store-environment guard in the + validator quarantine them. Apple sandbox accounts are free and self-service, so relaxing this + would make production entitlement self-service too. TestFlight purchases are sandbox + purchases; TestFlight testers get access by pointing TestFlight builds at a **staging + Environment**, not by weakening the guard. +- **Google Play has no sandbox.** License-tester purchases (allowlisted by an operator in Play + Console) arrive as production transactions, distinguishable only by a flag. They are admitted, + and every surface that reports access reports `isTestSource: true` for the sources they + produce, so a test-derived grant is never mistaken for a paid one. + +Consequently `isTestSource` appears on source summaries, on check results, and on webhook +payloads — but never on an Entitlement entry, because an Entitlement can be held for several +reasons at once and only the reasons can be test-derived. + +## Ratified access-semantics decisions + +Two decisions are restated here because both are places where a reasonable reader would expect +the opposite behaviour, and both are deliberate. + +**A partial refund never invalidates ownership; only a full one does.** Apple states a partial +refund as `REFUND_PRORATED`, Google as `quantity_partial` on a voided purchase. Neither is the +provider saying the customer stopped owning what they bought — a partial money-back on a +multi-quantity order, or a goodwill refund of part of a period, leaves the purchase standing. This +holds identically for subscriptions (the remaining period is preserved, per OD-18(a)) and for +one-time purchases (ownership is preserved). It is a single rule stated twice rather than two +rules, because the same provider statement must not mean "keep it" on one purchase type and "you +no longer own it" on another; the one-time engine tested only for Apple's `prorated` until this +was corrected. A genuine full void still arrives as a `full`/unspecified refund or as a +`revocation` fact, and both revoke. + +Ownership is the harder half. A subscription wrongly terminated by a partial refund recovers at +the next renewal; a lifetime purchase wrongly revoked has no expiry to recover from and no later +fact to restore it, so the wrong answer is permanent. + +**Replaying under an unimplemented rule version answers `422` by design.** It is not a gap to be +filled by falling back to the active engine. See "Projection replay and rule versions" below for +why: a checksum produced by the wrong engine is indistinguishable from a genuine determinism +result, which is the one thing a replay exists to prove. + +## From a validated fact to an owned Purchase Lineage (the 9A→9B seam) + +Phase 9A ends with a validated Transaction Fact in an append-only ledger. Phase 9B begins with +a Purchase Lineage that a Billing Customer owns. This is the step between them, and it is where +every entitlement in the system actually originates. + +### The two halves, and why they are in different places + +**The structural half runs inside the fact's own transaction.** `CompleteAttempt` writes the +attempt, the Resolution Snapshot, the fact, the ledger entries — and now the Purchase Lineage +and the Subscription or One-Time Purchase Instance it owns, plus the projection trigger. All of +it lands or none of it does. The lineage is a deterministic function of the fact's own chain +digest and decides nothing, so it belongs beside the fact; and the projection trigger written +next to it is only as durable as the row it points at. + +Three rules are load-bearing in that write: + +- **The lineage is keyed on the chain root, not on the fact's own digest.** A Google plan change + hands the subscription a new purchase token and names the old one as `linkedPurchaseToken`, so + the successor's fact carries a different `purchase_chain_digest`. Keying on it would mint a + fresh lineage per plan change and fragment one subscription's history — and the projection + loader would not put it back together, because it walks supersession edges *forward from the + root*. The root is resolved by walking those edges backwards, bounded and cycle-safe. +- **The digest domain is the fact's own** (`billing.AppleTransactionKey`, `billing.TokenDigest`). + Every fact-to-lineage join in the codebase compares `purchase_chain_digest` to + `lineage_key_digest`, so any other domain produces a lineage that can never join to the facts + it exists for. +- **Neither write disturbs an existing row.** `ON CONFLICT DO NOTHING` on both: a lineage's + customer association and an instance's projection state have other writers, and a fact + arriving is not new information about either. + +**The identity half runs after the commit**, through `billing.LineageBinder`. Deciding *who owns* +a lineage reads alias resolutions and prior evidence and can open an operator conflict; that is +application logic, and holding the ledger's hot-path transaction open across it would put +ingestion behind the identity module. It is idempotent — locating the lineage re-reads the row +the transaction created, and an association already naming the same customer is a no-op — which +is what makes it safe to run after the fact is already durable. + +The residue is deliberate and observable: if the process dies between the commit and the +binding, the fact and its lineage exist and the lineage is unassociated, which is exactly what +`unresolvedLineages` counts on the projection-health surface. The next fact on the same chain +retries the decision. + +### The evidence ladder (OD-2) + +The resolver — not the seam — decides which rung wins; the seam only assembles the observations. +Authority order is `billingcustomer.authorityRank`. + +| Rung | Evidence | Where it comes from | +| --- | --- | --- | +| 1 | `trusted_server_observation` | The application backend submitted under its secret server key while naming a customer with a Customer Access Token. | +| 2 | `app_account_token` / `obfuscated_external_account_id` | Provider correlators, matched against alias digests a backend already attached. | +| 3 | `prior_lineage_association` | An association already accepted for this lineage. | +| 3a | `token_bound_submission` | A public-SDK-key observation carried a Customer Access Token. It may attach an unowned lineage, but cannot move or freeze an attached one. | +| 4 | `purchase_anchor` | Nothing identified the purchase, so a customer was created to hold it. | + +**Submission context is what can attach a first purchase to an identified customer.** A store +notification arrives out of band and names nobody, and the observation contract carries no +customer member. So the submission carries a Customer Access Token in the +`Mosaic-Customer-Token` header — a header rather than a body member because it is a credential, +and the observation body is a ratified record Mosaic seals and can replay. A credential must +never be a thing that gets stored and replayed. The credential authenticating the **submission** +determines its authority. A public SDK key plus token records `token_bound_submission`; it may +establish a first association, but a device that once held a token can retain it, so that evidence +can never move or freeze an attached lineage. A request authenticated by the application's secret +server key records `trusted_server_observation`. Both observation endpoints document the optional +header in OpenAPI. + +The evidence is keyed on the **transaction reference**, not on a lineage, because at submission +time no lineage exists — the purchase has not been validated yet. `EvidenceForReference` reads it +back when the fact commits. More than one reference digest is searched: a client observation +cannot state a Store Environment (a device can be made to say anything), so it is recorded under +`unclassified` while the notification for the same purchase is recorded under the environment the +store confirmed. + +**Rung 2 correlators never touch a fact.** Apple's `appAccountToken` and Google's +`obfuscatedExternalAccountId` are read from the provider's *authoritative response* — the App +Store Server API transaction, the Play purchase resource — rather than from the notification, +because the response is the authority and the notification is only the trigger. They are hashed +at the point they are parsed, inside the validator. No Transaction Fact column holds one, no log +line, span attribute, or audit record ever sees the value or the digest, and Phase 9A's +fact-shape exclusion is unchanged. The digest's home is `billing_association_evidence`. + +**Rung 4 is plan §5a rules 1 and 2.** An anonymous purchase must still reach a customer: the +store confirmed a real transaction, and Mosaic has to answer for it on every surface whether or +not anyone has said who bought it. The customer is anchored to the **lineage**, never to the +device — the chain key survives reinstall, clear-data, and device change, so a reinstalling +customer who restores resolves back to the same customer rather than accumulating one per +install. That is the duplicate-customer trap §5a exists to avoid, and the reason an installation +identifier is evidence and never an anchor. + +`purchase_anchor` is its own vocabulary entry (migration `00049`) rather than being folded into +`prior_lineage_association`, because the two say different things: one is evidence *found*, the +other records that none was and a customer was created. The dashboard's "identified" versus +"purchase-anchored, not yet identified" distinction is exactly this row. It carries no correlator +digest — the whole meaning is the absence of one — and the resolver is never offered it, so it +can never become a route by which a guessable value reaches someone else's entitlements. + +When the person signs in later, an identified customer may adopt the anchor's lineage only with +ownership proof: possession of Google's bearer-grade purchase token, a provider correlator that +already resolves to the identified customer, or a secret-server-key submission. Possession of an +Apple transaction reference is explicitly not proof; it is a short decimal identifier rather +than a store-issued secret. The lineage moves, both customer aggregates are reprojected, and the +empty anchor row becomes `absorbed`. It is retained because snapshots, evidence, and audit history +already cite it. `absorbed` means “historical purchase anchor, no longer holding a lineage,” not a +deleted or merged identity, and it is available on the operator status filter. + +### Conflicts and supersession + +Two equally authoritative claims on one lineage — two backends each presenting their own +customer's token for the same transaction — conflict rather than one being picked: the lineage +freezes, neither customer is granted anything, and an operator resolves it (OD-10(a)). A lineage +already attached whose new evidence names someone else is a *reassignment*, which is never +automatic, and is downgraded to a conflict before any evidence is written. + +A lineage-level supersession edge is recorded only when the fact's own chain digest already had +a lineage of its own that is not the root — a link observed late, where the successor token +arrived first and was materialized before anything said it superseded an earlier chain. A token +handover inside one chain is not a lineage replacement and records no edge. Nothing is ever +deleted: a superseded lineage stops granting access and stays fully visible in history. + +An association that establishes an owner enqueues a **customer-scoped** projection. Any job +already queued for that lineage is lineage-scoped — it was queued when the lineage had no +customer — and a lineage-scoped command deliberately mints no customer snapshot. + +Pointer moves and conflict freezes are retry-safe across queue failures. Before a pointer changes, +the service persists prior-lineage evidence naming the old customer. If enqueueing either affected +aggregate fails, an identical association request reads that evidence and re-enqueues both the old +and current owner. Conflict resolution is likewise idempotent when the action and reason match the +committed decision, so a retry can finish both projections without rewriting operator intent. + +## Billing identity APIs and the conflict workflow + +The identity surface mounts under `/v1/billing/identity` rather than under `/billing/server`, so +the identity and access modules own disjoint route trees and neither can shadow the other. Every +route is authenticated by a secret server key. + +| Operation | Route | +| --- | --- | +| Create-or-get a Billing Customer | `POST /v1/billing/identity/customers` | +| List a customer's aliases | `GET /v1/billing/identity/customers/{customerId}/aliases` | +| Attach an application-user alias | `POST /v1/billing/identity/customers/{customerId}/aliases` | +| Revoke an alias | `POST /v1/billing/identity/aliases/{aliasId}/revoke` | +| Request a projection for one customer | `POST /v1/billing/identity/customers/{customerId}/sync-requests` | +| List identity conflicts | `GET /v1/billing/identity/conflicts` | +| Read one conflict | `GET /v1/billing/identity/conflicts/{conflictId}` | + +There is deliberately **no public-SDK-key path and no route anywhere that accepts an installation +identifier**. The application-user alias is assertable only by the customer's own backend, and a +client-generated installation id must never be able to create or select a customer (plan §5a, +OD-4(a)). Both properties are enforced by the absence of a surface rather than by a check a later +edit could remove. The installation alias is recorded as association evidence with outcome +`unsupported`, which is what gives purchase→install attribution at zero proliferation cost while +never letting the identifier resolve anything. + +No response on this surface carries an alias value or an alias digest. A digest is still a stable +per-person identifier, and nothing an operator or an application backend does needs one. + +### When identity is disputed (OD-10, review finding I-10) + +Two situations open a conflict rather than resolving: + +1. **Reassignment.** A Purchase Lineage already attached to customer A produces evidence that + resolves to customer B. Before this was fixed, the lineage moved silently: customer A kept a + committed snapshot granting access to a subscription that was no longer theirs, and nothing + recorded that it had happened. Now the resolution is downgraded to `conflicting` *before* any + evidence row is written — so the persisted evidence records a conflict rather than a + resolution that never took effect — a lineage-scoped conflict is opened with the incumbent + first and the challenger second, the lineage is frozen, and **the previous customer is + scheduled for reprojection** so the stale grant is recomputed. +2. **An alias that already resolves elsewhere.** Attaching an application-user alias whose digest + already has a live resolution to another customer answers `409 identity_conflict` — distinct + from `409 conflict`, which invites a retry — opens an alias-scoped conflict, and freezes the + customer named in the request. Only that customer: freezing the counterparty would let one + careless backend take a paying customer's identity offline. + +A frozen lineage keeps its last committed state. It is not projected and it does not advance a +checkpoint, but it still names the Entitlements in question and they are emitted as `unknown` +sources — because an absent entry reads to every consumer as "this customer never had it", which +is exactly the definite answer the uncertainty vocabulary exists to avoid asserting. + +Resolution is an operator action with three outcomes — `assigned_first`, `assigned_second`, +`detached_both` — and it requires a stated reason, after which the disputed subject is unfrozen and +**both** candidate customers are reprojected, not only the winner: the loser is the one holding the +stale snapshot. There is no automatic-merge path. Automatic merge stays an ADR checkpoint rather +than something a heuristic reaches on its own. + +Resolution is reachable only from the dashboard operator surface +(`POST /v1/projects/{projectId}/billing/identity-conflicts/{conflictId}/resolution`), never from a +secret server key. Deciding which of two people owns a purchase is a human judgement about +evidence, and an application backend holding a long-lived key is not the party that should be able +to make it unattended. See *The operator surface (dashboard)*. + +## Restore and sync + +A restore is not one action, it is a chain: the SDK submits provider transaction references as +observations, those become Raw Billing Inputs, validation turns them into facts, and only then +does a projection produce a snapshot that reflects them. The `billing_restore_sync` job family +follows the whole chain, and the outcome reported to the caller is derived from where the chain +actually got to — never from the fact that the native restore returned. + +| Operation | Route | Auth | +| --- | --- | --- | +| Request a restore (SDK) | `POST /v1/sdk/billing/restores` | Public SDK key in `Mosaic-SDK-Key` | +| Poll a restore (SDK) | `GET /v1/sdk/billing/restores/{restoreId}` | Public SDK key | +| Request a restore (backend) | `POST /v1/billing/server/restores` | Secret server key | +| Poll a restore (backend) | `GET /v1/billing/server/restores/{restoreId}` | Secret server key | + +**Two axes that are never merged**, matching the contract: Mosaic's `outcome` and the native +`providerOutcome`. A native restore that succeeded while Mosaic is still validating is +`providerOutcome: completed` with `outcome: validation_pending`. That is the honest answer, and +being able to say it is the reason the second axis exists. + +`outcome` is one of `restored`, `no_additional_purchases`, `validation_pending`, +`identity_unresolved`, `product_unresolved`, `provider_unavailable`, `failed`. Every non-definite +one names an uncertainty reason on the same vocabulary every other entitlement surface uses. + +**The invariant the whole subsystem exists for: `restored` is only ever reported together with +the accepted snapshot version that demonstrates it.** The job records a `baseline_snapshot_version` +when it starts, and `restored` requires the customer's pointer to have moved past it. It is +enforced three times over — a constructor that will not produce the outcome without the evidence, +a service-level validation, and a `CHECK` constraint — because reporting restored access that no +snapshot has yet granted is precisely the lie the contract's two-axis result was designed to +prevent. + +Two deliberate choices worth stating: + +- **The SDK surface takes the public SDK key, not a Customer Access Token.** A token is + customer-bound, and `identity_unresolved` is a first-class restore outcome: a restore is exactly + the flow where the customer may not be known yet, so requiring a customer-bound credential + would make the most important case unrepresentable. Safety comes from §5a instead — the request + names no customer, a public key cannot select one, and identity resolves server-side from + validated store lineage. +- **The body carries observation submission ids, not provider transaction references.** A Google + purchase-token digest is computable by anyone holding the token, so accepting caller-supplied + digests would let a caller attach someone else's input to its own restore. + `observedTransactionCount` reports what was actually linked, never what the caller claimed. + +## Application webhooks (OD-1(b)) + +Phase 9B ships the minimal slice: one event type, `customer.entitlements.changed`, with +at-least-once delivery, attempt history, and API-managed destinations. There is no dashboard UI. +The remaining nine event types the contract declares are reserved names; emitting one before it is +specified would be a defect. + +### The event is created inside the projection transaction + +An access-change webhook may only exist for state that was committed, so the complete Billing +State Webhook Contract v1 envelope is written into `webhook_events.payload` in the same +transaction as the snapshot it announces. Delivery happens strictly outside it. A destination +that is down produces retries and eventually an exhausted delivery; it never rolls back an +entitlement change and never blocks a projection, and no lock is held across the HTTP call. + +The whole envelope is stored rather than a partial payload the worker finishes assembling. That +makes the delivered body byte-identical across every attempt and every manual replay, which is +what makes the signature reproducible. + +**A no-change projection emits nothing.** The projection plans an event only when the customer's +committed entitlement state actually moved, so a replay that re-derives identical state delivers +no webhook — which is what stops a Project-wide replay from teaching every receiver to ignore the +channel. + +### Verifying a signature + +Every delivery carries: + +```http +Mosaic-Signature: t=1785243603, v1=e0af000fe574596fad9a154a3d74357a986a4896ceb188dcef6c486759257905 +``` + +The signed string is: + +```text +"v1" + "." + t + "." + eventId + "." + rawBody +``` + +hashed with HMAC-SHA256 under the destination's signing secret, taken as UTF-8 bytes verbatim — +not hex- or base64-decoded first — and rendered as 64 lowercase hexadecimal characters. + +> ADR-0024 originally wrote this prefix as a bare `1`. That does not reproduce any of the +> published vectors; the contract and `packages/test-fixtures/src/webhook-signature-vectors.json` +> both use `v1`, and the ADR has been corrected to agree with them. + +A worked example, taken verbatim from the shared vector `canonical-event-primary-key` so any +implementation can check itself against the same bytes the Go, Dart, Swift, and Kotlin +implementations are checked against: + +| Input | Value | +| --- | --- | +| secret | `whsec_fixture_primary_0000000000000000` | +| `t` | `1785243603` | +| `eventId` | `fixture-event-0001` | +| body | the exact bytes of `protocol/fixtures/billing-state-webhook/v1/events/entitlement-activated.json` | +| signature | `e0af000fe574596fad9a154a3d74357a986a4896ceb188dcef6c486759257905` | + +The vector set is eight entries and every one of them earns its place: +`canonical-event-rotation-key` proves the same event under a second key; the three +`must-not-verify` vectors prove the body, the event id, and the timestamp are each genuinely +inside the signed string rather than merely carried beside it; `minimal-body` bootstraps an +implementation before it can produce a real event; `non-ascii-body` catches a UTF-16 or +platform-default body encoding, which agrees on every ASCII vector and disagrees only here; and +`non-ascii-secret` catches a key that was hex- or base64-decoded before use. + +Receiver rules, in order: + +1. Reject a delivery whose `t` is more than **300 seconds** from your own clock, *before* + comparing signatures. +2. Hash the raw body exactly as received. Parsing and re-serializing changes whitespace, member + order, and Unicode escaping, and every genuine delivery then fails. +3. **During rotation the header carries one `v1` parameter per honoured secret. Accept if any of + them verifies.** A verifier that reads only the first parameter drops every delivery signed + with the new key. +4. Compare in constant time. +5. Only then parse the body — and treat it as a notification that state changed, never as the + authority on what the state now is. Re-read the Customer Entitlement Snapshot. + +Consumers are documented as **tolerant** — ignore unknown fields and unknown event types — while +the producer stays strict. That is a deliberate, recorded departure from Mosaic's fail-closed +posture (OD-16), because a consumer that rejects an unrecognized field breaks on every additive +change Mosaic makes. + +### Rotation + +A destination may hold more than one secret that is still permitted to sign. Rotation mints a new +secret, returns it once, and sets `previousSecretHonoredUntil` on the superseded one; until that +instant both sign and both appear in the header. The window lives on the secret row rather than +on the destination, because a second rotation started before the first overlap lapsed leaves two +superseded secrets and a single destination-level deadline would retire one of them early — +exactly the failure the overlap exists to prevent. + +Retirement is explicit and audited, and is the response to a suspected compromise. Retiring the +last secret that can still sign is refused: a destination with no signing secret would send +unsigned deliveries, and an unsigned entitlement webhook is an unauthenticated instruction to +grant access. + +### Destinations and SSRF + +A destination URL is operator-supplied and Mosaic makes outbound requests to it, which is a +server-side request forgery primitive unless it is bounded. Per ADR-0024: HTTPS only; no +redirects (a redirect is a second destination the operator never approved); RFC1918, loopback, +link-local including the cloud metadata address, CGNAT, IPv6 unique-local, IPv4-mapped, and the +unspecified address all refused **against the resolved address**; the hostname resolved once and +the connection pinned to the address that was checked; and bounded connect, total, and +response-body limits. + +The resolve-and-pin step is not a nicety. Checking the hostname and then letting the HTTP client +resolve again is the classic DNS-rebinding hole: the second resolution can return an address the +first check would have refused. The screen therefore runs **on every delivery attempt**, not only +at registration. + +`MOSAIC_BILLING_WEBHOOK_ALLOW_PRIVATE_DESTINATIONS` is the self-hosted exception, for operators +running Mosaic and their application backend on one private network. It is deployment-level on +purpose. A per-destination toggle would let anyone with destination-write permission reach the +internal network, which is the whole attack the policy prevents. + +### Delivery + +One `webhook_deliveries` row per (event, destination); attempts hang off it as append-only +history with `attempt_number` unique per delivery. Retries use exponential backoff with jitter, +so a destination outage does not produce a synchronized retry burst. Exhaustion is terminal and +means the attempts actually ran out — a delivery marked exhausted after two of eight looks, in +every operator view, exactly like one that was tried properly, so the schema refuses it. + +A manual replay reuses the same delivery row and the same event id, appending a further attempt. +A retry is a new delivery attempt, never a new logical event, so a receiver deduplicating on +event id sees each change once. + +A destination whose deliveries keep exhausting is misconfigured or gone, not having an incident, +so a bounded run of consecutive exhausted deliveries disables it automatically and audibly. The +counter counts exhausted *deliveries*, not failed attempts — a single delivery already burns its +whole budget against one provider outage — and any success resets it, so an outage that recovers +never trips the policy. + +Attempt history keeps a bounded, control-character-free excerpt of the destination's response so +an integrator can see why their own endpoint refused. It is never parsed and never influences +Mosaic state. + +## Projection replay and rule versions + +`POST /v1/projects/{projectId}/environments/{environmentId}/billing/projection-replays` recomputes +committed state from the immutable facts and reports what moved. It reuses the ordinary projection +command, so replayed state goes through the same advisory lock, compare-and-swap, and atomic +commit as live projection — there is no second write path that could diverge — and prior snapshots +are never deleted. + +**Bounded by construction.** One subscription instance, one customer, or a fact window. There is +no "replay everything" member: an unbounded replay is a migration, and bulk migration tooling is +out of Phase 9B (plan §18). The window bounds on *facts* — a scope is in scope when it holds a +fact whose effective or recorded time falls inside it — not on lineage creation. Bounding on when +a lineage was first seen selected the lineages created in the window and silently skipped every +long-lived lineage that merely *received* a fact in it, which is exactly the population a "replay +last Tuesday" is asking about. + +**Materialization is changes-only and has no switch.** The projection command mints a customer +snapshot only when the recomputed checksum differs from the committed one, and a replay reuses +that command. A `changesOnly` parameter previously existed and was never read; it has been removed +rather than left as a parameter that lies about being adjustable. + +**Rule versions are selectable, and an unimplemented one is refused.** `projectionRuleVersion` +selects the semantics; zero means the active version. A version this build does not derive under +answers `422` rather than being recomputed under the active engine and labelled with the requested +number — a checksum produced by the wrong engine is indistinguishable from a genuine determinism +result, which is the one thing a replay exists to prove. Only version 1 exists today; the shadow +diff engine is deferred per OD-11(a), so replay plus checksum comparison is how a future rule +change will be evaluated. + +**Provider asymmetry, stated rather than hidden.** Apple replay is input-sourced: a stored Apple +payload re-validates to the same transaction. Google replay is fact-sourced, because Google +validation re-queries live provider state and a re-query today does not reproduce what the +provider said last month. A Google replay therefore replays the facts Mosaic recorded, not the +provider's current answer. + +Every replay is audited with the rule version, the number of scopes replayed, and the number that +changed. The response is seen once by the operator who ran it; the audit entry is what an +investigation reads months later, and "a replay ran and changed nothing" versus "a replay ran and +rewrote four hundred customers" is the question such an investigation is actually asking. + +### A consequence of FactDigest v2 worth knowing (review finding I-13) + +Revalidating a Phase 9A input under validator version 2 recomputes a different fact digest and +inserts a **second** fact row for the same provider transaction. This is absorbed for access — +entitlement-source identity is `(purchase lineage, Mosaic Product, grant version)`, never a fact id, so the +duplicate cannot double-grant and the checksum is unchanged. It is **not** absorbed for +`subscription_timeline_entries` (one entry per fact id, so the same purchase can render twice) or +for `subscription_snapshot_facts` (both facts are cited as evidence). The full statement is in the +header of migration `00029_billing_fact_shape_v2.sql`. + +## The operator surface (dashboard) + +Every surface described above authenticates a *machine*. The identity, access, and restore APIs +take their tenant entirely from a secret server key, which is exactly right for an application +backend and is something a browser must never hold. The consequence is structural: none of that +state was reachable from a dashboard session at all, and Mosaic Studio could not show a Billing +Customer. + +The operator surface is the second door. It serves the same state, read through the same +repositories, behind a different lock. + +| Question | Route | +| --- | --- | +| Which customers exist here? | `GET /v1/projects/{projectId}/environments/{environmentId}/billing/customers` | +| Who holds this identifier? | `POST …/billing/customer-lookups` | +| Everything about one customer | `GET …/billing/customers/{customerId}` | +| Their current entitlements | `GET …/billing/customers/{customerId}/entitlements` | +| Their subscriptions | `GET …/billing/customers/{customerId}/subscriptions` | +| Recompute their state | `POST …/billing/customers/{customerId}/sync-requests` | +| One subscription | `GET …/billing/subscriptions/{instanceId}` | +| Why it changed | `GET …/billing/subscriptions/{instanceId}/timeline` | +| Restore and sync jobs | `GET …/billing/restore-jobs`, `GET …/billing/restore-jobs/{restoreId}` | +| Disputed identities | `GET /v1/projects/{projectId}/billing/identity-conflicts` (+ `/{conflictId}`) | +| Settle a dispute | `POST /v1/projects/{projectId}/billing/identity-conflicts/{conflictId}/resolution` | + +Projection health and bounded projection replay already live on this surface and use the same +authorization; they are documented under *Projection health* and *Projection replay and rule +versions*. + +### Who can see what, and why the server decides it + +Authentication is the opaque browser session (ADR-0017); authorization is the actor's +**organization role, resolved in SQL against `organization_members` alongside every query**. Owner +and admin reach this surface; every other role is refused. That is the same bar the Phase 9A +ledger, quarantine, and reconciliation pages use, and it is a deliberate step above plain project +membership: this is the most sensitive read Mosaic offers, because it names who bought what. + +Three properties are worth stating explicitly, because each one is a decision rather than a +default. + +- **The dashboard is not trusted to hide anything.** No route relies on a client not asking. A + session that is authenticated but not owner or admin receives `403` with the standard error + envelope, and a session belonging to another organization receives `404` — because telling a + caller that a Project exists but is not theirs is an existence oracle over other tenants. +- **The Environment on the route is checked for containment.** Pairing a Project you can read with + an Environment you cannot would otherwise pass the role check while every subsequent query, + which filters on `environment_id`, answered about someone else's Environment. A Subscription + Instance belonging to another Environment is likewise reported as absent rather than forbidden. +- **Enablement is checked after authorization.** "Billing is not enabled for this Project" is + itself information about a Project, and a caller who may not read the Project must not learn it. + +The secret-server surfaces are untouched by any of this. They are the application-backend +contract, and adding a browser-reachable route to them would have handed a public front end the +credential that names a customer. + +### The lookup is read-only by construction + +`POST …/billing/customer-lookups` takes a typed identifier — `billing_customer_id`, +`application_user_id`, or `installation_id` — and answers with at most one customer summary. + +It is not the trusted identify endpoint. That one is create-or-get, and using it as a search would +mint one Billing Customer per mistyped support query, which is precisely the duplicate-customer +trap plan §5a exists to avoid. The read model behind the operator surface declares **no writer at +all**, so this is a property of the code rather than a promise about it. + +The submitted value is digested server-side under the same domain separation the alias table uses, +and is never stored, never logged, and never echoed — which is also why the operation is a POST +with a body rather than a GET with a query string that would be written to access logs, proxy +logs, browser history, and referrer headers. It is rate limited in the export-class bucket, +because it is the one operator surface that accepts an attacker-chosen identifier and reports +whether it matched. + +`application_user_id` resolves through the active alias resolution. `installation_id` resolves +through **association evidence**, because an installation identifier is evidence and never an +anchor (plan §5a rule 2a) and therefore has no alias resolution to read. Reading recorded evidence +backwards for an authorized operator is a different act from letting a client-asserted identifier +select a customer at request time, and the two stay different because this path exists only behind +the authorization above. + +A miss answers `200` with `found: false` rather than `404`: "no customer holds this identifier" is +a true and useful answer to a support question. + +### What a customer detail actually shows + +One read returns the lifecycle, the Environment's current snapshot version and as-of instant, +aliases, purchase lineages, subscriptions, one-time purchases, identity conflicts on either side, +the current entitlement entries with their sources, and projection status. It is one call rather +than eight so the page describes one instant — an operator comparing a snapshot version against a +projection status assembled from eight requests would be comparing eight different moments. + +`currentSnapshot` is **absent**, not empty, when the customer has never been projected in this +Environment. "No answer yet" and "no entitlements" are different states and the surface keeps them +different. + +Aliases appear as protected representations: `aliasId`, `aliasType`, authority, verification +status, and validity dates. There is no value field and no digest field anywhere in the response +shape. The alias id is random and identifies the row for a revocation; an alias digest is still a +stable per-person identifier and would let one tenant's export be joined against another's, so it +never leaves the persistence layer. + +The list distinguishes `identified` from `purchaseAnchored` as two independent booleans rather +than one state, because the interesting customers are the ones where they disagree: a +purchase-anchored customer who never identified is real revenue with no person attached, and an +identified customer with no purchase is a person with no revenue. + +The Environment filter admits a customer holding a pointer or a lineage in this Environment, and +additionally a customer holding a lineage in no Environment at all — a customer created by a +trusted identify and not yet party to any purchase belongs to the Project and to no Environment, +and hiding it everywhere would make a just-created customer invisible. + +### Resolving a conflict from the dashboard + +Identity conflicts are Project-scoped and the route says so. A conflict is a dispute about who a +person is, and identity in Mosaic belongs to the Project (OD-3(b)); filing the page under an +Environment would imply it could be resolved differently in staging than in production. + +The resolution endpoint speaks the OD-10 vocabulary — `keep_existing`, `reassign_to_candidate`, +`operator_split` — mapped in exactly one place onto the schema's `assigned_first`, +`assigned_second`, `detached_both`. `reason` is **required**: every action moves committed access +for at least one paying customer, and the audit entry an investigation reads months later is worth +nothing without the why. The reason is written to the conflict's detail document and to the audit +event, so it survives a later resolution rewriting the row. + +The endpoint delegates the whole operation to the identity service rather than issuing SQL of its +own. That service applies the assignment under a row lock, unfreezes the disputed subject, audits, +and reprojects **both** candidates — the loser included, because the loser is the one holding a +committed snapshot that still grants the purchase. Duplicating that sequence behind a second write +path is how two copies come to disagree, so there is only one. + +### "Sync now" is not a restore + +`POST …/customers/{customerId}/sync-requests` enqueues a recomputation of the customer's +entitlement aggregate and reaches the same enqueue the trusted surface does, so an operator's +button and a backend's call produce one job on one queue rather than two answers. It computes +nothing itself. + +It is deliberately not a restore. A restore needs a device to ask its store for purchases, which +no operator can do on a customer's behalf, and a control that claimed to would report a native +outcome nobody produced. Restore visibility on this surface is read-only. + +## Projection health + +`GET /v1/projects/{projectId}/environments/{environmentId}/billing/projection-health` is a sibling +of the Phase 9A `billing/health` route, not a field on it. Billing health answers "can Mosaic still +turn store notifications into facts?"; projection health answers "is the authoritative answer +Mosaic gives about a customer's access still current?". An operator paged about one almost never +wants the other's numbers mixed in. + +It reports the projection backlog and its oldest queued age, failed jobs and the failure rate over +the last hour, stale and never-projected customers, open identity conflicts, frozen and unresolved +lineages, the count of `unknown` entries on current snapshots, the restore backlog, the webhook +backlog and exhausted deliveries, and the active projection rule version. Everything is a count or +a timestamp; nothing on the surface can carry a customer value, an alias digest, or a secret. + +Per-table row counts for all Phase 9B tables are published as +`mosaic.billing.table.rows{phase="9b",table=…}` from the worker. Plan §15 decided snapshot +retention with no drill baseline to extrapolate from — Phase 8 drills 4 and 5 were never run and +nothing is partitioned — so the trend has to start being recorded before it is needed. The counts +come from planner statistics rather than `count(*)`, because an exact count of the whole 9B schema +on every scrape is a self-inflicted load problem and the question being asked is a trend question. + +### Operational note: a systematic intake failure is hard to read from quarantine alone + +Phase 9A's quarantine surface records **one row per credential per reason per hour**, and the +notification identifiers behind it are not recoverable from that row. That is the deliberate +unbounded-growth control and it is the right trade — a store that starts rejecting every +notification would otherwise write a row per delivery — but it has a diagnostic cost worth +knowing before it is paid. + +The Phase 9B demonstration hit exactly that case (`docs/reviews/phase-9b-demo-evidence.md`, +D-0): seven of eighteen notifications were accepted with `202`, produced no Raw Billing Input, +and collapsed into a single `signature_invalid`/`intake_attribution_failed` row. Nothing was +wrong with Mosaic — the payloads' signing chain had genuinely expired as of their `signedDate` — +but from the quarantine surface alone the operator can see only that *something* in that hour +failed signature verification, not which deliveries or how many. + +When intake starts failing systematically, read the ledger and the raw-input counts alongside +quarantine rather than the quarantine surface on its own: the count of inputs accepted versus +facts recorded over the same window is what makes the size of the problem visible. + +## Observability + +Spans: `billing.token.issue`, `billing.token.revoke`, `billing.entitlement.sync`, +`billing.entitlement.check`, alongside the projection spans from the same phase. + +Metrics: + +| Metric | Meaning | +| --- | --- | +| `mosaic.billing.token.issued` | Tokens minted. | +| `mosaic.billing.token.rejected` | Rejected presentations, labelled by stage. The `tenant_mismatch` label is a security signal, not a client bug. | +| `mosaic.billing.sync.results` | Sync outcomes, labelled `snapshot` / `unchanged` / `unavailable` / `customer_mismatch`. The ratio of `unchanged` to `snapshot` is the ETag hit rate. | +| `mosaic.billing.sync.latency` | Sync latency in milliseconds. | + +Sync is expected to be the highest-QPS authenticated surface Mosaic serves. It carries its own +rate-limit bucket rather than sharing the observation bucket, keyed on the presented SDK key's +prefix so one carrier NAT does not become one bucket. **The limiter is in-process**: with more +than one API instance the effective limit is the configured limit times the instance count. +That is a documented limitation, not an oversight. + +## Logging and redaction + +Never logged, on any path: the token value, any alias value, any provider purchase token, any +Authorization header. Logged instead: `billing_token_id`, `project_id`, `environment_id`, +`billing_customer_id`, and Mosaic's own stable diagnostic codes. + +`response.Error` records the cause behind every 5xx to the operator log and never to the +response body; on these surfaces a cause can quote a credential, so no handler populates the +`Cause` field. + +## Configuration + +| Variable | Default | Meaning | +| --- | --- | --- | +| `MOSAIC_BILLING_ENTITLEMENT_SYNC_PER_MINUTE` | `1200` | Sync requests per bucket per minute. | +| `MOSAIC_BILLING_ENTITLEMENT_SYNC_BURST` | `240` | Sync burst allowance. | +| `MOSAIC_BILLING_ENTITLEMENT_REFRESH_AFTER` | `1h` | When a reader should refresh. | +| `MOSAIC_BILLING_ENTITLEMENT_VALID_FOR` | `168h` | Hard end of authoritative validity. | +| `MOSAIC_BILLING_ENTITLEMENT_STALE_GRACE_HOURS` | `24` | Bounded grace past validity. `0` is a strict policy. | +| `MOSAIC_BILLING_WEBHOOK_ALLOW_PRIVATE_DESTINATIONS` | `false` | The self-hosted SSRF exception. Deployment-level only; never a per-destination toggle. | + +Billing remains off by default at both levels; these surfaces are not registered unless +`MOSAIC_BILLING_ENABLED` is set, and they answer `billing_not_enabled` for any Project that has +not opted in. + +## Not yet in this document + +Nothing from the Phase 9B backend scope remains undocumented here. What is deliberately absent is +deferred rather than missing: the shadow diff engine (OD-11(a) — replay plus checksum comparison +stands in for it until a second rule version exists), the nine reserved webhook event types, a +dashboard UI for webhooks (OD-1(b) is API-only), and everything in the plan's §18 Phase 9C +exclusion list. Quarantine still has structurally no mark-as-valid path. diff --git a/docs/guides/privacy.md b/docs/guides/privacy.md index fa6262e6..1087c3f8 100644 --- a/docs/guides/privacy.md +++ b/docs/guides/privacy.md @@ -85,22 +85,59 @@ what makes a server-to-server observation actionable — a digest cannot be reversed into something the Play API will answer. It is encrypted on receipt, never logged, never returned by any endpoint, and expires with the raw body. -### What is deliberately **not** stored +### Customer correlators and Billing Customers (Phase 9B) + +Phase 9A stored no customer identity at all. Phase 9B changes that, and the +change is worth stating precisely rather than in summary. Apple's `appAccountToken` and Google's `obfuscatedExternalAccountId` are the -developer-chosen customer correlators, and Mosaic never decodes or persists -either. There is no customer, subscriber, entitlement-state, or access-grant -table anywhere in Mosaic Billing, and no price or currency column: Phase 9A -records that a store confirmed a transaction, and decides nothing about -customer access. +developer-chosen customer correlators. Mosaic now parses them server-side out of +raw provider payloads — never from anything a client asserts — and records them +as **SHA-256 digests with domain separation**. The raw values are never +persisted, never logged, never returned by any endpoint, and never appear in a +metric or a span. Capture is **forward-only**: correlators are read from inputs +received after the feature landed, and no historical raw input is reprocessed to +mine identity out of it. + +A **Billing Customer** is a Project-scoped row that purchases attach to. It is +created lazily — either when your backend identifies a user, or when a validated +purchase needs somewhere to attach — so SDK initialization and installation +registration create nothing. Aliases (application user id, installation id, and +the two provider correlators) are stored as digests only, one active resolution +per value, with end-dated history. + +Still deliberately absent: any price or currency column, any store account +identifier, any device identifier beyond the installation alias digest, and any +raw correlator value. + +Mosaic Billing's alias tables carry **no foreign key into the analytics identity +tables**, deliberately. The identified-user join happens at report time on the +shared application-user id value. A foreign key would force a choice between +breaking accepted deletion behaviour and silently revoking entitlements when a +subject is deleted, and neither is acceptable. ### Billing data and identity deletion -**Billing records are exempt from analytics identity deletion**, by owner -decision. This is not an oversight and it does not leave a subject's data -behind: Transaction Facts carry no customer identity, so an identity deletion -has nothing in them to reach. The link between a transaction and a person -exists in your own systems and in the store's, not in Mosaic's ledger. +The exemption is now a **split**, because the old rationale — "Transaction Facts +carry no customer identity, so a deletion has nothing to reach" — stopped being +true when Billing Customers arrived. Repeating it would have been the +comfortable answer rather than the accurate one. + +**Erasable on an identity deletion request:** Billing Customer aliases. The +alias is the person-to-purchase link and therefore the personal data in Mosaic +Billing. Deleting a subject end-dates and redacts their alias rows, so the +correlation between a human being and a purchase is gone. + +**Exempt, and why:** Transaction Facts, Billing Customers themselves, +subscription snapshots, and Customer Entitlement Snapshots. These are financial +evidence — the record of what a store confirmed and what access was granted on +the strength of it. They survive an alias deletion carrying no identifier that +points at a person: a Billing Customer with every alias removed is an anonymous +purchase anchor, which is what a refund dispute, a tax audit, and a chargeback +investigation each need to still exist. + +The practical consequence: after deletion, the purchase history is still there +and nothing in Mosaic can tell you whose it was. The store-issued material that *is* sensitive — signed payloads and purchase tokens — ages out on the raw-input retention window rather than on a deletion @@ -109,6 +146,39 @@ a person. If you need it gone sooner, lower `MOSAIC_BILLING_RAW_RETENTION_DAYS` (minimum 30) or disable billing for the Project. +### What leaves Mosaic on a billing webhook (Phase 9B) + +An application webhook is the one billing path where Mosaic sends data to a URL +an operator chose, so what it may carry is worth stating rather than implying. + +A `customer.entitlements.changed` delivery carries the Project and Environment +identifiers, the Billing Customer identifier, the snapshot version it announces, +which Entitlement keys changed and their before/after states, a four-axis state +summary of the subscription the change came from, and Mosaic's own correlation +identifier. That is the whole payload; the contract declares +`additionalProperties: false` at every level, so nothing else can be added to a +delivery without a contract version. + +Structurally absent, and unable to be added by configuration: any raw alias +value, any alias digest, any provider purchase token, any signed store payload, +any store account identifier, any price or currency, and any device identifier. +An event is a notification that state changed, never a copy of the state, which +is why the contract instructs consumers to re-read the snapshot rather than to +trust the payload. + +The destination is constrained rather than free: HTTPS only, no redirects, +private and link-local address space refused against the *resolved* address on +every attempt, and a self-hosted exception that is a deployment-level flag +rather than a per-destination toggle. The reasoning is in +[ADR-0024](../architecture/decisions/0024-sign-application-webhooks-with-hmac-sha256.md). + +Delivery attempt history keeps a bounded, control-character-free excerpt of your +endpoint's response body so an integrator can see why their own endpoint +refused. It is never parsed and never influences Mosaic state. Attempts are +retained for a much shorter window than the events themselves, because the event +identifier is a contract a consumer deduplicates on and an attempt is +operational detail. + ### Backup and key handling The keyring that seals billing envelopes is a backup artifact in its own right diff --git a/docs/plans/phase-9b-subscription-state-authoritative-entitlements.md b/docs/plans/phase-9b-subscription-state-authoritative-entitlements.md new file mode 100644 index 00000000..4ebc0ee1 --- /dev/null +++ b/docs/plans/phase-9b-subscription-state-authoritative-entitlements.md @@ -0,0 +1,544 @@ +# Phase 9B Plan: Subscription State and Authoritative Entitlements + +Status: **Stage 5 complete; Phase 9B accepted with tracked follow-ups.** Stages +2 and 3 are implemented, Stage 4 demonstrations pass after their recorded +defect pass, and the Stage 5 reviews and fix round are complete. All 19 owner decisions in §2 approved +as recommended by the owner on 2026-07-28 ("approve all +recommendations"), including the OD-14 opaque-token deviation from the +orchestration prompt's "signed" wording. Every recommendation column in +§2 is now the accepted policy. +Branch: `phase/9b-subscription-state-entitlements` (base `ecfe845`, one chore +commit after accepted 9A review head `1867605`). Entry review: +`docs/reviews/phase-9b-entry.md`. + +This plan reconciles the eight Stage 1 inspection reports (product, +protocol, backend, quality; dashboard, Flutter, iOS, Android). Where the +reports disagreed, the resolution and its reasoning are recorded here. +Sections marked **[OD-n]** are gated on the owner decisions in §2 and +record the recommended option; implementation must not begin against a +gated section until the owner has decided. + +--- + +## 1. Stage 1 findings that reshape the phase + +1. **Phase 9A persists no customer identity anywhere.** Apple + `appAccountToken` is structurally never parsed + (`internal/platform/appstorejws/payloads.go` omits the field); Google + `obfuscatedExternalAccountId` is likewise never persisted; facts have + no subject column; `docs/guides/privacy.md` documents this as a + guarantee. Every 9B association path must be built new [OD-2]. +2. **Two accepted-9A defects must be fixed before projection is safe** + (classified per the no-silent-repair rule; both are exactly the class + of defect live-sandbox testing would have caught): + - **B1**: `service_worker.go:581-586` overwrites `fact_kind` with + `purchase_superseded` whenever Google's persistent + `linkedPurchaseToken` attribute is present — the successor + subscription's expiration/cancellation/grace facts never appear by + kind → indefinite entitlement after any Google plan change. + - **B2**: a voided/refunded Google one-time purchase records **no + fact** (`purchaseState != 0` → `recorded_no_fact`) → refunded + non-consumables stay entitled forever. + - Also **B7**: `occurred_at` wall-clock fallback participates in + `FactDigest`, defeating replay idempotency; quarantine instead. + [OD-13] +3. **No webhook or token-signing infrastructure exists** anywhere in the + repository. The prompt's "reuse the accepted webhook-signing system" + has no referent; webhooks are also placed in Gate 9C by the roadmap. + [OD-1, OD-14] +4. **`product_entitlement_grants` is unversioned and hard-deletable** — + a one-DELETE mass-revocation path. Versioning with backfill is + required scope, not optional. [OD-8] +5. **Google ordering/replay caveats**: all facts in a Google lineage + share `occurred_at = startTime`; Google validation re-queries live + provider state, so replay is fact-sourced for Google, input-sourced + for Apple (§8, §12). +6. **Provider semantics are now documentation-verified** (backend + report, Part 3; sources with URLs and access date 2026-07-28): grace + grants access on both providers; billing retry / account hold does + not; Google pause does not; cancellation is renewal-intent-only until + period end; Google `linkedPurchaseToken` is the supersession edge; + Apple `REFUND_REVERSED` requires reversible revocation; Google + voided-purchases lookback is 30 days, so void events must be + persisted on receipt. +7. **iOS restore gap**: the StoreKit adapter never emits observations + from `Transaction.currentEntitlements`, so a fresh-device restore + submits nothing; restore-path emission (idempotent through both + existing dedup layers) is required 9B SDK work. +8. **Backup-exclusion inconsistencies** on iOS (four stores, including + the StoreKit acceptance store — a correctness issue) and Flutter (no + store is backup-excluded). All 9B entitlement caches are + backup-excluded from day one. [OD-19 for remediation of existing + stores] + +## 2. Owner decisions required (with recommendations) + +Implementation of gated sections must not begin until each is decided. + +| # | Decision | Options | Recommendation | +|---|---|---|---| +| OD-1 | Webhooks in 9B or 9C (roadmap places them in 9C; prompt places full subsystem in 9B) | (a) defer entirely to 9C — backends poll the Access Decision API; (b) minimal slice: `customer.entitlements.changed` only, HMAC signing, at-least-once delivery, attempt history, API-only destinations, no UI; (c) full subsystem + roadmap amendment | **(b)** with roadmap amended to record the split | +| OD-2 | Customer-association evidence | (a) submission-context only (token-bound SDK / trusted-server observations); (b) (a) + forward-only protected capture (SHA-256 digests, never raw) of Apple `appAccountToken` and Google `obfuscatedExternalAccountId` parsed server-side from raw payloads into 9B-owned evidence tables — Billing Ingestion v1 stays frozen; privacy guide amended in the same change; pre-9B facts project `unknown` until an explicit restore/link | **(b)** | +| OD-3 | Billing Customer scoping | (a) Environment-scoped customer (isolation fully structural); (b) Project-scoped customer identity, with Environment-scoped lineages, instances, subscription snapshots, tokens, webhooks, **and Environment-scoped Customer Entitlement Snapshots + current pointers** (one pointer per (customer, environment)) | **(b)** — matches the prompt's "a Customer belongs to one Project" while keeping sandbox/production isolation schema-enforced everywhere state lives | +| OD-4 | Anonymous installation-scoped access | (a) identified-only in 9B — Mosaic Billing requires an application backend, documented prominently; the installation alias exists only as evidence/diagnostics/cache-key/restore-hint and can never create or select a customer; (b) installation-scoped customers (the prompt's line-1368 anonymous mode — this is structurally the RevenueCat duplicate-customer trap: eager creation anchored to a client-generated device-local ID) | **(a)**; decline (b). See §5a customer-creation model | +| OD-5 | Offline access policy (uniform across all three SDKs) | strict / bounded grace / server-only | **Bounded grace**: `refresh_after` 1h, `valid_until` 7d defaults, per-Environment configurable, hard max 30d; past grace → `unknown` (never `inactive`); server-only documented as guidance for irreversible actions | +| OD-6 | Authoritative vs provider-observed entitlements in SDKs and placement targeting | (a) purely additive `MosaicCustomer…` namespace; targeting keeps reading provider-observed; no existing symbol changes; (b) authoritative replaces targeting input; (c) configurable | **(a)** for 9B; (c) later behind its own decision | +| OD-7 | Customer deletion vs the published privacy claims | (a) split: billing facts/customers/snapshots exempt (financial evidence); aliases (the PII — the person-to-purchase link) erasable/redactable; `docs/guides/privacy.md` §"no customer table" claim rewritten in the same change; (b) full cascade deletion | **(a)**. Hard constraint (verified): the Phase 6 deletion job hard-DELETEs analytics identity rows (`analyticspostgres/jobs.go:346-380`), so billing aliases must be an independent table with **no FK into analytics identity tables** — RESTRICT would break accepted deletion behaviour, CASCADE would silently revoke entitlements | +| OD-8 | Grant-change policy + backfill | prospective-by-period-effective-time + Product replacement; retroactive only as validated **additive-superset** correction with impact preview + confirmation. Backfill: (i) v1 effective from beginning of time; (ii) v1 effective from `created_at`, with prior grant-then-remove cycles reconstructed as closed intervals from `audit_events` (verified reliable: grants are discrete audited INSERT/DELETE operations, never bulk-replaced), plus the deterministic rule that a purchase predating the earliest version selects the earliest version | **Prospective + replacement + additive-superset**; backfill option **(ii)** — exact for live pairs, best-effort-reconstructed for removed pairs, no stranded historical purchase | +| OD-9 | Family Sharing | (a) exclude; (b) Apple `FAMILY_SHARED` transactions are an independent-lineage source for the family member's customer under the same evidence rules, revoked immediately on `FAMILY_REVOKE`, ownership type recorded in explanations; no family graph; Google N/A | **(b)** | +| OD-10 | Identity conflict behaviour | (a) freeze projection for the disputed lineage, preserve last committed state, mark `identity_unresolved`, operator resolution; (b) drop to `unknown` immediately | **(a)** | +| OD-11 | Shadow projection in 9B | (a) defer the diff engine until a second rule version exists (rule versions recorded on every snapshot from day one; replay + checksum comparison ship in 9B); (b) build full shadow infra now per prompt | **(a)** — deviation from the prompt, needs explicit sign-off | +| OD-12 | Live-sandbox waiver hardening | (a) waiver stands through 9B acceptance; (b) live verification of grace, billing-retry, pause, refund, revocation transitions becomes a named **blocking pre-production follow-up** in the 9B review | **(b)** | +| OD-13 | 9A defect corrections (B1, B2, B7) | (a) fix within 9B as explicitly classified 9A corrections (first backend work package, own commits, reprojection consequence stated); (b) separate 9A fix branch first | **(a)** | +| OD-14 | Customer Access Token mechanism | (a) opaque random tokens stored as SHA-256 digests (ADR-0017 posture; revocation is one UPDATE; Project/Environment/customer scoping is composite-FK columns, not claims validation; no signing ADR); (b) signed JWS + new ADR. **Note: the orchestration prompt's token list says "signed" — (a) is an explicit deviation** that satisfies every other requirement on that list strictly better (especially "revocable where practical") and needs owner sign-off as a deviation, not a quiet substitution | **(a)**; SDKs hold tokens in memory only, never persisted | +| OD-15 | Contract status | all three 9B contracts born `draft`, promoted alongside Billing Ingestion v1 once live-sandbox evidence exists | **yes** | +| OD-16 | Webhook consumer tolerance | documented departure from repo-wide fail-closed: producers strict (`additionalProperties: false`), consumers documented tolerant (ignore unknown fields/event types, re-read the snapshot) | **accept** | +| OD-17 | Test transactions (`is_test_transaction`) | Verified structural asymmetry: Apple sandbox facts (incl. all TestFlight purchases) **cannot** reach a production-mode Environment — `storeEnvironmentMatchesMode` + the 00024 alignment CHECK quarantine them; this is a fraud control (Apple sandbox accounts are free and self-service) and must never be relaxed. TestFlight testers get access by pointing TestFlight builds at a staging Environment. Google has no sandbox: license-tester purchases (operator-allowlisted in Play Console) arrive as production transactions flagged `is_test_transaction`. Options: (a) per-Environment `test_transaction_entitlement_policy` ∈ {deny, grant}, default `grant`, every test-derived Entitlement carrying an `is_test_source` flag on server API, SDK result, and webhook payloads; (b) deny in production-mode Environments | **(a)** — and the Apple/Google asymmetry is documented in the 9B entitlement semantics | +| OD-18 | Apple prorated refund (`REFUND_PRORATED`) — provider docs do not state remaining-period effect | (a) does not revoke the remaining period unless provider status says revoked; (b) revokes | **(a)** | +| OD-19 | Backup-exclusion remediation of existing stores (iOS `Identity.swift`, `ConfigurationStore`, `CommerceConfigurationStore`, `MosaicStoreKitAcceptanceStore`; Flutter stores) | (a) fix the acceptance store + identity in 9B as classified corrections, file the rest; (b) fix all in 9B; (c) file all separately | **(a)** | + +## 3. Orchestrator-ratified decisions (recorded, not owner-gated) + +- **SDK naming**: `MosaicCustomer…` prefix on all three platforms + (Flutter and Android proposals adopted; iOS renames its proposed + `MosaicAuthoritative…` types accordingly). Provider-observed symbols + are frozen; no renames, no deprecations. +- **Cache states** (identical on all platforms): `fresh`, + `refreshRecommended`, `staleWithinGrace` (exists only under bounded + grace), `expired`, `missing`, `invalid`, `differentCustomer`. + Clock-unreliability is a diagnostic that forces expired-equivalent + behaviour, not an extra enum member. +- **Cross-platform constants**: clock-skew tolerance 60 s; restore + snapshot-poll bound 3 attempts / ~6 s before `validation_pending`. +- **Snapshot versioning**: `snapshotVersion` is a per-customer(-per- + environment) monotonic integer and the sole cache-monotonicity key; + ETag is an opaque equality validator only; a no-change projection does + not advance the version (`lastProjectedAt` in projection status does). + The conditional POST answers `200` with `snapshotUnchanged`, including + refreshed freshness windows in its contract body. GET is unconditional and + never answers `304`. +- **Entitlement entry shape**: `mosaicProductId` / + `subscriptionInstanceId` live on source summaries, not duplicated on + entries (deliberate deviation from the prompt's entry field list, to + prevent two places disagreeing). +- **Serialization**: transaction-scoped advisory lock + (`billing-projection:{customer_id}` / `…-lineage:{lineage_id}`) via + the accepted `LockScope` pattern, plus CAS on + `current_projection_version` as a belt, plus projection-job partial + uniqueness on scope key. No lock held across network I/O. +- **Entitlement Source identity** is `(purchase lineage, product, + grant version)` — never a fact ID — so multi-fact-per-purchase + (mapping drift, validator bumps) cannot double-grant. +- **Association from raw payloads**: correlators are parsed + server-side from ledger raw inputs into 9B-owned tables; Billing + Ingestion Contract v1 is not amended and `subjectReference` stays + unpopulated. Fact→authority resolution scans raw inputs by + `transaction_reference_digest` (first-writer-wins fact provenance is + not authoritative; quality B8). +- **Webhook signing** (if OD-1 ships any slice): HMAC-SHA256, + `Mosaic-Signature: t=…, v1=hex(hmac(secret, version.t.eventId.body))`, + multiple active keys during rotation, secrets sealed as a new v2 AAD + `SubjectKind` — requires the "new webhook-signing system" ADR. +- **SSRF policy** for destinations: HTTPS-only, deny + RFC1918/loopback/link-local/CGNAT/ULA/IPv4-mapped, resolve-and-pin + per attempt, no redirects, bounded time/size, self-hosted allowlist + env flag — recorded as policy in the ADR above. +- **Billing disabled** maps to `unavailable` on every entitlement + surface, never `inactive`; tested. +- **Dashboard IA**: Customers under the existing Billing nav group + (Environment-scoped routes for consistency; the customer header + states Project scope); grant versions in Catalog; projection health + as a sibling of billing health; the existing catalog grant/revoke + mutation is retired in favour of versioned grants; 9A's + `BILLING_BOUNDARY_NOTE` is revised. A direct `GET customer/{id}` + endpoint is mandatory. +- **Customer token wire form**: the customer token travels in + `Authorization: Bearer` on the SDK sync surface with the public SDK + key in `Mosaic-SDK-Key` — final header naming is a Stage 2 protocol + work-package decision, bound by contract fixtures. + +## 4. Official provider documentation consulted + +Recorded in full in the Stage 1A backend report (Part 3.5), incorporated +here by reference; access date 2026-07-28. Apple: App Store Server API +(transaction/renewal payloads, subscription statuses, transaction +history), App Store Server Notifications V2, StoreKit +(`currentEntitlements`, `RenewalState`, Family Sharing, `AppStore.sync`). +Google: Play Developer API v3 (`purchases.subscriptionsv2` + `revoke`, +`voidedpurchases`, `purchases.products`), Play Billing lifecycle/RTDN/ +test docs, ReplacementMode. Key normalization decisions: §1.6 of this +plan and backend report Part 3.1–3.4. Neither provider guarantees +notification ordering; the canonical ordering tuple (§8) is Mosaic +policy. + +## 5. Domain model + +As specified in the backend Stage 1A report Part 2 (adopted with the +scoping amendment of OD-3(b)): + +- `billing_customers` (Project-scoped identity; `status` incl. + `frozen`/`anonymized`/`absorbed`; diagnostics; audit). +- `billing_customer_aliases` (typed; SHA-256 digest values with + domain separation, never raw; partial-unique one active resolution + per `(project, type, digest)`; end-dated history). +- `billing_association_evidence` (append-only; evidence types: + `app_account_token`, `obfuscated_external_account_id`, + `trusted_server_observation`, `prior_lineage_association`, + `restore_link`, `operator_repair`; outcomes resolved / unresolved / + conflicting / unsupported; resolver versioned). +- `billing_identity_conflicts` (one open per lineage; freeze semantics + per OD-10). +- `purchase_lineages` (Environment-scoped; unique + `(environment_id, provider, lineage_key_digest)`; Apple key = + `originalTransactionId` digest, Google key = purchase-token chain + root walked through `linkedPurchaseToken`; explicit + `superseded_by_lineage_id`; environment-mode alignment CHECK). +- `subscription_instances`, `one_time_purchase_instances` (1:1 with + lineage; consumables excluded). +- `subscription_snapshots` (immutable; five state axes; effective + timestamps incl. grace end, retry start, pause/resume, cancellation, + expiration, revocation, refund; checksum; rule version; source-fact + links via `subscription_snapshot_facts`). +- `subscription_timeline_entries` (append-only; closed entry-type set; + safe detail via the ledger guard function). +- `projection_checkpoints` (derived, rebuildable; invalidated by + out-of-order facts), `projection_rule_versions` (seed v1; one + active), `projection_jobs`/`projection_attempts`. +- `product_entitlement_grant_versions` (immutable versions; no-overlap + intervals; access-policy columns `grants_in_grace` etc. defaulted per + OD-5/§7; backfill per OD-8), plus `entitlements` lifecycle columns. +- `entitlement_sources` (per customer-snapshot generation; identity + `(lineage, product, grant version)`). +- `customer_entitlement_snapshots` + `…_entries` (immutable; monotonic + per (customer, environment); `unavailable` never persisted — it is a + read-time service state). +- `customer_access_tokens` (opaque digests per OD-14; ≤1h default, + ≤24h max; audience/scopes; Environment-bound). +- Webhook tables per OD-1 scope; `restore_sync_jobs`, + `projection_replay_jobs`; shadow tables deferred per OD-11. + +## 5a. Customer creation model (avoiding customer proliferation) + +The duplicate-customer failure mode of client-anchored systems (eager +creation at SDK launch, anchored to a device-local ID, reconciled later +by lossy merge) is avoided structurally by four rules: + +1. **Lazy creation.** No Billing Customer exists until either the host + backend identifies a user (trusted flow) or a validated purchase + fact needs somewhere to attach. SDK init and installation + registration create nothing. +2. **Anonymous purchases anchor to the purchase lineage, not the + device.** 9A already persists store-account-derived anchors + (`purchase_chain_digest` from Apple `originalTransactionId`; + Google token digest chains + `supersedes_chain_digest`) that + survive reinstall, clear-data, and device changes — so a + reinstalling user who restores resolves to the *same* + purchase-anchored customer. +2a. **Installation alias is evidence, never an anchor.** When the SDK + supplies it, the installation alias is recorded as association + evidence on the Billing Customer — giving purchase→install + attribution (a real conversion funnel) at zero proliferation cost — + but it never creates or selects a customer. Pre-purchase + usage questions ("someone is using the app and might subscribe") + are answered by Phase 6 analytics (`analytics_installations`, + sessions, funnels), not by empty billing rows; the identified-user + join happens at report time on the shared application-user ID value + (deliberately no FK — see OD-7's deletion constraint). Honest + residue: a purchase-anchored, never-identified customer whose + installation evidence never arrived has correct revenue but no + install attribution. Dashboard shows active installations (Phase 6) + and Billing Customers (9B) side by side, never conflated. +3. **Login attaches, it does not merge.** Identifying a user appends + an application-user alias to the existing lineage-anchored + customer; merge is made rare by construction rather than made good. +4. **Real conflicts quarantine** (one app-user alias claiming two + customers with real purchases): freeze, grant neither + automatically, operator resolution, audit — per OD-10. Automatic + merge stays an ADR checkpoint, not taken in 9B. + +Security corollaries: the client-generated installation ID must +never, by itself, select an existing Billing Customer (replay/guess ⇒ +reading someone else's entitlements); the application-user alias is +assertable only by the customer's backend (secret key or a token that +backend minted), never by a public-SDK-key client; restore resolves +customers through server-validated store lineage, never a +client-asserted identifier. Caveat: Apple `originalTransactionId` is +store-account-scoped, so Family Sharing / shared devices can put two +people on one lineage — persisting `inAppOwnershipType` (9A gap B10) +lands in the same fact-shape pass to keep lineage anchoring precise. +Dashboard consequence: the customer list distinguishes "identified" +from "purchase-anchored, not yet identified". + +## 6. State model and canonical derivation + +Axes exactly as the contract §3 of the protocol report: `accessState` +(active/inactive/unknown/unavailable), `lifecycleState` (trialing/ +active/grace_period/billing_retry/paused/expired/revoked/refunded/ +superseded/unknown), `renewalIntent`, `billingState`, `uncertainty` +(object with reason ∈ none/provider_unavailable/missing_fact/ +identity_unresolved/product_unresolved/conflicting_facts/ +projection_failed/stale_validation/unsupported_provider_state). + +Derivation order (deterministic at snapshot `as_of`; half-open +intervals `[start, end)` per backend §3.4-8): effective revocation → +effective invalidating refund (per OD-18) → supersession → effective +pause (Google only; `inactive`; a *scheduled* pause keeps access until +its effective time, which is why pause is evaluated before the +current-period branch — an effective pause overrides an otherwise +active period) → verified current period → verified grace +(`access active` per provider docs + grant policy) → billing retry +(`access inactive` by default) → period ended → `unknown`. Cancellation flips renewal +intent only. Schema-level `if/then` invariants encode +revoked⇒inactive, grace⇒grace-end-present, +unknown/unavailable⇒uncertainty≠none. + +Provider-specific transition tables (Apple and Google separately, per +fact kind, with the driving timestamp for each transition) are +finalized in Stage 2 WP6/WP8 against the backend report's Part 3 +normalization decisions; the prompt's transition-table rows are all +representable with the 9A `fact_kind` vocabulary once B1/B2 are fixed. + +## 7. Access policies (versioned, policy version 1) **[OD-5, OD-17, OD-18]** + +- Trial, active period: access active. +- Verified grace: active through provider grace end (`grants_in_grace` + default true; per-grant opt-out allowed). +- Billing retry / account hold: inactive (default false; enabling + requires explicit owner approval — contradicts provider docs). +- Pause (Google): inactive while effective; fixed, no override. +- Cancellation: renewal intent only; access until validated period end. +- Refund/revocation: effective at validated provider time; Apple + `REFUND_REVERSED` reinstates; prorated per OD-18. +- Unknown evidence: `unknown`, never `inactive`; billing disabled or + service failure: `unavailable`. +- Test transactions: per OD-17. + +## 8. Ordering, effective time, supersession + +Canonical tuple, ordering version 1, one component +(`internal/billingprojection/ordering.go`): +`(effective_at, fact_kind_precedence, provider_transaction_id, +occurred_at, recorded_at, fact_id)` — effective time per fact kind and +provider as documented in backend §2.5; `received/recorded` only as +late tie-breakers. Google's constant `occurred_at` is compensated by +recovering event time via join to `billing_raw_inputs. +provider_occurred_at` where the fact kind needs it; whether that +becomes an additive fact column (validator-version increment) is a +Stage 2 WP1/WP5 decision recorded before migration 00031 lands. + +**Fact-shape pass (blocking prerequisite for §6/§7):** the 9A fact +row must gain additive columns for provider fields that are parsed +today but never persisted (quality B10) — Apple +`gracePeriodExpiresDate`, `isInBillingRetryPeriod`, +`autoRenewProductId` (scheduled downgrade), `isUpgraded`, +`revocationReason`/`revocationType` (full vs prorated), +`inAppOwnershipType`, `subscriptionGroupIdentifier`, and a recovered +provider event time for Google — as one additive change with a +validator-version increment and documented `FactDigest` +participation. Without these, grace end, billing-retry state, +scheduled downgrades, refund scope, and Family Sharing are not +expressible, so this lands in backend WP1 immediately after the +B1/B2/B7 corrections and before the projection engines. +Supersession is explicit (`superseded_by_lineage_id`, lifecycle +`superseded`); nothing is deleted. Out-of-order facts invalidate the +checkpoint and reproject the lineage from zero. + +## 9. Consistency, transaction boundary, idempotency + +One atomic transaction per projection command (advisory lock → +re-read version → project → write snapshots, timeline, sources, +customer snapshot, both current pointers, checkpoint, webhook events, +audit → commit). External calls never inside. Idempotency key = +digest of `(scope, high-watermark position, rule version, grant +version set)`. No-change replay: checksum-equal ⇒ no snapshot, no +webhook, checkpoint advances, attempt recorded. Read-your-writes and +eventual-consistency disclosures per the contract's +`projectionStatus`; every surface exposes `as_of` and snapshot +version. + +## 10. Contracts (Stage 2 protocol work packages) + +Per the protocol Stage 1A report, adopted: three new contracts — +`authoritative-entitlement/v1`, `customer-access-token/v1` (claims doc +only; SDK treats tokens as opaque), `billing-state-webhook/v1` (scope +per OD-1) — all born `draft` [OD-15], house envelope shape, closed +over-provisioned enums, `additionalProperties: false`, with the one +normative reader rule: **any rejection yields `accessState: unknown` + +preserved cache, never `inactive`**. Fixture inventory as specified +(including the semantic-layer `older-snapshot-version-rejected`, +`different-customer-rejected`, and the behavioural +`cancelled-access-still-active`). Shared reference vectors in +`packages/test-fixtures`: snapshot digest vectors, cache-decision +vectors, freshness vectors, webhook signature vectors. Negotiation +lives in the sync request body; Configuration Delivery capability +request is untouched. + +## 11. Server APIs, tokens, SDK sync + +- Trusted (`secret_server` key): customer create-or-get, alias + attach/revoke/list, token issuance, snapshot fetch, multi-key check + (state + explanation + version, never bare boolean), subscription + list/snapshot/timeline, conflicts, restore/sync jobs, replay, + `GET customer/{id}` direct. +- SDK: unconditional `GET /v1/sdk/billing/entitlements` plus conditional + `POST` sync — customer token bearer, opaque ETag equality, `200` + `snapshotUnchanged` when the posted known version is current, Access + Decision Snapshot response, rate limited. Public SDK key alone can never + select a customer. +- Tokens per OD-14: opaque digests, ≤1h default, Environment- and + customer-bound, revocable, issuance audited, second consumer of + `secret_server` auth. + +## 12. Replay and rule versioning + +Replay: one instance / one customer / bounded project window; +selectable rule version; ignores checkpoints; checksum comparison; +`changes_only` materialization default; prior snapshots preserved; +audited. Apple replay is input-sourced; Google replay is fact-sourced +(live re-query is not deterministic) — stated as a documented +provider asymmetry. Rule versions: monotonic, recorded on every +snapshot, one active, promotion audited; shadow diff engine deferred +per OD-11. + +## 13. SDKs (Stage 3) + +Shared design per the three Stage 1B reports, reconciled: +- Token provider abstraction per platform idiom (`fun interface` + + suspend / protocol / typedef); memory-only tokens; single-flight + forced refresh; exactly one retry per 401 generation; cooldown on + provider failure; `null`/signed-out ⇒ `unavailable`. +- Cache: per-customer namespace digest in the **path**; atomic + four-step write; strict closed-key decode; integrity digest + (documented as corruption detection, not security); backup-excluded + from day one (iOS `isExcludedFromBackup` + file protection; Android + `noBackupFilesDir`; Flutter `getApplicationCacheDirectory()` with + memory-only degradation, never the support dir). +- Acceptance gate: contract version, customer binding (mismatch ⇒ + clear cache + high-severity diagnostic), monotonic version, `as_of` + regression, checksum, required fields. Rejected snapshots never + emit. `snapshotUnchanged` preserves cache and slides freshness. +- Observation: Flutter broadcast stream + `ChangeNotifier`; iOS + `AsyncStream` fan-out with current-value replay; Android + `StateFlow`. Explicit `Cleared`/`SignedOut`/`Loading` states so + identity transitions are observable without emitting stale grants. +- Identity change/logout: generation bump → cancel in-flight → clear + before any read → installation identity preserved (Phase 6). +- Restore: multi-stage sealed results; success (`…Updated`) only when + an accepted snapshot reflects the restore; provider result carried + separately; iOS adds the `currentEntitlements` observation emission + (finding §1.7); Android composes over `queryPurchases` recovery; + purchase-completion refresh is unawaited and never blocks purchase. +- No boolean convenience API anywhere. +- Cross-platform conformance driven by the shared fixtures and + reference vectors (§10); Go/Dart/Swift/Kotlin must agree on the + cache-decision and freshness vector tables. + +## 14. Dashboard (Stage 2) + +Per the Stage 1B dashboard report, adopted: `features/billing-customers`, +`features/entitlement-grants`, `features/billing-projection` +(+ `features/billing-webhooks` iff OD-1 ships UI — default per OD-1(b) +is API-only, so no webhook UI in 9B). New +`entitlement-vocabulary.ts` with four distinct access labels (unknown +and unavailable in "attention" tone with explanations, never +negative); five separate state pills; typed-identifier customer +search; conflict resolution as a multi-step confirmed form; grant +versions read-only once published with impact preview before publish; +`BILLING_BOUNDARY_NOTE` revised; `LedgerPaging` extracted for reuse. + +## 15. Security, privacy, observability, performance + +- Aliases digest-only; correlators sealed/digested; tokens digested; + webhook secrets sealed (new SubjectKind); nothing sensitive in + logs/telemetry/timeline (ledger-guard reuse). +- Authorization: every operator surface permission-checked + server-side; SDK read surface selects customers by token only. +- Privacy: guide amended per OD-2/OD-7 in the same change that lands + migration 00029; Phase 6 deletion exemption rationale rewritten + (aliases erasable, facts/snapshots exempt). +- Observability: OTel spans/metrics for association, ordering, + projection, commit, replay, token issuance, SDK sync, webhook + create/deliver, lock wait, stale projections; new job families in + the existing queue gauges; alerts for backlog, failure rate, stale + state, conflict spikes, exhausted deliveries, cross-tenant auth + failures. +- Performance: no invented SLOs; measure fact→projection latency, + sync endpoint latency/QPS (highest-QPS authenticated surface to + date — in-process rate limiter's multi-instance limitation + documented), replay throughput; record in the 9B review. +- Snapshot retention (decided now — no drill baseline exists to + extrapolate from; Phase 8 drills 4–5 were NOT RUN and nothing is + partitioned): current snapshots and pointers retained indefinitely; + **historical** snapshots and timeline entries get an explicit + operator-configurable retention window, safe because they are + rebuildable from facts + rule versions (Principle 2); webhook + delivery **attempts** get a much shorter window than webhook + **events** (the stable-ID contract); per-table row-count metrics + added to billing observability from day one so the first post-9B + drill has a trend. + +## 16. Migrations + +As landed (numbering shifted one from the original outline because the +fact-shape pass took 00029): 00029 fact-shape v2 · 00030 customers/ +aliases/evidence/conflicts · 00031 lineages/instances · 00032 +subscription projection (+ projection_jobs/attempts/rule versions — +replay reuses `projection_jobs.kind='replay'` rather than a separate +table) · 00033 grant versions + entitlement lifecycle · 00034 +entitlement snapshots/sources/pointers (two-step circular FK) · 00035 +tokens · 00036 webhooks [OD-1] · 00037 restore/sync jobs (batch 2). +Shadow tables deferred per OD-11(a). +All `ON DELETE RESTRICT`, composite Project/Environment FKs, +append-only triggers, up→down→up verified against clean 9A schema and +representative data. + +## 17. Testing + +Minimum-sufficient policy applies; the risk inventories in the eight +Stage 1 reports are adopted as the test charters: projection +determinism (same facts ⇒ same checksum; duplicate/out-of-order; +checkpoint = full replay; no-change ⇒ no webhook), state transitions +(cancellation keeps access; grace/retry/pause per policy; refund +scope), aggregation (multi-source; permanent-source no-false-expiry; +grant selection determinism), identity (token cannot cross +project/environment/customer; conflict ⇒ no double grant; logout/ +identity-change leak tests on all SDKs), concurrency (advisory-lock +serialization; atomic pointer commit; idempotent retry), SDK cache +(older/malformed/wrong-customer rejection; `snapshotUnchanged`; offline expiry; +backup-exclusion regression guards), webhooks (signature vectors; +stable event ID on retry; failure never rolls back state; SSRF), and +the four 9A-defect regression tests named in the quality report +(extend existing files; no new suites/runners anywhere). + +## 18. Phase 9C exclusions (unchanged) + +No RevenueCat migration, historical import, dual-run, cutover, bulk +repair/reassociation, financial reporting, consumables/credits/ +metering, manual grants, operator state override, Stripe/Paddle/Lemon +Squeezy, AI billing decisions. Quarantine keeps structurally no +mark-as-valid path. + +## 19. Integrated demonstration + +The 14 scripted demonstrations from the orchestration prompt, driven +by a reusable driver following the 9A `billingdemo` pattern +(build-tagged), with webhook demos scoped per OD-1 and shadow demo +replaced by replay-checksum comparison per OD-11. One-minute demo: +validated purchase → authoritative Pro entitlement → cancellation +keeps access → expiration removes subscription source → lifetime +source keeps access → three SDKs converge on one snapshot version → +signed webhook (if OD-1(b)) reports the change. + +## 20. Stage plan + +- **Stage 2** (after owner decisions): protocol (3 contracts + + fixtures + vectors), backend (WP order: 9A corrections [OD-13] → + migrations → identity → lineage → ordering → projection engines → + transaction → jobs → grants → tokens → sync/server APIs → restore → + webhooks [OD-1] → replay → diagnostics → OpenAPI → observability), + dashboard (WP1–8, 11; 9–10 iff webhook UI ships). +- **Stage 3**: Flutter, iOS, Android per §13 + cross-platform + conformance. +- **Stage 4**: integrated demonstrations. +- **Stage 5**: product/UX/protocol/quality reviews; fix pass ≤2 + rounds; `docs/reviews/phase-9b.md`; stop. No merge, no tag, no 9C. diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 00ea8cf8..4e5fbc52 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -3919,9 +3919,15 @@ Implement: - migration dry run - migration validation - migration rollback strategy -- retryable billing webhooks -- webhook signing -- webhook audit history +- the remainder of the billing webhook subsystem beyond the Gate 9B + minimal slice (owner decision OD-1(b), 2026-07-28): the nine + reserved event types beyond `customer.entitlements.changed`, + dashboard webhook destination and delivery UI, and any further + delivery tooling. Gate 9B shipped the minimal slice: one emitted + event type, HMAC signing with rotation, at-least-once delivery with + bounded backoff, append-only attempt history, SSRF-screened + API-only destination management, and an audited operator + delivery-replay endpoint. - manual reconciliation - repair tools - invalid mapping diagnostics diff --git a/docs/protocol/authoritative-entitlement-v1.md b/docs/protocol/authoritative-entitlement-v1.md new file mode 100644 index 00000000..d74dcfc5 --- /dev/null +++ b/docs/protocol/authoritative-entitlement-v1.md @@ -0,0 +1,566 @@ +# Authoritative Entitlement Contract v1 + +Authoritative Entitlement Contract `1` is Mosaic's closed, platform-neutral +contract for **what access a Billing Customer has, and why**. It is a **draft**: +it is not part of the approved v1 GA set and reaches `approved` only through an +explicit product-owner decision, alongside Billing Ingestion `1`, once +live-sandbox evidence exists. + +Billing Ingestion `1` records what a provider confirmed. This contract records +what that means for a person. The two are deliberately separate: a fact is +immutable and provider-shaped, an entitlement is derived and Mosaic-shaped, and +a contract that mixed them would make every projection-rule change a fact +migration. + +Canonical artifacts: + +- `protocol/schema/authoritative-entitlement/v1/snapshot.schema.json` +- `protocol/schema/authoritative-entitlement/v1/sync-request.schema.json` +- `protocol/schema/authoritative-entitlement/v1/check.schema.json` +- `protocol/schema/authoritative-entitlement/v1/subscription.schema.json` +- `protocol/schema/authoritative-entitlement/v1/restore.schema.json` +- `protocol/schema/authoritative-entitlement/v1/compatibility-manifest.schema.json` +- `protocol/compatibility/authoritative-entitlement/v1.json` +- `protocol/fixtures/authoritative-entitlement/v1/` +- `protocol/authoritative-entitlement/CHANGELOG.md` + +No platform type name appears anywhere in the contract. There is no StoreKit, +Play Billing, SwiftUI, Compose, or Flutter vocabulary, and **no provider status +string is admissible**: a provider concept enters only as a member of a closed +Mosaic enumeration. Nothing in the contract is executable. + +## The one rule that matters most + +> **Any rejection yields `accessState: unknown` and preserves the cache. Never +> `inactive`.** + +This is normative, it applies to every reader, and it is pinned in the manifest +as `readerPolicy.rejectedRecord: "reportUnknownPreserveCache"` and +`readerPolicy.inactiveInference: "forbidden"`. + +`inactive` is a claim about a person: it means Mosaic looked, found no +qualifying source, and is confident. It may only ever be the result of a +snapshot Mosaic issued and the reader fully accepted. It is never inferred from +a network failure, a timeout, an expired cache, an unknown field, an unknown +enumeration member, a digest mismatch, an unsupported version, or a rejected +document. Every one of those is `unknown`. + +The distinction is not stylistic. A reader that collapses "I could not find out" +into "you do not have it" turns every Mosaic outage into a mass revocation +experienced by paying customers, and does so most reliably at exactly the moment +Mosaic is least able to notice. The validator enforces the rule against the +contract itself: `validateFailClosedVocabulary` fails if any reader policy value +resolves to `inactive`, and fails if the persisted-state enumeration ever gains +`unavailable`. + +## Envelope and record types + +Every document is an envelope: + +```json +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": {} +} +``` + +`additionalProperties` is `false` at every level. The record-type set is closed +at seven members and is pinned by the manifest as well as by the schemas: + +| Record type | Schema | Meaning | +| --- | --- | --- | +| `entitlementSyncRequest` | sync-request | What an SDK asks for | +| `customerEntitlementSnapshot` | snapshot | The immutable authoritative view of one customer's access | +| `snapshotUnchanged` | snapshot | The conditional-request answer confirming a cached snapshot | +| `entitlementCheckRequest` | check | A focused multi-key access question | +| `entitlementCheckResult` | check | Its answer, never a bare boolean | +| `subscriptionSnapshot` | subscription | The projected state of one Subscription Instance | +| `restoreResult` | restore | The outcome of a restore, on two independent axes | + +Five schemas rather than one follows the Billing Ingestion precedent. Each is a +self-describing envelope pinning its own `recordType` subset, so a reader +dispatches on the envelope alone and a document matches exactly one schema. + +## Four state axes, not one flat enum + +A provider lifecycle does not collapse into a single value without lying. A +subscription can be cancelled and still grant access; it can be in a grace +period, which grants access, or in billing retry, which does not, and both are +"payment failed". So state is four independent axes plus an explanation. + +| Axis | Members | Answers | +| --- | --- | --- | +| `accessState` | `active`, `inactive`, `unknown`, `unavailable` | Does this grant access right now? | +| `lifecycleState` | `trialing`, `active`, `grace_period`, `billing_retry`, `paused`, `expired`, `revoked`, `refunded`, `superseded`, `unknown` | What is the provider doing? | +| `renewalIntent` | `auto_renew_enabled`, `auto_renew_disabled`, `provider_managed`, `paused`, `unknown` | Will it renew? | +| `billingState` | `current`, `retrying`, `grace`, `failed`, `refunded`, `revoked`, `unknown` | What is happening to the money? | + +Every axis is closed and deliberately over-provisioned: adding a member is a +breaking change requiring Authoritative Entitlement `2`, so the members that +might be needed are declared now. + +**`unavailable` is not customer state.** It says the authoritative service could +not answer — billing disabled for the Environment, a projection failure, an +outage. It is therefore admissible only on a read-time response +(`entitlementCheckResult`, and a subscription snapshot's `accessState`) and is +structurally impossible inside a `customerEntitlementSnapshot` entry, whose +state vocabulary is the separate three-member `persistedEntitlementState`. An +immutable record of a service failure would be a service failure remembered +forever as customer state. + +### Uncertainty + +Unknown and unavailable must remain explainable, so both carry an `uncertainty`: + +```json +{ + "reason": "identity_unresolved", + "since": "2026-07-28T10:30:00.000Z", + "expectedResolution": "operator_action", + "diagnosticCode": "entitlement.identity.conflict_open" +} +``` + +`reason` is closed: `none`, `provider_unavailable`, `missing_fact`, +`identity_unresolved`, `product_unresolved`, `conflicting_facts`, +`projection_failed`, `stale_validation`, `unsupported_provider_state`. A +definite state carries `reason: "none"` and no `since`; a non-definite state +requires both. The schema enforces the pairing in both directions. + +**`product_unresolved` is reachable by an SDK reader**, unlike the +`restoreResult.outcome` member of the same name, which in Phase 9B only the +server surface can state (recorded under "Known consumer limitations" in the +[changelog](../../protocol/authoritative-entitlement/CHANGELOG.md)). A validated +purchase whose Mosaic Product mapping is missing or ambiguous projects to an +entry with `state: "unknown"` and this reason, and a client reads it directly +from the snapshot. Two consequences for a reader: the matching +`recoveryAction` is `fixProductMapping`, which only an operator can perform, so +retrying the sync never clears it and a client must not loop on it; and the +state stays `unknown`, never `inactive` — a catalogue defect is Mosaic failing +to answer, not the customer losing access. + +### Schema-level invariants + +Three invariants are `if`/`then` rules in the canonical schema rather than +prose, so a third-party validator with no Mosaic code enforces them: + +- `lifecycleState: "revoked"` requires `accessState: "inactive"` **and** + `revocationEffectiveAt`. Revocation is the one transition that is never + policy-dependent. +- `lifecycleState: "grace_period"` requires `gracePeriodEnd`. Grace grants access + on both providers, so a grace state without an end is an unbounded grant. +- `accessState` of `unknown` or `unavailable` requires an `uncertainty.reason` + other than `none`. + +The subscription schema adds the same treatment for `billing_retry` +(`billingRetryStart`), `paused` (`pauseEffectiveAt`, **and `storePlatform: +"google_play"`** — pause does not exist on Apple, so an Apple snapshot claiming +it is a normalization defect), `expired`, `refunded`, and `superseded`, plus +`lifecycleState: "unknown"` implying a non-definite `accessState`. + +## Entries and sources + +An **entry** is one Entitlement's state for one customer. A **source summary** +is one reason the customer holds it. + +Mosaic Product identity and Subscription Instance identity live **on source +summaries only** and are deliberately absent from entries. This is a considered +deviation from the orchestration prompt's entry field list: when several sources +grant one Entitlement — a monthly subscription, a lifetime purchase, and a +family-shared source — duplicating Product identity onto the entry creates two +places that can disagree, and the entry is the one a reader trusts. Entries +carry `sourceIds`; sources carry the identity. + +Source identity is `(purchase lineage, Product, grant version)` and never a +transaction fact identifier, so a second fact for one purchase — a mapping +correction, a validator-version bump — cannot double-grant. + +### Never-projected customers + +A customer whose first projection has not committed receives a +`customerEntitlementSnapshot` at `snapshotVersion: 0`. This placeholder carries +no entries or sources, uses `projectionStatus.state: "pending"`, and therefore +states only that Mosaic has not projected the customer yet. An absent key remains +`unknown`; the placeholder never means inactive access. + +Issued snapshots start at version 1. All SDKs cache version 0 normally and replace +it through the same monotonic comparison used for every other update. A sync +request may send `knownSnapshotVersion: 0`; the server reissues the placeholder +instead of returning `snapshotUnchanged`, because there is no projected state +whose freshness should be extended. + +### Effective end + +`endKnown` and `effectiveEnd` together express three different things, and the +difference matters to a paying customer: + +| `endKnown` | `effectiveEnd` | Meaning | +| --- | --- | --- | +| `true` | present | The Entitlement ends then. Safe to display. | +| `true` | **absent** | The Entitlement is **permanent**. A permanent source contributes. | +| `false` | forbidden by schema | The end is genuinely uncertain. Display no expiry at all. | + +Reporting the subscription's end date when a lifetime purchase also contributes +would tell a lifetime purchaser their access expires next month. +`snapshots/permanent-source-no-finite-expiry.json` pins the behaviour. + +### The entry-to-source graph + +Semantic rules the schema cannot express, all enforced by the validator: + +- `sourceCount` equals `sourceIds.length`. +- Every `sourceId` resolves to a source the snapshot carries, and every source is + accounted for by at least one entry. +- An `active` entry has at least one contributing source with + `sourceState: "granting"`. An active Entitlement always has a reason. +- An `inactive` entry has no contributing source that is `granting` **or** + `unknown`. Unresolved evidence yields `unknown`, never `inactive` — the top + rule, applied inside the projection rather than only at the reader. +- Entries ascend by `entitlementKey` and sources ascend by `sourceId`. + +### Test sources + +Every source carries `isTestSource`. The Apple and Google situations are +structurally different and both are reported the same way: + +- **Apple** sandbox transactions — including every TestFlight purchase — cannot + reach a production-mode Environment at all. Phase 9A quarantines them at + ingestion. This is a fraud control: Apple sandbox accounts are free and + self-service. TestFlight testers get access by pointing TestFlight builds at a + staging Environment. +- **Google** has no sandbox. License-tester purchases, allowlisted by an operator + in Play Console, arrive as ordinary production transactions and are + distinguishable *only* by this flag. + +So `isTestSource` is not decoration: on Google it is the only thing separating a +test grant from a paid one, and it appears on the server API, the SDK result, +and the webhook payload. + +## Entitlement keys are project data + +`entitlementKey` reuses the Commerce Configuration key pattern unchanged +(`^[a-z][a-z0-9_.-]*$`, ≤64), so one vocabulary spans catalogue and access; a +test asserts the two patterns stay identical. + +Unlike every enumeration in this contract, **an unrecognized key is accepted** +(`readerPolicy.unknownEntitlementKey: "acceptAsProjectData"`). Keys are Project +data, not contract vocabulary. Rejecting an unknown key would make *defining a +new Entitlement* a breaking change for every already-shipped SDK — an +operator-facing action silently breaking readers is the opposite of what +fail-closed reading is for. + +## Canonical serialization and `contentDigest` + +`contentDigest` is SHA-256 over the canonical serialization of the snapshot +payload with `contentDigest` removed. `subscriptionSnapshot.checksum` is the same +derivation over its own payload. + +It is **corruption and binding detection, not authentication**. It covers +`billingCustomerId`, `projectId`, `environmentId`, and `snapshotVersion`, so a +snapshot cannot be accepted into another customer's, Project's, or Environment's +cache even if a field were altered in transit. It proves nothing about origin: +anyone can compute it. + +The canonical form is pinned in the manifest's `canonicalSerialization` block +because five implementations must produce byte-identical input: + +- Minified JSON: no whitespace. +- Object members ascending by UTF-16 code unit, **at every depth**. +- **Array order preserved.** Array order is normative in this contract, so a + serializer must never sort an array — doing so would silently repair a + document the semantic validator exists to reject. +- Absent members omitted. **`null` is never emitted**; absent and null are + different bytes and therefore different digests. +- Timestamps with exactly three fractional digits and a literal `Z`. The + precision is fixed in the schema for this reason: the same instant written + with different precision would digest differently. +- Integers in shortest decimal form, no exponent. The contract contains no + non-integer numbers. +- Minimal JSON string escaping; non-ASCII is never escaped into `\u` sequences. + +Reference vectors: +[`packages/test-fixtures/src/entitlement-snapshot-digest-vectors.json`](../../packages/test-fixtures/src/entitlement-snapshot-digest-vectors.json). + +## Freshness, caching, and bounded grace + +A snapshot carries `issuedAt`, `asOf`, `refreshAfter`, `validUntil`, and an +optional `staleGraceSeconds`. + +| Window | Cache state | Behaviour | +| --- | --- | --- | +| before `refreshAfter` | `fresh` | Serve; do not refresh. | +| `refreshAfter` → `validUntil` | `refresh_recommended` | Fully valid; refresh opportunistically. | +| `validUntil` → `validUntil + staleGraceSeconds` | `stale_within_grace` | Previously active Entitlements stay active and **must be surfaced as stale**. | +| after that | `expired` | Report `unknown`. Never `inactive`. | + +`staleGraceSeconds` defaults to 24 hours, so the fourth row is a real band in the +shipped configuration rather than a theoretical one. + +Per OD-5, **bounded grace is the shipped policy**, and the defaults are +`refreshAfter` = issuance + 1 h, `validUntil` = issuance + 7 d, and +`staleGraceSeconds` = 86400 (24 h). In Phase 9B all three are configured +**deployment-wide** server-side, not per Environment: one setting applies to +every Project and Environment a deployment serves. Per-Environment configuration +is a tracked follow-up, not a shipped capability — a reader must not assume two +Environments can be given different windows, and an operator who needs that today +has no supported way to express it. A zero grace default would have shipped the +strict policy under a bounded-grace decision, so the default is stated rather +than left to fall out of an absent field. + +`staleGraceSeconds` absent means zero, so a producer that intends bounded grace +states it explicitly. Zero **is** the strict policy, expressed through the same +fields rather than as a separate mode; the server-only policy is guidance for +irreversible actions rather than a contract state. + +**The 30-day hard maximum is on the combined horizon.** `maxValidUntilSeconds` +and `maxStaleGraceSeconds` bound each field individually, but only +`maxCacheHorizonSeconds` stops them composing: `(validUntil - issuedAt) + +staleGraceSeconds` may never exceed 2592000 seconds, or a 30-day validity and a +30-day grace window would together license 60 days during which a device serves +access Mosaic never confirmed. The semantic validator enforces it, on +`snapshotUnchanged` as well as on a snapshot — otherwise the bound could be +evaded by confirming a snapshot rather than reissuing it. + +Clock skew tolerance is 60 seconds, applied in the direction that favours the +user. A device clock earlier than `issuedAt` by more than the tolerance is +*unreliable*, and an unreliable clock is not a fifth cache state: it forces +expired-equivalent behaviour. A naive implementation computes a negative cache +age, concludes "fresh", and hands unlimited offline access to anyone willing to +change their device time. + +Reference vectors: +[`entitlement-freshness-vectors.json`](../../packages/test-fixtures/src/entitlement-freshness-vectors.json). + +## Cache acceptance + +`snapshotVersion` is a monotonic integer **per customer per Environment** and is +the sole cache-monotonicity key. `entityTag` is an opaque HTTP validator: compare +it for equality, never for magnitude. + +Checks run in this order, and the order is normative: + +1. `unsupportedContractVersion` → reject, preserve cache +2. `customerBindingMismatch` (customer, Project, or Environment) → reject, + **clear** cache +3. `contentDigestMismatch` → reject, preserve cache +4. `snapshotVersionNotNewer` (older *or equal*) → reject, preserve cache +5. `asOfRegression` → reject, preserve cache +6. accept, replacing the cache atomically + +Binding is checked before version for a specific reason. Snapshot versions are +monotonic *per Environment*, so a staging snapshot legitimately starts at `1`; +diagnosing that as a version regression would be wrong and would preserve a +production cache under a staging identity. A binding mismatch is also the one +rejection that clears rather than preserves, because continuing to serve the +previous customer's access after an identity change is precisely the leak the +rule exists to prevent. + +Acceptance is atomic: `readerPolicy.partialAcceptance` is `forbidden`. A reader +never keeps the entries it understood from a document it rejected. + +Reference vectors: +[`entitlement-cache-decision-vectors.json`](../../packages/test-fixtures/src/entitlement-cache-decision-vectors.json). + +### `snapshotUnchanged` + +A sync whose cached snapshot is still current is answered with a **`200` +carrying the `snapshotUnchanged` record**, which has no entries but does carry +refreshed `refreshAfter` and `validUntil` values. A confirmed-current snapshot +must not expire merely because it was confirmed instead of resent. A snapshot +whose version *equals* the cached version is not "newer" and is not accepted; +confirming it is what this record is for. + +The refreshed window travels **in the record, not in headers**. No header name +for freshness exists anywhere in the frozen schemas, so an SDK that looked for +one would be reading a field this contract does not define. + +### Cross-customer cache state + +When a snapshot fails the customer, Project, or Environment binding check, the +SDK-facing cache state is spelled **`differentCustomer`** — that is the canonical +spelling across all three SDKs, alongside `fresh`, `refreshRecommended`, +`staleWithinGrace`, `expired`, `missing`, and `invalid`. + +These are SDK API states in camelCase. The freshness reference vectors use +snake_case identifiers (`fresh`, `refresh_recommended`, `stale_within_grace`, +`expired`) for the four freshness bands because they are vector-file data rather +than a public API surface; the mapping is one-to-one and the extra states +(`missing`, `invalid`, `differentCustomer`) are decided by cache acceptance +rather than by freshness. Clock unreliability is a diagnostic that forces +expired-equivalent behaviour, not an eighth state. + +## Sync and check + +`entitlementSyncRequest` carries the contract negotiation +(`supportedAuthoritativeEntitlementContracts: ["1"]`) **in the body**. The +Configuration Delivery capability request is untouched. + +### The SDK-conformant sync form + +Negotiation lives in the body, so the sync surface is a `POST`. This is the +**only** conformant form for an SDK: + +```http +POST /v1/sdk/billing/entitlements +Authorization: Bearer mcat_<43 base64url characters> +Mosaic-SDK-Key: +Content-Type: application/json + +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementSyncRequest", + "payload": { + "knownSnapshotVersion": 4, + "entityTag": "cs-0001-v4", + "supportedAuthoritativeEntitlementContracts": ["1"], + "correlationId": "fixture-correlation-0004" + } +} +``` + +The response is one of exactly two records, both returned with `200`: + +- `customerEntitlementSnapshot` — the full snapshot. +- `snapshotUnchanged` — when `knownSnapshotVersion` matches the server's current + version, carrying the refreshed freshness windows. + +**`POST` is the only conditional mechanism.** There is no conditional `GET` and +no `304` on this surface. The `GET` read exists for non-SDK callers and always +answers with a plain `200` carrying the full `customerEntitlementSnapshot`; it +is unconditional. + +A conditional `GET` was considered and removed. A `304` carries no body, so it +cannot carry the refreshed `refreshAfter` and `validUntil`, and no freshness +header name exists anywhere in the frozen schemas to carry them instead — so the +`304` path could confirm a snapshot without being able to say how long the +confirmation was good for, which is the one thing the unchanged response exists +to communicate. It also had nowhere to put the contract negotiation. Both +problems are solved by `snapshotUnchanged`, which is a body, so the second +mechanism earned nothing and was a second place for freshness semantics to +drift. + +`billingCustomerId` on the request is a **hint**. The server derives the customer +from the Customer Access Token and verifies the hint against it, refusing a +mismatch. A caller can never select a customer by asserting an identifier; see +[Customer Access Token Contract v1](customer-access-token-v1.md). + +### An absent Entitlement key is `unknown` + +**Normative.** An `entitlementKey` that does not appear in a snapshot's `entries` +array reads as state **`unknown`**. It is never read as `inactive`. This holds +whether or not `requestedEntitlementKeys` narrowed the response. + +Absence is not a statement. A key can be missing because the Project never +defined it, because the projection could not evaluate it, because the request +narrowed it away, or because the reader is asking about a key that belongs to a +different Project entirely — and a snapshot gives a reader no way to tell those +apart. Treating the silence as a denial is the same mistake as treating a +rejected document as a denial, arrived at from the other direction. + +An `inactive` entry is the opposite of absence: it is Mosaic stating that it +looked, found no qualifying source, and is confident. That statement is present +in the document, with a `primaryExplanation` — usually `no_qualifying_source` — +and a `sourceCount`. + +The narrowed case makes the difference concrete. Given +`sync/sync-request-requested-keys.json`, which narrows to +`["pro", "pro_lifetime"]`: + +| Reader asks about | Snapshot contains | Reads as | +| --- | --- | --- | +| `pro` | an entry with `state: "active"` | `active` | +| `pro_lifetime` | an entry with `state: "inactive"`, `sourceCount: 0` | `inactive` | +| `team_seats` | nothing — it was narrowed away | **`unknown`** | + +A reader that concluded `inactive` for `team_seats` would have converted its own +request parameter into a revocation. + +The same rule applies to `entitlementCheckResult`, from the other side: the +response contains one result per requested key, so a key the caller asked about +that is missing from `results` is `unknown` rather than `inactive`. A named +result carrying `state: "inactive"` is a real answer and is trusted as one; this +is why `checks/check-result-active.json` reports `pro_lifetime` as `inactive` +with `no_qualifying_source` rather than omitting it. + +`entitlementCheckResult` returns per-key `state`, `primaryExplanation`, +`sourceCount`, `snapshotVersion`, and `asOf`. There is no bare boolean anywhere +in this contract. When no snapshot can be read at all, `snapshotVersion` is +absent and every result is `unavailable` — a rule the semantic validator +enforces, so "no version" and "definitely inactive" can never be confused. + +## Restore + +`restoreResult` reports two independent axes: + +- `outcome`: Mosaic's authoritative answer — `restored`, + `no_additional_purchases`, `validation_pending`, `identity_unresolved`, + `product_unresolved`, `provider_unavailable`, `failed`. +- `providerOutcome`: what the native provider restore did — `completed`, + `no_purchases_found`, `cancelled`, `failed`, `unsupported`, `not_attempted`. + +`restored` requires `billingCustomerId`, `completedAt`, **and** +`snapshotVersion`: the accepted snapshot is the evidence that makes the outcome +authoritative rather than hopeful. A successful native restore whose facts have +not yet been validated is `validation_pending` with a `pendingValidationCount`, +not `restored`. The cross-platform poll bound is 3 attempts / ~6 s before +reporting `validation_pending`. + +## Reader sequence + +1. Require exact `authoritativeEntitlementContractVersion: "1"`. +2. Validate the complete closed document. Reject the whole record on any unknown + version, record type, field, or enumeration member — except an unrecognized + `entitlementKey`, which is accepted. +3. Verify `contentDigest` and the customer, Project, and Environment binding. +4. Apply the cache-acceptance order above. +5. Evaluate freshness against the device clock with 60 s tolerance. +6. On **any** rejection: report `accessState: unknown`, preserve the cache + (except on a binding mismatch, which clears it and reports the cache state as + `differentCustomer`), and emit a diagnostic. +7. For a key **absent** from an accepted snapshot's `entries`, report `unknown`. +8. Never report `inactive` except from an entry, or a check result, that says so + in a document the reader fully accepted. + +## What this contract is not + +- **Not a bearer credential.** An Access Decision Snapshot is a read model; + possessing it authorizes nothing, and a backend must never accept one + presented by a client as proof of access + (`readerPolicy.snapshotAsCredential: "forbidden"`). +- **Not a replacement for server authorization.** The SDK cache supports UI + continuity and feature gating. Protected backend resources are authorized by + the application's own server. +- **Not provider state.** No provider status string is admissible anywhere. +- **Not a source of truth for provider facts.** Those stay in Billing Ingestion + `1`, which this contract does not `$ref` — both are drafts, and a draft that + referenced another draft would inherit its lifecycle. The safe-diagnostic shape + is copied rather than referenced for the same reason. + +## Fixtures + +`protocol/fixtures/authoritative-entitlement/v1/` — 37 canonical fixtures across +`snapshots/`, `sync/`, `checks/`, `subscriptions/`, `restores/`, and 27 in +`invalid/`. + +The behavioural fixtures are the ones worth reading first: +`subscriptions/cancelled-access-still-active.json` (auto-renew off, access +active), `snapshots/multiple-active-sources.json`, +`snapshots/refund-of-one-source-other-remains-active.json`, +`snapshots/permanent-source-no-finite-expiry.json`, and +`checks/check-result-unavailable-billing-disabled.json`. + +Two invalid fixtures are recorded in `invalid/rejection-layers.json` as +**semantic** rather than schema rejections, which is the honest classification: +`older-snapshot-version-rejected.json` (a version that regresses against its own +predecessor — cross-field arithmetic no JSON Schema can express) and +`different-customer-rejected.json` (a digest computed over a different +`billingCustomerId`, which is exactly the binding failure the digest exists to +catch). See [fixture lifecycle](fixture-lifecycle.md). + +## Related documents + +- [Customer Access Token Contract v1](customer-access-token-v1.md) +- [Billing State Webhook Contract v1](billing-state-webhook-v1.md) +- [Billing Ingestion Contract v1](billing-ingestion-v1.md) +- [Compatibility policy](compatibility-policy.md) · [Versioning](versioning.md) diff --git a/docs/protocol/billing-ingestion-v1.md b/docs/protocol/billing-ingestion-v1.md index d093d1f3..5f0578b2 100644 --- a/docs/protocol/billing-ingestion-v1.md +++ b/docs/protocol/billing-ingestion-v1.md @@ -283,6 +283,65 @@ populates or persists either one, and no fixture carries either one. `unsupported_capability` detail, that is additive REST vocabulary and does not require a contract version bump. +### The customer token is transport, not a record field + +Phase 9B associates validated provider facts with a Billing Customer. One of the +accepted evidence types is **submission context**: the request that submitted an +observation was authenticated as a known customer. + +An SDK therefore attaches its current Customer Access Token, when it holds one, +to the observation-submission request: + +```http +POST /v1/sdk/billing/observations +Mosaic-SDK-Key: +Mosaic-Customer-Token: mcat_<43 base64url characters> +Content-Type: application/json +``` + +`Mosaic-Customer-Token` is **optional**. An observation submitted without it is +valid, is validated exactly as before, and simply yields no submission-context +evidence; the fact still exists and can be associated later through a restore or +an explicit link. Nothing about Phase 9A's behaviour changes when the header is +absent, which is the whole point — 9A submissions remain conformant. + +**No Billing Ingestion record schema changes.** The frozen draft stays frozen: +no observation record gains a field, no fixture changes, and +`clientTransactionObservation` still rejects any unknown property. This follows +the established rule that +[transport is not contract](#compatibility-and-versioning) — the same rule under +which a `406` negotiation detail is additive REST vocabulary rather than a +contract version bump. + +#### Why a header and never the body + +The credential travels in a header specifically because an observation body does +not behave like a request. It is: + +- **Persisted.** The submitted record is stored as the raw input a validation was + performed against. A credential in the body would be a bearer token written to + the ledger, and the ledger is append-only by design — there is no path to + redact it later. +- **Replayable.** Observations are re-validated and replayed. A credential + embedded in a replayed body would be re-presented long after it expired, which + either fails confusingly or, worse, succeeds against a token whose lifetime + should have ended the association. +- **Digested.** The body participates in idempotency and fact identity. A + rotating credential inside it would make two submissions of the same purchase + look like two different purchases. +- **Sealed and forwarded.** Bodies are encrypted at rest and surfaced to + operators through diagnostics. Headers are stripped at the transport boundary + and never logged. + +The header is read once, at the transport boundary, to resolve the Billing +Customer; the resolved association is recorded as evidence and the token itself +is never persisted, never logged, and never enters the record. This is the same +posture the contract already takes toward every other credential — see +[Deliberately absent](#deliberately-absent). + +The token's shape, lifetime, scoping, and revocation are defined by +[Customer Access Token Contract v1](customer-access-token-v1.md). + ### Quarantine vocabulary is not REST vocabulary `quarantineRecord.reason` is the **contract** vocabulary: a closed, cross-SDK diff --git a/docs/protocol/billing-state-webhook-v1.md b/docs/protocol/billing-state-webhook-v1.md new file mode 100644 index 00000000..5bfa61c8 --- /dev/null +++ b/docs/protocol/billing-state-webhook-v1.md @@ -0,0 +1,265 @@ +# Billing State Webhook Contract v1 + +Billing State Webhook Contract `1` is how Mosaic tells an application backend +that a customer's committed authoritative state changed. It is a **draft** and +reaches `approved` only through an explicit product-owner decision. + +Scope is the **minimal slice** approved as OD-1(b): one emitted event type, HMAC +signing, at-least-once delivery, attempt history, API-only destinations, no +dashboard UI. The roadmap places webhooks in Gate 9C; the split is deliberate and +recorded. + +Canonical artifacts: + +- `protocol/schema/billing-state-webhook/v1/event.schema.json` +- `protocol/schema/billing-state-webhook/v1/delivery.schema.json` +- `protocol/schema/billing-state-webhook/v1/compatibility-manifest.schema.json` +- `protocol/compatibility/billing-state-webhook/v1.json` +- `protocol/fixtures/billing-state-webhook/v1/` +- `protocol/billing-state-webhook/CHANGELOG.md` + +## An event is a notification, not an authority + +> **The event says something changed. The snapshot says what it is.** + +A consumer reacts to an event by re-reading the Customer Entitlement Snapshot. +It never grants or revokes access from the event body alone. This is pinned as +`consumerTolerance.authoritativeState: "reReadSnapshot"`. + +That single decision is what makes everything else safe: delivery can be +duplicated, delayed, or reordered without any of it changing what a customer can +do. + +## Envelope and record types + +```json +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": {} +} +``` + +| Record type | Meaning | +| --- | --- | +| `billingStateEvent` | The committed change Mosaic transmits | +| `webhookDeliveryAttempt` | One recorded attempt, **never transmitted** | + +The delivery-attempt record is dashboard- and API-facing only. It carries no +destination URL and no signing secret — attempt history is read by operators far +more often than by whoever configured the destination — and a test asserts no +property name in it matches `url`, `secret`, `signature`, `endpoint`, or `token`. + +## Event types: ten declared, one emitted + +The enumeration is closed at ten members, and Phase 9B emits exactly one: + +| Event type | 9B | +| --- | --- | +| `customer.entitlements.changed` | **emitted** | +| `subscription.state.changed` | reserved | +| `subscription.period.changed` | reserved | +| `subscription.renewal_intent.changed` | reserved | +| `subscription.expired` | reserved | +| `subscription.revoked` | reserved | +| `subscription.refunded` | reserved | +| `customer.billing_identity.conflict` | reserved | +| `customer.projection.failed` | reserved | +| `customer.projection.recovered` | reserved | + +All ten are declared **now** because adding an enumeration member later costs a +contract version, which matches house style everywhere else in Mosaic. The +manifest separates the two facts: `eventTypes` is a promise about the vocabulary, +`emittedEventTypes` is a promise about behaviour, and they are deliberately +different sizes. + +No member names a provider. An application backend that had to branch on whether +a change came from Apple or Google would be reading a provider integration, not a +Mosaic contract; a validator guard rejects any event type matching +`apple|google|storekit|play|itunes`. + +## Event payload + +`eventId`, `eventType`, `projectId`, `environmentId`, `billingCustomerId`, +optional `subscriptionInstanceId`, `snapshotVersion`, optional +`previousSnapshotVersion`, `projectionRuleVersion`, `occurredAt`, `createdAt`, +`changedEntitlements[]`, `stateSummary`, `sourceReason`, optional `isTestSource`, +`correlationId`, optional `diagnostics[]`. + +The contract version lives on the envelope only. Duplicating it into the payload +would create two places that can disagree. + +`changedEntitlements` entries carry `entitlementKey`, `previousState`, and +`currentState`. `previousState` may be `absent`, which is how a first grant is +reported without pretending the customer was previously `inactive`. + +`stateSummary` carries the same four axes as +[Authoritative Entitlement v1](authoritative-entitlement-v1.md) — `accessState`, +`lifecycleState`, `renewalIntent`, `billingState`, `uncertainty` — as a safe +summary for routing and logging. The authoritative answer is still the snapshot. + +**`accessState` here is narrower than the read-time one: `active`, `inactive`, +`unknown`, and never `unavailable`.** `unavailable` means Mosaic could not answer +a read, and an event is not a read — an event exists only because a projection +committed a new snapshot, so the projection did answer. The worst an event can +honestly say about an axis is `unknown`, with the `uncertainty` that explains +why; a schema-level `if`/`then` requires a non-`none` reason in that case. +Admitting `unavailable` would put a service-delivery state on a record that is +not authoritative to begin with, and a consumer told to be tolerant would have no +reason to distrust it. A validator guard fails the build if the enumeration is +ever re-widened. + +**An event must report a change.** A no-change projection creates no snapshot and +emits no webhook, so an event whose entitlement states are all unchanged is a +producer defect — unless `sourceReason` is `subscription_period_changed`, +`renewal_intent_changed`, or `grant_version_changed`, which are real changes that +legitimately leave every access state untouched. The semantic validator enforces +exactly that. + +### Never in an event + +Provider secrets, raw purchase tokens, private keys, and complete raw provider +payloads. `additionalProperties: false` blocks the field; the ported +forbidden-value walk blocks a signed-payload-shaped **value** smuggled into an +allowed field, which is what `invalid/raw-purchase-token-in-event.json` pins. + +Only the *values* half of the Billing Ingestion walk is ported. Billing Ingestion +also bans `entitlement`, `subscription`, and `customer` field names; here that +vocabulary is legitimate and banning it would ban the contract. + +## Signing + +```http +POST /your-endpoint +Mosaic-Signature: t=1785243603, v1=<64 lowercase hex characters> +``` + +```text +signature = HMAC-SHA256(secret, "v1" + "." + t + "." + eventId + "." + rawBody) +``` + +- **Raw body, exactly as received.** Parsing and re-serializing changes the + bytes — whitespace, member order, Unicode escaping — and every genuine delivery + then fails verification. +- **The event ID is inside the signed payload**, so a captured signature cannot + be replayed onto a different event body even within the replay window. +- **The timestamp is inside it too**, which is what makes the replay window + enforceable. +- The secret is used as **UTF-8 bytes verbatim**, not hex- or base64-decoded. +- Output is lowercase hexadecimal. Several HMAC libraries emit uppercase; compare + case-insensitively or lowercase your own output. + +### Verification + +1. Reject if `t` is more than **300 seconds** from your own clock, before + comparing signatures. +2. Compute the expected signature over the raw body. +3. **During rotation the header carries one `v1` parameter per active key.** + Accept if **any** verifies. A verifier that reads only the first parameter + drops every delivery signed with the new key. +4. Compare in constant time. A byte-by-byte early-exit comparison leaks the + expected value over enough requests. +5. Only then parse the body. + +Reference vectors: +[`packages/test-fixtures/src/webhook-signature-vectors.json`](../../packages/test-fixtures/src/webhook-signature-vectors.json) +— eight vectors including a tampered body, a changed event ID, a changed +timestamp, a rotation key, a non-ASCII body, and a non-ASCII secret. A test +asserts the canonical vector signs the bytes of +`events/entitlement-activated.json` **with the file's trailing newline removed** +(the builder applies `trimEnd()`), so the vectors and the fixture cannot drift. +The trailing newline is a property of the file on disk, not of a delivery: a real +delivery signs the exact body bytes it transmits, whatever they are, and a +verifier must never trim, re-encode, or re-serialize the body before comparing. + +A test endpoint is supported so an integrator can verify a signature before any +real state change depends on it. + +## Delivery + +At-least-once. **Exactly-once is never promised** +(`producerPolicy.exactlyOnceDelivery: "neverPromised"`). + +- `eventId` is stable across every attempt and every manual replay. It is the + consumer's deduplication key. +- Order by `snapshotVersion`, never by `createdAt` or arrival. A consumer that + has applied a higher version ignores a lower one. +- Retry with exponential backoff and jitter, bounded timeout, bounded attempts. +- Attempt status is `pending`, `succeeded`, `failed`, `exhausted`, or `skipped`. + Exhaustion is terminal and means the attempts actually ran out — the semantic + validator rejects an `exhausted` record whose attempt count has not reached its + maximum. +- `responseExcerpt` is at most 240 control-character-free characters, kept only + so an integrator can see why their endpoint refused. It is never parsed. +- **Delivery failure never rolls back customer state** + (`delivery.failureIsolation: "deliveryNeverRollsBackState"`). Delivery is a + notification path, not a commit path. + +**The `webhookDeliveryAttempt` schema is the specification for the +delivery-attempt surface, not a description of it.** It is the one record type +in this contract that is never transmitted anywhere: it exists so operators can +read attempt history, and the 9B API serves exactly this record inside the +operator response envelope. Where the Go surface and this schema disagree about a +field name, a status spelling, or a bound, **the schema is authoritative and the +implementation conforms to it** — the vocabulary an operator sees in the +dashboard, in the API, and in these fixtures has to be one vocabulary, and the +contract is where it is defined. Adding an operator-only field to the API without +adding it here is a drift, not an extension. + +Destinations are HTTPS-only and validated against the SSRF policy: RFC1918, +loopback, link-local, CGNAT, ULA, and IPv4-mapped addresses are denied, DNS is +resolved and pinned per attempt, redirects are not followed, and time and size +are bounded. A self-hosted allowlist flag exists for operators who genuinely need +an internal destination. + +## Consumer tolerance: a documented exception to fail-closed reading + +**Approved as OD-16.** Everywhere else in Mosaic, a reader that does not fully +understand a document rejects it. Webhook consumers are the documented exception: + +| | Producer (Mosaic) | Consumer (your backend) | +| --- | --- | --- | +| Unknown field | `rejectRecord` | **ignore** | +| Unknown event type | `rejectRecord` | **ignore** | +| Unknown enumeration member | `rejectRecord` | **ignore** | +| Authoritative state | — | **re-read the snapshot** | +| Ordering | — | ignore older `snapshotVersion` | +| Duplicates | — | deduplicate by `eventId` | +| Signature | — | **verify before parsing** | + +The asymmetry is justified by the top rule of this contract: the event is not +authoritative, the snapshot is. A consumer that rejected an event carrying a +field it had not seen would stop reacting to real state changes in order to +protect itself from information it was free to ignore. Ignoring the unknown and +re-reading the snapshot reaches the same fail-safe outcome by the opposite route. + +Signature verification is the one thing a consumer must **not** be tolerant +about, and it happens before the body is parsed at all. + +Both columns are machine-checked: `producerPolicy` and `consumerTolerance` are +separate pinned blocks in the manifest, so the exception stays an exception +rather than becoming a habit. See +[compatibility policy](compatibility-policy.md#webhook-consumer-tolerance-is-a-documented-exception). + +## Fixtures + +`protocol/fixtures/billing-state-webhook/v1/` — 13 canonical fixtures across +`events/` and `deliveries/`, and 8 in `invalid/`. + +Behavioural fixtures worth reading: +`events/subscription-cancelled-access-active.json` (cancelled, still active), +`events/expiry-extended.json` (a real change with no state change), +`events/unknown-state-transition.json` (unknown, not deactivated), and +`deliveries/retry-same-event-id.json` (attempt 2 carrying the same `eventId`). + +Invalid fixtures: `raw-purchase-token-in-event.json`, +`provider-payload-embedded.json`, `unknown-event-type.json`, +`missing-snapshot-version.json`, `response-excerpt-too-long.json`, +`exhausted-before-attempts-ran-out.json`, `snapshot-version-regresses.json`, +`created-before-it-occurred.json`. + +## Related documents + +- [Authoritative Entitlement Contract v1](authoritative-entitlement-v1.md) +- [Customer Access Token Contract v1](customer-access-token-v1.md) +- [Compatibility policy](compatibility-policy.md) · [Versioning](versioning.md) diff --git a/docs/protocol/compatibility-policy.md b/docs/protocol/compatibility-policy.md index c683c978..86d30330 100644 --- a/docs/protocol/compatibility-policy.md +++ b/docs/protocol/compatibility-policy.md @@ -26,6 +26,9 @@ carries no compatibility meaning relative to any other contract. | Contract | Version | Status | Manifest | | --- | --- | --- | --- | | Billing Ingestion | `1` | `draft` | `protocol/compatibility/billing-ingestion/v1.json` | +| Authoritative Entitlement | `1` | `draft` | `protocol/compatibility/authoritative-entitlement/v1.json` | +| Customer Access Token | `1` | `draft` | `protocol/compatibility/customer-access-token/v1.json` | +| Billing State Webhook | `1` | `draft` | `protocol/compatibility/billing-state-webhook/v1.json` | A draft contract carries **no compatibility guarantee**: it may change or disappear without a version bump, and nothing in the approved set depends on it. @@ -34,6 +37,70 @@ decision recorded in the Phase 9A review. It is optional, adds no required reference to any approved contract, and is not generated into the browser contract. See [Billing Ingestion Contract v1](billing-ingestion-v1.md). +The three Phase 9B contracts are born `draft` for the same reason and are +promoted alongside Billing Ingestion `1` once live-sandbox evidence exists. None +of them `$ref`s another draft: a draft that referenced another draft would +inherit its lifecycle, so shared shapes such as the safe-diagnostic object are +**copied** into each contract rather than referenced. See +[Authoritative Entitlement Contract v1](authoritative-entitlement-v1.md), +[Customer Access Token Contract v1](customer-access-token-v1.md), and +[Billing State Webhook Contract v1](billing-state-webhook-v1.md). + +### Unknown access is never inactive + +**Normative, and the strongest reader obligation Mosaic states.** Wherever a +reader of Authoritative Entitlement `1` rejects a document — unknown version, +unknown field, unknown enumeration member, content-digest mismatch, version +regression, expired cache, network failure — the resulting access state is +`unknown` and the previously accepted cache is preserved. It is **never** +`inactive`. + +`inactive` is a claim about a person: Mosaic looked, found no qualifying source, +and is confident. It may only be the result of a snapshot Mosaic issued and the +reader fully accepted. A reader that collapses "I could not find out" into "you +do not have it" converts every Mosaic outage into a mass revocation experienced +by paying customers, at exactly the moment Mosaic is least able to observe it. + +The one exception to *preserving* the cache is a customer, Project, or +Environment binding mismatch, which **clears** it: continuing to serve the +previous customer's access after an identity change is the leak that rule exists +to prevent. The resulting state is still `unknown`. + +This is enforced, not merely documented. +`protocol/tools/authoritative-entitlement-validation-v1.mjs` fails if any reader +policy value in the manifest resolves to `inactive`, and fails if the persisted +snapshot-entry state vocabulary ever gains `unavailable`. + +### Webhook consumer tolerance is a documented exception + +**Owner-approved (Phase 9B OD-16).** Every other contract in this repository +fails closed on both sides. Billing State Webhook `1` is asymmetric on purpose: + +| | Producer (Mosaic) | Consumer (application backend) | +| --- | --- | --- | +| Unknown field | `rejectRecord` | **ignore** | +| Unknown event type | `rejectRecord` | **ignore** | +| Unknown enumeration member | `rejectRecord` | **ignore** | +| Authoritative state | — | **re-read the snapshot** | +| Ordering | — | ignore older `snapshotVersion` | +| Duplicate delivery | — | deduplicate by `eventId` | +| Signature | — | **verify before parsing** | + +The justification is that a webhook event is not authoritative — the Customer +Entitlement Snapshot is. A consumer that rejected an event carrying a field it +had not seen would stop reacting to real state changes in order to protect itself +from information it was free to ignore. Ignoring the unknown and re-reading the +snapshot reaches the same fail-safe outcome by the opposite route, which is why +this exception is safe here and would not be safe for a delivery contract that +readers act on directly. + +Signature verification is the one place a consumer must not be tolerant, and it +happens before the body is parsed at all. + +Both halves are pinned as separate manifest blocks (`producerPolicy` and +`consumerTolerance`) so the exception stays machine-checked and cannot spread by +imitation. + ## Exact-match reading Readers match versions **exactly**. A reader declaring Paywall `0.2` accepts diff --git a/docs/protocol/customer-access-token-v1.md b/docs/protocol/customer-access-token-v1.md new file mode 100644 index 00000000..aa88d126 --- /dev/null +++ b/docs/protocol/customer-access-token-v1.md @@ -0,0 +1,259 @@ +# Customer Access Token Contract v1 + +Customer Access Token Contract `1` defines how an SDK proves it may read one +Billing Customer's authoritative Entitlements. It is a **draft** and reaches +`approved` only through an explicit product-owner decision. + +A public SDK key identifies an application. It can never select a Billing +Customer, and an application user ID is guessable, so neither is sufficient to +read someone's access. The Customer Access Token is what closes that gap. + +Canonical artifacts: + +- `protocol/schema/customer-access-token/v1/token.schema.json` +- `protocol/schema/customer-access-token/v1/compatibility-manifest.schema.json` +- `protocol/compatibility/customer-access-token/v1.json` +- `protocol/fixtures/customer-access-token/v1/` +- `protocol/customer-access-token/CHANGELOG.md` + +## The token is opaque, not signed + +**Owner-approved deviation (OD-14).** The orchestration prompt's token +requirement list says "signed". Mosaic v1 tokens are **opaque random bytes** +instead. This is recorded here as an explicit deviation rather than a quiet +substitution, and it is pinned in the manifest +(`tokenModel.signed: false`) so it cannot drift back without a visible contract +change. A validator guard fails the build if the contract ever grows a signing +vocabulary — `alg`, `kid`, `jwk`, `jws`, `signature`, a key identifier — or a +property whose name suggests the token carries access state. + +The token is: + +```text +mcat_<43 base64url characters> # 256 bits of randomness, 48 characters +``` + +Mosaic stores **only the SHA-256 digest** of the token, alongside columns for +Project, Environment, Billing Customer, audience, scopes, and expiry. There is +no header, no claim set, no signature, and nothing parseable. The token is +returned exactly once, at issuance, and Mosaic can never reproduce it. + +Why opaque beats signed here, against the prompt's own requirement list: + +| Requirement | Opaque | Signed JWS | +| --- | --- | --- | +| short lived | expiry column | `exp` claim | +| scoped to Project and Environment | composite-FK columns, enforced by the same authorization path as every other Mosaic resource | claims, validated by a second, parallel code path | +| scoped to one Billing Customer | column | claim | +| audience restricted | column | `aud` claim | +| **revocable where practical** | **one `UPDATE`, effective immediately and everywhere** | not revocable without a revocation list, which is a second lookup that reintroduces the database read a signed token existed to avoid | +| free of provider secrets | nothing inside to leak | nothing inside to leak | +| minimal in claims | no claims at all | minimal claims | +| safe to refresh through the host backend | yes | yes | + +A signed token satisfies "revocable where practical" only by adding the +server-side lookup that is a signed token's entire advantage. It would also +require a signing-key ADR, a JWKS surface, key rotation, and clock validation on +devices — four new failure modes, none of which buys anything Mosaic needs. +ADR-0017's posture (secrets stored as digests, never recoverable) already covers +opaque credentials. + +## Envelope and record types + +```json +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": {} +} +``` + +`additionalProperties` is `false` at every level. Every record is server-side +metadata **about** a token; none of it is content **inside** one. + +| Record type | Meaning | +| --- | --- | +| `customerAccessTokenIssuanceRequest` | The host backend asks for a token for a user it has authenticated | +| `customerAccessTokenIssuanceResult` | The only record that ever carries the token value | +| `customerAccessTokenMetadata` | Everything Mosaic knows about a token | +| `customerAccessTokenRevocation` | An immediate, audited revocation | + +## Issuance + +```text +Host application backend +→ authenticates its own user +→ POSTs an issuance request with its Mosaic secret_server key +→ receives the token exactly once +→ returns it to the app +→ SDK attaches it to every entitlement sync +``` + +The issuance request carries `billingCustomerId`, `audience`, `scopes`, an +optional `requestedTtlSeconds`, and a `correlationId`. It carries **no +`projectId` and no `environmentId`**: tenant scope is derived from the +authenticated secret server key, exactly as Billing Ingestion derives it. A +request that could name a Project would let a careless or compromised caller mint +a token into a tenant it does not own. A test asserts those two properties stay +absent from the request shape. + +`requestedTtlSeconds` is a request, not an instruction. Mosaic clamps it: a +caller may shorten a token's life but never lengthen it past the maximum. + +## Audience, scopes, and binding + +- **Audience** is `sdk_sync` in Phase 9B. The enumeration is closed, so it is + over-provisioned: `server_check` is declared now, reserved for a future + server-facing audience and **not issued in 9B**, because adding an audience to + a closed enumeration later would cost a contract version. A token minted for + one audience is refused by every other surface. +- **Scopes** are `entitlements.read`, `entitlements.sync`, and + `restore.request`. A token carries the least it can; `restore.request` is + granted only to a client that may trigger a restore. +- **Binding** is one Project, one Environment, and one Billing Customer, all + required. A request that asserts a different customer is refused + (`readerPolicy.customerMismatch: "refuseRequest"`); the assertion never selects + the customer. + +Scope and binding are evaluated against stored columns, not against claims +presented by the caller. There is no claims-validation step because there are no +claims. + +## Lifetime and clock skew + +| | | +| --- | --- | +| Default | 3600 s (1 hour) | +| Maximum | 86400 s (24 hours) | +| Minimum | 60 s | +| Clock skew tolerance | ±60 s | +| Evaluated by | **the server** | + +A short life is the only thing limiting the damage of a leaked opaque token, so +the maximum is enforced by the semantic validator as well as pinned in the +manifest. The **server** evaluates expiry: a device clock is attacker-controlled +and never decides whether a token is still valid. + +## Revocation + +Revocation is immediate and server-side: the digest row is marked, and the next +presentation fails regardless of how long the token had left to live. Reasons are +closed and over-provisioned: `customer_signed_out`, `identity_changed`, +`operator_revoked`, `customer_deleted`, `key_rotated`, `suspected_compromise`, +`superseded_by_new_token`. + +Revocation state is all-or-nothing in the schema: `status: "revoked"` requires +both `revokedAt` and `revocationReason`, and an `active` or `expired` token may +carry neither. + +## Wire form + +**Contract-owned. These names are final and are pinned in the manifest**, so a +rename is a contract change rather than an implementation detail: + +```http +POST /v1/sdk/billing/entitlements +Authorization: Bearer mcat_<43 base64url characters> +Mosaic-SDK-Key: +Content-Type: application/json + +{ "authoritativeEntitlementContractVersion": "1", "recordType": "entitlementSyncRequest", "payload": { ... } } +``` + +The sync surface is a `POST` because contract negotiation and the conditional +`knownSnapshotVersion` / `entityTag` live in the +[`entitlementSyncRequest`](authoritative-entitlement-v1.md#the-sdk-conformant-sync-form) +body rather than in headers. An SDK does not use conditional `GET`, +`If-None-Match`, or a bare `304`; the unchanged path is a `200` carrying the +`snapshotUnchanged` record. + +Both headers are required. `wireForm.publicSdkKeyAloneSufficient` is `false`: +the public SDK key identifies the application, the customer token selects the +customer, and neither substitutes for the other. + +`readerPolicy.tokenInQueryString` is `forbidden`. Query strings end up in access +logs, proxy logs, and browser history. + +### Observation submission + +The SDK also attaches the token, when it holds one, to Billing Ingestion +observation submissions, as the optional `Mosaic-Customer-Token` request header. +This lets the server record submission-context association evidence — that the +request submitting a provider transaction was authenticated as a known customer. + +The header is transport only. No Billing Ingestion record gains a field, and the +token never enters an observation body, because observation bodies are persisted +as raw validation inputs, replayed, and digested into fact identity — all three +of which a credential must never be. See +[the customer token is transport, not a record field](billing-ingestion-v1.md#the-customer-token-is-transport-not-a-record-field). + +## SDK obligations + +Accepting a token means accepting these. They are conformance obligations, +verified by inspection and by the SDK cache tests, not by any schema. + +| Obligation | Rule | +| --- | --- | +| Storage | **Memory only.** Never written to disk, keychain, or preferences. | +| Attachment | Attach to every sync request. | +| Parsing | **Forbidden.** The token is opaque; nothing may be inferred from it. | +| Refresh on 401 | Exactly **one** forced refresh per token generation. A second 401 on a freshly minted token is a real failure; retrying forever turns an outage into a request storm. | +| On logout | Discard the token **and** clear the entitlement cache. | +| On identity change | Bump the generation, cancel in-flight requests, clear the cache before any read. | +| On token-provider failure | Report `unavailable`. **Never `inactive`.** A host backend that cannot mint a token has not revoked anyone's subscription. | +| Logging | **Forbidden.** The token never appears in a log, a diagnostic, a crash report, or telemetry. | + +A `null` token or a signed-out user yields `unavailable`, consistent with +[Authoritative Entitlement v1](authoritative-entitlement-v1.md)'s top rule. + +## What a token never contains + +- Entitlement state. `tokenModel.carriesEntitlementState` is `false`, and a + validator guard rejects any property name suggesting otherwise. A token that + carried entitlements would keep granting them after a refund, for as long as it + lived — there is nothing to revoke inside a bearer claim. +- Provider secrets, receipts, purchase tokens, or signing keys. +- Personal data. `actorReference` on a revocation is an opaque handle, never a + name or an email address. + +## Anonymous mode + +Not supported in v1 (OD-4). Mosaic Billing requires an application backend. An +installation identifier is client-generated and guessable, so allowing it to +select a Billing Customer would let anyone read someone else's entitlements by +replay or by guess. The installation alias exists only as association evidence, +a cache key, and a restore hint; it can never create or select a customer. + +## Reader sequence + +1. Require exact `customerAccessTokenContractVersion: "1"`. +2. Reject the whole record on any unknown version, record type, field, scope, or + audience. +3. Present the token in `Authorization: Bearer` with the public SDK key in + `Mosaic-SDK-Key`, on a `POST` carrying the `entitlementSyncRequest` envelope. +4. On `401`, force **one** token refresh through the host backend, then retry + once. +5. On expiry, revocation, or an audience or customer mismatch: the request is + refused and the SDK reports `unavailable` — never `inactive`. + +## Fixtures + +`protocol/fixtures/customer-access-token/v1/` — 6 canonical fixtures in +`tokens/` and 9 in `invalid/`, including `token-carries-entitlement-claims.json`, +`token-shaped-as-signed-payload.json`, `token-missing-customer-binding.json`, and +the semantic `token-lifetime-exceeds-maximum.json`. + +The `mcat_` value in `tokens/issuance-result.json` is **fabricated** — typed to +satisfy the pattern, never issued by any deployment, and therefore useless +anywhere. It is there because the issuance result is the only record that carries +a token value at all and an SDK author needs to see its shape. No fixture, +example, or document may ever carry a token a deployment actually minted: a +fixture is copied, committed, and published, so a real credential in one is a +leaked credential. The rule is recorded as a `$comment` on `tokenValue` in the +canonical schema. + +## Related documents + +- [Authoritative Entitlement Contract v1](authoritative-entitlement-v1.md) +- [Billing State Webhook Contract v1](billing-state-webhook-v1.md) +- [Compatibility policy](compatibility-policy.md) · [Versioning](versioning.md) diff --git a/docs/protocol/versioning.md b/docs/protocol/versioning.md index bc4baac5..f277d1b1 100644 --- a/docs/protocol/versioning.md +++ b/docs/protocol/versioning.md @@ -9,10 +9,13 @@ Protocol `0.2`; Local Preview `0.2` (development-only); Configuration Delivery Earlier experimental contracts were retired before approval rather than carried as compatibility readers. -Billing Ingestion `1` exists but is **not** in the approved set: it is born +Four contracts exist but are **not** in the approved set. Each is born `status: "draft"` and carries no compatibility guarantee until an explicit -product-owner decision approves it. See -[Billing Ingestion versioning](#billing-ingestion-versioning). +product-owner decision approves it: Billing Ingestion `1` (see +[Billing Ingestion versioning](#billing-ingestion-versioning)) and the three +Phase 9B contracts — Authoritative Entitlement `1`, Customer Access Token `1`, +and Billing State Webhook `1` (see +[Phase 9B contract versioning](#phase-9b-contract-versioning)). These are independent versioned contracts. Their exact version values do not imply compatibility with one another and do not change the Paywall @@ -225,6 +228,48 @@ into the browser contract. Adding browser generation later is additive and does not require a contract version. See [Billing Ingestion Contract v1](billing-ingestion-v1.md) and ADR-0022. +## Phase 9B contract versioning + +Authoritative Entitlement `1`, Customer Access Token `1`, and Billing State +Webhook `1` are born `status: "draft"` per Phase 9B owner decision OD-15 and are +promoted alongside Billing Ingestion `1` once live-sandbox evidence exists. + +Readers require the exact discriminators +`authoritativeEntitlementContractVersion: "1"`, +`customerAccessTokenContractVersion: "1"`, and +`billingStateWebhookContractVersion: "1"`. Every enumeration in all three is +closed and deliberately over-provisioned, so adding a record type, state, +uncertainty reason, explanation code, change reason, source type, scope, +revocation reason, or event type is a breaking change requiring version `2`. + +Three rules distinguish these contracts from the rest of the set: + +- **A rejection yields `unknown`, never `inactive`.** Authoritative Entitlement + `1` fails closed to an explicitly uncertain state rather than to a negative + one, and preserves the reader's cache. See + [compatibility policy](compatibility-policy.md#unknown-access-is-never-inactive). +- **An unknown `entitlementKey` is accepted.** Keys are Project data rather than + contract vocabulary. This is the single exception to closed-vocabulary reading + in Authoritative Entitlement `1`; rejecting an unknown key would make defining + a new Entitlement a breaking change for every already-shipped SDK. +- **Webhook consumers are documented as tolerant.** Producers stay strict. See + [compatibility policy](compatibility-policy.md#webhook-consumer-tolerance-is-a-documented-exception). + +Contract negotiation for Authoritative Entitlement lives in the **sync request +body** (`supportedAuthoritativeEntitlementContracts`). The Configuration Delivery +capability request is untouched, and no approved contract gains a required +reference to any of these three. None of them `$ref`s Billing Ingestion `1` or +each other: shared shapes are copied so a draft never inherits another draft's +lifecycle. + +Like Billing Ingestion, these contracts are server- and SDK-facing and are +deliberately **not** generated into the browser contract. Adding browser +generation later is additive and does not require a contract version. + +See [Authoritative Entitlement Contract v1](authoritative-entitlement-v1.md), +[Customer Access Token Contract v1](customer-access-token-v1.md), and +[Billing State Webhook Contract v1](billing-state-webhook-v1.md). + ## Related policy documents - [Compatibility policy](compatibility-policy.md) — consolidating index of the diff --git a/docs/reviews/phase-9b-demo-evidence.md b/docs/reviews/phase-9b-demo-evidence.md new file mode 100644 index 00000000..24250423 --- /dev/null +++ b/docs/reviews/phase-9b-demo-evidence.md @@ -0,0 +1,1452 @@ +# Phase 9B Stage 4 — Integrated demonstration evidence + +Operator: mosaic-backend agent +Date: 2026-07-29 +Branch: `phase/9b-subscription-state-entitlements` +Specification: `docs/plans/phase-9b-subscription-state-authoritative-entitlements.md` §19 +(fourteen demonstrations, one-minute demonstration), §2 owner decisions, +orchestration prompt "Stage 4: Integrated Demonstration". + +**Honesty rule applied throughout.** Every claim below is backed by an HTTP +response, a SQL result, or a signature check produced by the run recorded here. +Where a demonstration produced a result the plan did not intend, the actual +result is recorded and the defect is named. §9 lists **five defects, three of +them release-blocking**. Nothing is paraphrased into a stronger statement than +the output supports. No secret value appears in this document: the Apple intake +token, API key secrets, the webhook signing secret, and the Customer Access +Token are redacted at the point they are printed. + +**No live store was reachable.** There is no App Store Connect account, no +Sandbox Apple Account, no Play Console application, and no Google Cloud project +in this environment. Phase 9A's accepted synthetic signed-vector precedent +applies (`docs/reviews/phase-9a-demo-evidence.md` §2). §2 below classifies every +synthetic element and §10 states exactly what only a live sandbox can prove. + +--- + +## 1. Environment + +| Item | Value | +| --- | --- | +| Host | Apple Silicon macOS (Darwin 25.5.0), `arm64` | +| Go toolchain | go1.26.5 darwin/arm64 | +| PostgreSQL | `postgres:17-alpine` (Docker, container `mosaic-9b-demo-pg`, published port 5439) | +| Migrations | `go run ./cmd/migrate up` → 48/48 applied to an empty database; `migrate preflight` verdict `compatible` | +| Demo driver | `apps/api/cmd/billingdemo` (extended; new files `demo9b.go`, `demo9b_seed.go`, `demo9b_stubs.go`, `demo9b_vectors.go`, `demo9b_helpers.go`) | +| Run command | `DATABASE_URL=postgres://… go run -tags billingdemo ./cmd/billingdemo -phase 9b` | +| One-minute command | `DATABASE_URL=postgres://… go run -tags billingdemo ./cmd/billingdemo -phase oneminute` | +| Build tag | `//go:build billingdemo` on every file. `go list ./cmd/billingdemo` without the tag reports *"build constraints exclude all Go files"*. | +| Wall-clock | **34.0 s** and **48.6 s** for the two recorded consecutive full runs. The spread is one jittered webhook retry wait (23 s vs 37 s of real elapsed time); everything else is identical. | +| One-minute wall-clock | **1.042 s** | +| Tenant | `org_demo9b` / `proj_demo9b` / `env_demo9b` (mode `production`); application `app_demo9b_ios` (`com.mosaic.demo9b`) | +| Data | Created by this run only. No production data exists or was used. | + +The driver is destructive to the tenant it owns (`proj_demo9b`) and touches +nothing else. It mints fresh identifiers each run, so the `bcu_…`, `bpl_…`, +`ces_…`, and `whe_…` values quoted below are from the recorded run. + +--- + +## 2. What is real, and what is synthetic + +Read this section before any evidence below. + +### Real — exercised exactly as it would be in production + +| Component | Evidence it was the real thing | +| --- | --- | +| PostgreSQL schema, every constraint, every append-only trigger | 48/48 migrations against an empty database; every write below went through them. The append-only triggers are disabled **only** for the pre-run tenant reset and are in place for the whole demonstration. | +| chi router with the full middleware stack | built by `httpserver.NewWithDependencies` and mounted on `httptest.NewServer` | +| Every Phase 9A and Phase 9B HTTP handler and its ozzo validation | every HTTP line below is a real handler response | +| Phase 9A intake, validation, product resolution, fact digests, ledger | demonstrations 1–13 all begin at `POST /v1/billing/apple/notifications/{intakeToken}` | +| `appstorejws` verifier — chain, `x5c`, ES256, `signedDate` window | §9 defect D-0 was found by it rejecting back-dated payloads | +| `appstoreserver.Client` (App Store Server API, ES256 request JWT) | the Apple stub's call log shows the real client's request paths | +| `billingcustomer` identity service, association resolver, conflict machinery | demonstration 12 | +| `billingprojection` ordering, subscription engine, one-time engine, grant selection, entitlement aggregation, checksums, checkpoints, replay | demonstrations 1–14 | +| `billinggrant` publish, immutability refusal, interval closure | stage 0 | +| `billingaccess` token issuance, SDK sync, trusted reads | demonstrations 1, 10, 11 | +| `billingwebhook` signing, SSRF policy, fan-out, delivery, retry, auto-disable | demonstrations 1, 13 | +| `billingrestore` submit and status surfaces, and `ProcessNextRestoreSync` | demonstration 10 (where it failed — §9 D-2) | +| `billingdiagnostics` replay and projection health | demonstration 14 | +| The worker job entry points `cmd/worker` schedules | `ProcessNextValidation`, `ProcessNextProjection`, `ProcessNextDelivery`, `ProcessNextRestoreSync` all invoked by name | +| **TLS on webhook delivery** | Not relaxed. The delivery policy is HTTPS-only, builds its own transport, sets no `InsecureSkipVerify`, and performs a real certificate verification. See the classification of the trust anchor below. | + +### Synthetic — classified, with the reason it could not be otherwise + +| Element | Classification | Why | +| --- | --- | --- | +| **Apple JWS signing chain** | **SYNTHETIC.** A three-certificate ECDSA P-256 chain generated per run carrying Apple's App Store extension OID `1.2.840.113635.100.6.2.1` on the intermediate, injected through `appstorejws.NewVerifier(appstorejws.WithRoot(…))` — the package's own documented option. Its validity window is widened to three years back (`demoChainBackdate`) because the verifier validates the chain **as of the payload's `signedDate`**, and these demonstrations replay months of subscription history. | Apple's signing key is not obtainable. **This proves the verifier accepts a chain it was told to trust; it does not prove Mosaic accepts Apple's real chain.** | +| **App Store Server API responses** | **LOCAL STUB.** `httptest` server speaking `/inApps/v1/transactions/{id}`. Mosaic's real client calls it and re-verifies the JWS it returns. | No Apple account. | +| **Credential material** | **SYNTHETIC.** A locally generated PKCS#8 P-256 key stands in for an Apple `.p8`. | Same reason. | +| **Webhook destination** | **LOCAL STUB** — an `httptest` TLS server on `127.0.0.1` standing in for a tenant's application backend. It verifies `Mosaic-Signature` with an **independent** implementation of the published rules (`packages/test-fixtures/src/webhook-signature-vectors.json`), deliberately not by calling `billingwebhook.Sign`: a verifier that reuses the producer's own function proves only that the function agrees with itself. | +| **Webhook TLS trust anchor** | **SYNTHETIC ROOT, real verification.** A CA and a leaf for `127.0.0.1` are minted per run and installed with `x509.SetFallbackRoots`, honoured because `demo9b_stubs.go` carries `//go:debug x509usefallbackroots=1`. That directive is the single strongest reason this driver must never be reachable from a release build, and it is why the file is build-tagged. Certificate verification, hostname/IP SAN matching, redirect refusal, resolve-and-pin, and the reserved-address screen all run unmodified. | +| **Webhook SSRF allowlist** | **CONFIGURATION, not a substitution.** `billingwebhook.WithSelfHostedAllowlist(true)` is the existing deployment flag (`MOSAIC_BILLING_WEBHOOK_ALLOW_PRIVATE_DESTINATIONS`) that permits a private destination address. HTTPS is still mandatory; the flag does not remove transport security. | +| **Dashboard principal resolver** | **SUBSTITUTED.** `authn.ResolverFunc` returning a fixed actor from an `X-Demo-Actor` header instead of validating a browser session cookie. **Authorization is not substituted:** owner/admin membership is still enforced by the real repository SQL against a real `organization_members` row. | +| **SDK clients** | **WIRE-LEVEL SIMULATION, classified.** The driver sends the ratified `entitlementSyncRequest` envelope and reads the response, which is exactly the wire the Flutter, iOS, and Android SDKs consume. It does **not** run those SDKs. Their decoders, acceptance gates, caches, and cache-state machines are proven by their own conformance suites against the shared fixtures; this driver re-runs none of them and claims nothing about them. Demonstration 11 is therefore explicitly a **wire-level** offline-cache demonstration. | +| **Time** | **DRIVEN BY EFFECTIVE TIMESTAMPS.** No clock is manipulated and nothing sleeps waiting for a period to elapse. Every state transition below is caused by a provider-stated effective time inside a signed payload — `purchaseDate`, `expiresDate`, `revocationDate`, `gracePeriodExpiresDate`, `signedDate` — expressed as an offset from one scenario baseline `T`. The **only** real waits in the run are the webhook retry backoffs in demonstration 13 (23–37 s), which are deliberately real. | +| **The 9A→9B bridge** | **SUBSTITUTION, and the most important line in this document.** Nothing in `cmd/api` or `cmd/worker` creates a `purchase_lineages` row, a `subscription_instances` row, a `one_time_purchase_instances` row, or a lineage→customer association. The driver performs that step itself, in the open, printing `SUBSTITUTION bridge:` every time. See §9 defect **D-1**. | +| **The customer-scoped reprojection** | **WORKAROUND, from demonstration 5 onward.** Every demonstration after 5 finishes with a direct `billingprojection.Service.Project` call on a customer-only scope, because the queued path drops sources. See §9 defect **D-4**. | + +### Process shape + +The demonstration runs the real router on `httptest.NewServer` inside one +process and calls the worker job functions directly, rather than running +`cmd/api` and `cmd/worker`. The reason is the Apple root, exactly as in Phase +9A: both binaries call `appstorejws.NewVerifier()` with no options and pin +Apple's embedded root, so a separate API process could never verify the +synthetic chain. Everything between the socket and the database is identical to +the deployed path — **except** that the operator surface is mounted on a second +mux, because the deployed composition cannot start at all (§9 defect **D-3**). + +--- + +## 3. Stage 0 — tenant, grant versions, destination, signature vectors + +Seeded: organization, owner membership, project, production environment, one iOS +application, three Products (`pro-monthly` subscription, `pro-yearly` +subscription, `pro-lifetime` one-time non-consumable), one Entitlement +(`ent_demo9b_pro`, key `pro`), three provider Product mappings, one public SDK +key, one secret server key. **Nothing under `billing_*`, `purchase_lineages`, +`subscription_*`, `customer_entitlement_*`, `product_entitlement_grant_versions`, +or `webhook_*` is seeded** — every one of those rows below was produced during +the run by a handler, a service, or a worker job. + +```text +HTTP PUT /v1/projects/{projectId}/billing/settings -> 200 +{"data":{"billingEnabled":true}} +``` + +Three grant versions published through `POST /v1/projects/{projectId}/billing/grant-versions`: + +```text +SQL: published grant versions (immutable, one open interval per pair) + product_id | version | grant_policy_version | grants_in_active | grants_in_trial | grants_in_grace | grants_in_billing_retry | grants_in_one_time_ownership | supported_purchase_types | open + -------------------+---------+----------------------+------------------+-----------------+-----------------+-------------------------+------------------------------+---------------------------------+----- + prd_demo9b_lifetime| 1 | 1 | true | true | true | false | true | [non_consumable] | true + prd_demo9b_monthly | 1 | 1 | true | true | true | false | true | [auto_renewable_subscription] | true + prd_demo9b_yearly | 1 | 1 | true | true | true | false | true | [auto_renewable_subscription] | true +``` + +An in-place edit of a published version is refused with a specific code, not a +bare 405: + +```text +HTTP PATCH /v1/projects/{projectId}/billing/grant-versions/{versionId} -> 409 +{"error":{"code":"grant_version_immutable","message":"A published grant version cannot be edited. Publish a superseding version instead."}} +``` + +A prospective grant version must take effect now or later — a back-dated +`effectiveStart` without `retroactive: true` is refused (`422 +validation_failed`). That was observed during driver development and is the +accepted OD-8 policy; the driver publishes prospectively and relies on the +documented backfill rule that a purchase predating every recorded version +selects the earliest one, which is what demonstrations 1–14 depend on. + +Webhook destination registered (secret redacted; it is returned exactly once and +Mosaic keeps only the sealed form): + +```text +HTTP POST .../billing/webhook-destinations -> 201 +{"data":{"destination":{"id":"whd_…","url":"https://127.0.0.1:PORT/mosaic/webhooks","status":"active", + "eventTypes":["customer.entitlements.changed"],"consecutiveFailureCount":0},"secret":"","secretId":"whs_…"}} +``` + +Mosaic's signing function checked against the published cross-implementation +vectors (`packages/test-fixtures/src/webhook-signature-vectors.json`): + +```text +note: vector canonical-event-primary-key produced==published: true +note: vector canonical-event-rotation-key produced==published: true +note: vector tampered-body-must-not-verify produced==published: true +note: vector different-event-id-must-not-verify produced==published: true +note: vector different-timestamp-must-not-verify produced==published: true +note: vector minimal-body produced==published: true +note: vector non-ascii-body produced==published: true +note: vector non-ascii-secret produced==published: true +``` + +All eight published vectors agree, including the rotation key, non-ASCII bodies, +and a non-ASCII secret. The "must-not-verify" vectors are must-not-verify **for +the canonical body**; +the assertion here is that Mosaic reproduces the signature the vector file +publishes for the body the vector carries, which is what makes the negative +vectors usable by an SDK. + +--- + +## 4. Demonstrations 1–4 — the subscription lifecycle + +Scenario clock: baseline `T`. Initial period `[T-25d, T+5d]`; renewal period +`[T-3h, T+30d]`; cancellation effective `T-2h`; expiration effective `T-1h`. + +### Demonstration 1 — initial subscription — **PASS** + +1. **Billing Customer created** through the trusted identity API + (`POST /v1/billing/identity/customers`, secret server key) → `201`, + `bcu_…`, `status: active`, `currentProjectionVersion: 0`. +2. **Customer Access Token issued** (`POST /v1/billing/server/customer-tokens`, + Customer Access Token Contract v1 envelope) → `201`. The token value is + redacted in this document. Stored form: + + ```text + SQL: the token is stored as a digest, never as a value + audience | scopes | digest_bytes | bounded | live | ttl_seconds + ---------+---------------------------------------+--------------+---------+------+------------ + sdk_sync | [entitlements.read entitlements.sync] | 32 | true | true | 3600 + ``` + +3. **Validated Apple purchase** — synthetic signed `SUBSCRIBED`/`INITIAL_BUY` + notification through the real intake endpoint (`202`), then the real + validation worker, which re-read the transaction from the App Store Server + API stub and re-verified its JWS: + + ```text + SQL: the validated Transaction Fact + fact_kind | transaction_type | resolution_state | mosaic_product_id | period_start_at | period_end_at | renewal_expected | validator_version + -----------------+-----------------------------+------------------+--------------------+-----------------+---------------+------------------+------------------ + initial_purchase | auto_renewable_subscription | active_mapping | prd_demo9b_monthly | T-25d | T+5d | true | 2 + ``` + +4. **Association via submission-context evidence** — the driver's bridge + (SUBSTITUTION, §9 D-1) locates the lineage and runs the real association + resolver with a `trusted_server_observation`: + + ```text + SQL: purchase lineage and its association evidence + provider | lineage_type | projection_frozen | diagnostic_status | attached_to_customer | evidence + ----------+--------------+-------------------+-------------------+----------------------+------------------------------------ + app_store | subscription | false | none | true | trusted_server_observation/resolved + ``` + +5–7. **Subscription Snapshot, grant version applied, Customer Entitlement Snapshot:** + +```text +SQL: current Subscription Snapshot + projection_version | access_state | lifecycle_state | renewal_intent | billing_state | uncertainty_reason | period_start_at | period_end_at | current_product_id + -------------------+--------------+-----------------+--------------------+---------------+--------------------+-----------------+---------------+------------------- + 1 | active | active | auto_renew_enabled | current | none | T-25d | T+5d | prd_demo9b_monthly + +SQL: current Customer Entitlement Snapshot and its entries + snapshot_version | change_reason | entitlement_key | state | end_known | source_count | uncertainty_reason | explanation_code + -----------------+----------------------+-----------------+--------+-----------+--------------+--------------------+-------------------- + 1 | entitlements_changed | pro | active | true | 1 | none | subscription_active + +SQL: the Entitlement Source names (lineage, product, grant version) — never a fact id + source_type | source_state | explanation_code | is_test_source | has_grant_version | from_subscription + --------------------+--------------+---------------------+----------------+-------------------+------------------ + active_subscription | active | subscription_active | false | true | true +``` + +8. **Trusted server API** — `GET /v1/billing/server/customers/{id}/entitlements` + → `200`, raw Authoritative Entitlement v1 record, `state: active`, + `primaryExplanation.code: active_subscription_period`, `sourceCount: 1`, + `projectionStatus.state: current`. + +9. **SDK sync wire** — `POST /v1/sdk/billing/entitlements` with + `recordType: entitlementSyncRequest`, `Authorization: Bearer mcat_…`, + `Mosaic-SDK-Key: …` → `200`, the same `snapshotVersion: 1`, the same + `entityTag`, the same single granting source, plus: + + ```text + note: freshness headers: refresh-after=T+1h valid-until=T+7d stale-grace-seconds=86400 etag="ces.…" + ``` + + Classification: this is the wire the three SDKs consume. Their own + conformance suites prove the client behaviour; this driver does not re-run + them. + +10. **Active entitlement with source explanation** — shown above. +11. **Signed webhook delivered**: + + ```text + note: destination received event whe_… (answered 204); signature verified against key 0: true + SQL: delivery outcomes recorded by Mosaic + status | attempt_count | outcome | response_status + ----------+---------------+-----------+---------------- + succeeded | 1 | delivered | 204 + ``` + +### Demonstration 2 — renewal — **PASS** + +```text +note: prior snapshot version 1, prior effective end T+5d + +SQL: current Subscription Snapshot + projection_version | access_state | lifecycle_state | renewal_intent | billing_state | period_start_at | period_end_at + -------------------+--------------+-----------------+--------------------+---------------+-----------------+-------------- + 2 | active | active | auto_renew_enabled | current | T-3h | T+30d + +SQL: prior Subscription Snapshots are preserved, never rewritten + projection_version | access_state | lifecycle_state | period_end_at | is_current + -------------------+--------------+-----------------+---------------+----------- + 1 | active | active | T+5d | false + 2 | active | active | T+30d | true +``` + +Effective end extended `T+5d → T+30d`; the prior snapshot row is intact; +`subscription_snapshots` carries an append-only trigger. + +Webhook policy: the renewal changed **which period** the customer is in but not +**which Entitlements** they hold, so the customer aggregate was a no-change +projection — no customer snapshot minted, no version advance, and no +`customer.entitlements.changed` event. That is the accepted policy (plan §9) and +is the reason an SDK cache is not churned by every renewal. + +### Demonstration 3 — cancellation without immediate revocation — **PASS** + +```text +SQL: renewal intent is off, access is still active, and the scheduled expiration is visible + access_state | renewal_intent | billing_state | cancellation_effective_at | scheduled_expiration + -------------+---------------------+---------------+---------------------------+--------------------- + active | auto_renew_disabled | current | T-2h | T+30d +``` + +`billing_state` is `current`, not `failed`: the subscription is ending because +the customer chose to, which is the distinction plan §6 requires. The Customer +Entitlement Snapshot did not change and no access-change webhook was emitted — +the event vocabulary is deliberately about access, not about provider intent. + +### Demonstration 4 — expiration — **PASS** + +Driven by the provider's `expiresDate: T-1h`; no wall-clock wait. + +```text +SQL: current Subscription Snapshot + access_state | lifecycle_state | renewal_intent | billing_state | period_end_at | expiration_effective_at + -------------+-----------------+---------------------+---------------+---------------+------------------------ + inactive | expired | auto_renew_disabled | current | T-1h | T-1h + +SQL: current Customer Entitlement Snapshot and its entries + snapshot_version | change_reason | entitlement_key | state | source_count | explanation_code + -----------------+----------------------+-----------------+----------+--------------+----------------- + 3 | entitlements_changed | pro | inactive | 1 | no_active_source + +SQL: the subscription Entitlement Source is no longer granting + source_type | source_state | explanation_code | source_end + --------------------+--------------+----------------------+----------- + active_subscription | inactive | subscription_expired | T-1h +``` + +Access-change webhook delivered and signature-verified; the SDK wire returned +`state: inactive`, `primaryExplanation.code: no_qualifying_source`. + +--- + +## 5. Demonstrations 5–9 — sources, refunds, grace, ordering, upgrade + +### Demonstration 5 — multiple sources — **FAIL on the queued path (defect D-4); PASS on the projection engine** + +Step 1 adds a resubscribe (`[T-30m, T+30d]`) and a lifetime one-time purchase, +both granting `pro`. Three sources, entitlement active via the permanent source: + +```text +snapshot_version 4 | pro | active | source_count 3 | explanation_code permanent_source_active + active_subscription | inactive | subscription_expired | T-3h → T-1h + active_subscription | active | subscription_active | T-30m → T+30d + one_time_non_consumable | active | one_time_purchase_owned | T-20m → (none) +``` + +Step 2 expires the resubscribe. **This is where the demonstration failed.** What +a deployed worker produces: + +```text +snapshot_version 5 | pro | inactive | source_count 1 | explanation_code no_active_source + active_subscription | inactive | subscription_expired | T-30m → T-10m +``` + +The lifetime source and the earlier subscription source are gone from the +customer aggregate, and `pro` reads `inactive` while a valid, unrefunded +lifetime purchase is recorded and its instance still reads `validity_state: +owned`. Nothing revoked it. + +Step 3 runs the identical projection command with **no lineage restriction**: + +```text +snapshot_version 6 | pro | active | source_count 3 | explanation_code permanent_source_active + active_subscription | inactive | subscription_expired | T-3h → T-1h + active_subscription | inactive | subscription_expired | T-30m → T-10m + one_time_non_consumable | active | one_time_purchase_owned | T-20m → (none) + +SQL: the job the worker actually ran, and what it was scoped to + kind | scope_key | lineage_in_detail | status + ---------------+----------------+-------------------+---------- + fact_committed | customer:bcu_… | bpl_… | completed + fact_committed | customer:bcu_… | | completed + fact_committed | customer:bcu_… | bpl_… | completed +``` + +The projection engine is correct. The job scoping is not. Full analysis: §9 D-4. + +With the workaround applied, the demonstration's acceptance point holds: the +Entitlement remains active through the lifetime source after the subscription +expires, and both source histories remain inspectable. + +### Demonstration 6 — refund or revocation — **PASS** + +An Apple `REFUND` with `revocationDate: T-5m` on the lifetime lineage only: + +```text +SQL: the refunded source, and the unrelated sources beside it + lineage_type | state | refund_effective_at | revocation_effective_at + -------------+----------+---------------------+------------------------ + subscription | inactive | - | - + subscription | inactive | - | - + one_time | refunded | T-5m | - + +SQL: history is intact: every fact for the refunded lineage is still recorded + fact_kind | occurred_at | refunded_at | revoked_at | refund_type + ------------------+-------------+-------------+------------+------------ + one_time_purchase | T-20m | NULL | NULL | - + refund | T-20m | T-5m | T-5m | - +``` + +The two subscription lineages are untouched. The Entitlement went `inactive`, +the change was delivered as a signed webhook, and the SDK wire reported +`state: inactive`. Note that Apple's `revocationDate` populates **both** +`revoked_at` and `refunded_at` — that is the 9A normalization decision, and it +is what makes the refund invalidating (`refundInvalidates` requires +`revoked_at`). + +### Demonstration 7 — grace and recovery — **PASS** + +Apple emits no grace notification; grace arrives as `DID_FAIL_TO_RENEW` carrying +`gracePeriodExpiresDate` in the renewal info. Period `[T-40d, T-10d]`, grace end +`T+5d`: + +```text +SQL: grace is active, access is granted by the approved policy, and the grace end is recorded + access_state | lifecycle_state | billing_state | grace_period_end_at + -------------+-----------------+---------------+-------------------- + active | grace_period | grace | T+5d + +snapshot_version 6 | pro | active | source_count 4 | explanation_code verified_grace_period +``` + +Recovery (`DID_RENEW`, new period `[T-2m, T+28d]`): + +```text + access_state | lifecycle_state | billing_state | grace_period_end_at | period_end_at + -------------+-----------------+---------------+---------------------+-------------- + active | active | current | NULL | T+28d + +SQL: Subscription Timeline (append-only) + entry_type | effective_at | explanation_code | rule_version + ----------------------+--------------+----------------------------+------------- + purchase_validated | T-40d | initial_purchase_validated | 1 + billing_retry_started | T-10d | billing_retry_started | 1 + renewal_validated | T-2m | renewal_validated | 1 +``` + +The timeline is preserved across the recovery; nothing was rewritten. + +### Demonstration 8 — out-of-order fact — **PASS** + +Lineage projected with `SUBSCRIBED [T-60d, T-30d]` then `DID_RENEW [T-30d, T+30d]`: + +```text +SQL: checkpoint before the late fact + high_watermark | facts_projected | valid | checksum + v1|…|030|3000000900000013!…|btf_… | 2 | true | 154027…ca52 +``` + +A late `EXPIRED` for the earlier period arrives with effective time `T-45d`, +strictly before the watermark: + +```text +SQL: the checkpoint was invalidated and the lineage was reprojected from zero + high_watermark | facts_projected | invalidated | checksum + v1|…|030|3000000900000013!…|btf_… | 3 | true | 154027…ca52 + +SQL: every Subscription Snapshot for this lineage — priors are preserved + projection_version | access_state | lifecycle_state | period_start_at | period_end_at | is_current + -------------------+--------------+-----------------+-----------------+---------------+----------- + 1 | active | active | T-30d | T+30d | true +``` + +The out-of-order fact was detected, the checkpoint was invalidated, and the +lineage was reprojected from zero over three facts. The renewal at `T-30d` still +sorts last in canonical order, so the deterministic result is **byte-identical** +to the pre-invalidation snapshot: the checksum did not move and no new snapshot +was minted. Expected access — active — is confirmed. This is the strongest form +of the determinism claim: the checkpoint is an optimization and never a source +of truth. + +Minor observation (not a defect, recorded for the reviewer): the checkpoint's +`invalidated` flag remains `true` after the successful reprojection. It reads as +a historical marker rather than a live state, but nothing clears it, so a +projection-health surface that ever counted "invalidated checkpoints" would +count it forever. + +### Demonstration 9 — upgrade with supersession — **PASS** + +Monthly `[T-20d, T+10d]`, then `DID_CHANGE_RENEWAL_PREF`/`UPGRADE` to yearly +with `isUpgraded: true` and period `[T-1m, T+365d]`: + +```text +SQL: the new Product is current, the prior Product is preserved on the snapshot + current_product_id | prior_product_id | access_state | period_start_at | period_end_at | scheduled_product_identifier + -------------------+--------------------+--------------+-----------------+---------------+----------------------------- + prd_demo9b_yearly | prd_demo9b_monthly | active | T-1m | T+365d | com.mosaic.demo9b.pro.yearly + +SQL: exactly one Entitlement Source per (lineage, entitlement, grant version) — no double grant + purchase_lineage_id | entitlement_id | grant_version_id | sources + --------------------+----------------+------------------+-------- + bpl_… | ent_demo9b_pro | pegv_… | 1 + +SQL: Subscription Timeline (append-only) + entry_type | effective_at | explanation_code | rule_version + -------------------+--------------+----------------------------+------------- + purchase_validated | T-20d | initial_purchase_validated | 1 + product_upgraded | T-1m | provider_reported_upgrade | 1 +``` + +The new Product's grants take effect at the transition's effective time; the old +Product's history is preserved on the snapshot and in `billing_product_resolutions` +(both provider Products resolved, mapping version recorded); the +`(snapshot, lineage, entitlement, grant version)` uniqueness makes a double +grant structurally impossible, and the count confirms one source. + +--- + +## 6. Demonstrations 10–12 — restore, cache, identity + +### Demonstration 10 — restore across devices — **FAIL (defect D-2)** + +Device A submits a client observation → validated → projected → snapshot +version 9. Device B syncs the same customer and reads the same +`snapshotVersion: 9` and the same `ETag`. + +Device C submits a **duplicate** observation for the same transaction, and +duplicate-safe validation holds: + +```text +SQL: duplicate-safe validation: one fact for the restored transaction, however many devices submit it + provider_transaction_id | facts | digests + ------------------------+-------+-------- + 3000000900000017 | 1 | 1 +``` + +The restore request is accepted: + +```text +HTTP POST /v1/sdk/billing/restores (device C) -> 202 +{"recordType":"restoreResult","payload":{"outcome":"validation_pending","pendingValidationCount":2, + "observedTransactionCount":2,"uncertainty":{"reason":"missing_fact","expectedResolution":"next_provider_notification"}}} +``` + +The restore then **never settles**: + +```text +note: ProcessNextRestoreSync attempt 1 processed=true +note: ProcessNextRestoreSync attempt 2 processed=false +note: ProcessNextRestoreSync attempt 3 processed=false +note: ProcessNextRestoreSync attempt 4 processed=true +note: ProcessNextRestoreSync attempt 5 processed=false + +PROBE: billingrestorepostgres stage-3 identity chain read (repository.go) + FAILED: ERROR: column f.purchase_lineage_id does not exist (SQLSTATE 42703) +PROBE: billing_transaction_facts has no such column + 0 + +SQL: restore job state + status | attempt_count | outcome | uncertainty_reason | observed_transaction_count | baseline_snapshot_version + -------+---------------+---------+--------------------+----------------------------+-------------------------- + queued | 2 | - | none | 2 | NULL +``` + +The job burns attempts and reports `validation_pending` forever. Analysis: §9 D-2. + +### Demonstration 11 — offline cache, at the wire level — **PARTIAL (defect D-5)** + +Classification restated in the transcript: client-side cache states are proven +by the three SDK conformance suites; this demonstrates the wire they consume. + +```text +note: issuedAt=T+0 refreshAfter=T+1h validUntil=T+7d staleGraceSeconds=86400 +note: contentDigest=sha256:… (the integrity value every SDK recomputes before accepting a snapshot) +note: headers carry the same window so a bodyless answer still slides it: T+1h / T+7d / 86400 +``` + +Bounds are consistent with the OD-5 policy (refresh after 1 h, valid until 7 d, +24 h stale grace) and `refreshAfter < validUntil`. + +The `snapshotUnchanged` slide works on the ratified POST form: + +```text +HTTP POST /v1/sdk/billing/entitlements (knownSnapshotVersion set) -> 200 +{"recordType":"snapshotUnchanged","payload":{"snapshotVersion":9,"entityTag":"ces.…", + "issuedAt":"…","refreshAfter":"…","validUntil":"…","staleGraceSeconds":86400, + "projectionStatus":{"state":"current"}}} +``` + +A stale `knownSnapshotVersion: 1` is answered with the **current** snapshot, +never the older one — the monotonicity property SDK caches depend on. + +The conditional GET did **not** return 304: + +```text +HTTP GET /v1/sdk/billing/entitlements (If-None-Match) -> 200 +note: DEFECT D-5 — the documented 304 path is unreachable. +``` + +Analysis: §9 D-5. + +### Demonstration 12 — identity conflict — **PASS** + +Billing Customer B created. A lineage is associated with Customer A. Conflicting +trusted evidence then names Customer B: + +```text +note: resolver outcome=conflicting customer=bcu_A conflictWith=bcu_B diagnostic=reassignment_requires_operator_resolution + +SQL: the conflict is open, the lineage is frozen, and nothing was reassigned + conflict_scope | status | diagnostic_code | first_is_a | second_is_b | projection_frozen | diagnostic_status | still_attached_to_a + ---------------+--------+-------------------------------------------+------------+-------------+-------------------+-------------------+-------------------- + lineage | open | reassignment_requires_operator_resolution | true | true | true | identity_conflict | true +``` + +Trusted-server evidence outranks the prior association (90 vs 70), so a naive +resolver would have moved the lineage. The reassignment downgrade refuses to: +the incumbent keeps the lineage, the challenger is recorded, a conflict is +opened, and the lineage is frozen. No double grant: + +```text +SQL: no double grant: the disputed lineage appears under exactly one Billing Customer + billing_customer_id | sources | states + --------------------+---------+------- + bcu_A | 1 | 1 +``` + +Last accepted authoritative state is preserved (pointer unchanged at version 11 +for A; B has no pointer, having no purchases). Operator resolution: + +```text +HTTP POST /v1/projects/{projectId}/billing/identity-conflicts/{conflictId}/resolution -> 200 +{"data":{"status":"resolved","resolutionAction":"keep_existing", + "resolutionReason":"Support ticket 4711: the store account belongs to customer A."}} + +SQL: the resolution is audited with its reason and the actor who took it + status | resolution_action | resolved_by_actor_id | reason | projection_frozen | diagnostic_status + ---------+-------------------+----------------------+---------------------------------------------------------------+-------------------+------------------ + resolved | assigned_first | actor_demo9b_owner | Support ticket 4711: the store account belongs to customer A. | false | none +``` + +The lineage is unfrozen, both candidates are reprojected, and both pointers are +unchanged — which is the correct answer for `keep_existing`. + +Note on the transcript: because a permanent one-time source still granted `pro`, +freezing the disputed lineage changed no Entitlement **state**, so no snapshot +was minted. That is the OD-10 requirement (preserve the last accepted state) +rather than an absence of behaviour. A frozen lineage that was the customer's +only source would instead surface as an `unknown` entry; this run did not +construct that case. + +--- + +## 7. Demonstrations 13–14 — webhooks and replay + +### Demonstration 13 — webhook retry — **PASS** + +An entitlement change (a `REVOKE` on the conflict lineage) is committed while the +destination stub is configured to answer `503` once. + +```text +SQL: the failed attempt is recorded and a retry is scheduled + attempt_number | outcome | response_status | error_code | retry_scheduled + ---------------+-------------------+-----------------+-------------------+---------------- + 1 | retryable_failure | 503 | destination_error | true + +SQL: the delivery is pending, not failed, and its state was never rolled back + status | attempt_count | max_attempts | scheduled_ahead + --------+---------------+--------------+---------------- + pending | 1 | 8 | true + +SQL: the entitlement state that produced the event is unchanged by the delivery failure + snapshot_version | change_reason + -----------------+--------------------- + 10 | entitlements_changed +``` + +The destination recovers on its own; the driver waits for the real jittered +backoff (23–37 s across runs; no clock was manipulated): + +```text +SQL: complete attempt history for the retried delivery + attempt_number | outcome | response_status | error_code + ---------------+-------------------+-----------------+------------------ + 1 | retryable_failure | 503 | destination_error + 2 | delivered | 204 | - + +note: event whe_… was delivered 2 times; body byte-identical across attempts: true +note: first attempt answered 503, last answered 204; signature verified each time: true/true +``` + +The event id is stable across attempts, the body is byte-identical (compared as +bytes by the destination stub, not re-serialized), the signature verified on both +attempts against the destination's own independent implementation, the attempt +history is complete and append-only, and the entitlement state was never rolled +back by the delivery failure. + +### Demonstration 14 — replay and rule versions — **PASS** + +```text +note: before the replay: 13 customer snapshots, 13 webhook events, current checksum d610155f…248f + +HTTP POST .../billing/projection-replays -> 200 +{"data":{"projectionRuleVersion":1,"scopesReplayed":1,"scopesChanged":0, + "outcomes":[{"projectionScopeKey":"customer:bcu_…","comparison":"unchanged","materialized":false}]}} + +note: after the replay: 13 customer snapshots, 13 webhook events, current checksum d610155f…248f +note: identical checksum: true; no new snapshot: true; no new webhook: true + +SQL: the replay recorded an attempt even though it wrote no snapshot + outcome | error_code | rule_version | scope_key + ----------+------------+--------------+---------------- + no_change | - | 1 | customer:bcu_… +``` + +Deterministic checksum, no new snapshot, no webhook, and the attempt is still +recorded — a replay that leaves no trace would be unauditable. + +An unimplemented rule version is refused cleanly: + +```text +HTTP POST .../billing/projection-replays (projectionRuleVersion=2) -> 422 +{"error":{"code":"validation_failed", + "message":"The replay must be bounded and must name a rule version this build derives under."}} + +SQL: one rule version exists and it is active + version | status | description + --------+--------+------------------------------------------------------------------------ + 1 | active | Phase 9B initial projection semantics (plan §6/§7, ordering version 1) +``` + +**Shadow projection is deferred per OD-11(a)** and is not demonstrated. Rule +versions are recorded on every snapshot, timeline entry, and checkpoint from day +one, and replay-plus-checksum comparison ships in 9B; the diff engine waits for +a second implemented rule version to diff against. A request for one is refused +rather than silently recomputed under the active semantics, which is the +property that makes the deferral safe. + +Projection health closes the run: + +```text +HTTP GET .../billing/projection-health -> 200 +{"data":{"billingEnabled":true,"activeProjectionRuleVersion":1,"projectionQueueDepth":0, + "projectionFailedJobs":0,"staleCustomers":0,"neverProjectedCustomers":0,"openIdentityConflicts":0, + "frozenLineages":0,"unresolvedLineages":0,"unknownEntitlementEntries":0, + "restoreBacklog":1,"webhookDeliveryBacklog":0,"webhookDeliveriesExhausted":0,"activeWebhookDestinations":1}} +``` + +`restoreBacklog: 1` is defect D-2 showing up on an operator surface, which is at +least the system reporting its own failure honestly. + +--- + +## 8. The one-minute demonstration — **PASS** + +`-phase oneminute`, **1.042 s**: + +```text +Validated purchase → authoritative Pro Entitlement +→ cancellation keeps access through period end +→ expiration removes the subscription source +→ lifetime source keeps access active +→ the SDK wire returns the same snapshot +→ signed webhook reports the change +``` + +Final state: + +```text +snapshot_version 4 | pro | active | source_count 2 | explanation_code permanent_source_active + active_subscription | inactive | subscription_expired | T-50m → T-10m + one_time_non_consumable | active | one_time_purchase_owned | T-20m → (none) + +SDK wire: "state":"active","primaryExplanation":{"code":"permanent_one_time_purchase"},"sourceCount":2 +note: destination received event whe_… (answered 204); signature verified against key 0: true +``` + +The prompt's one-minute script says "all three SDKs synchronize the same +Snapshot". This run demonstrates the **wire** those SDKs consume, once. It does +not run three clients, and no claim is made that it did. + +--- + +## 9. Defects found + +The demonstrations found five defects. Three are release-blocking. Finding them +is the demonstration working as intended. + +None of these were fixed here: this deliverable is demo-driver-only, and fixes +go through the orchestrator. + +### D-1 — the Phase 9A → 9B seam is not wired at all (**critical**) + +**Symptom.** In a deployed API and worker, a validated Transaction Fact produces +no Purchase Lineage, no Subscription Instance, no customer association, and +therefore no projection, no Subscription Snapshot, no Customer Entitlement +Snapshot, no webhook, and nothing on any SDK or trusted surface. The entire +Phase 9B read model is unreachable from a purchase. + +**Evidence.** + +- The only `INSERT INTO purchase_lineages` in the repository is + `apps/api/internal/platform/billingcustomerpostgres/lineages.go:57`, reached + only through `billingcustomer.Service.LocateLineage` + (`apps/api/internal/billingcustomer/service.go:373`). That method has **zero + production callers** — only `service_test.go` and + `identity_integration_test.go`. +- `billingcustomer.Service.ResolveLineageCustomer` (`service.go:238`) — the only + path that attaches a lineage to a customer or writes + `billing_association_evidence` — likewise has **zero production callers**. +- `billingcustomer.Service.RecordSupersession` (`service.go:403`) and + `billingcustomer.Repository.EvidenceForReference` + (`apps/api/internal/platform/billingcustomerpostgres/repository.go:486`) also + have zero production callers. +- **Nothing anywhere** inserts `subscription_instances` or + `one_time_purchase_instances` outside integration-test fixtures, and + `billingprojectionpostgres` `loadLineages` joins to them for the instance id + every commit needs. +- `enqueueProjectionForFact` + (`apps/api/internal/platform/billingpostgres/jobs.go:339`) reads + `purchase_lineages` and, per its own comment, treats a missing lineage as + silence: `pgx.ErrNoRows → return nil`. + +**Repro.** Run `cmd/api` and `cmd/worker` with billing enabled (after D-3 is +fixed), deliver a valid store notification, and observe `purchase_lineages` +stays empty, `projection_jobs` stays empty, and every entitlement surface reports +`pending`/404 forever. + +**Driver stand-in.** `demo9b.go` `bridge()` performs the missing step through +the real services and prints `SUBSTITUTION bridge:` each time. + +**Related design gap.** `billingcustomer.LineageKey(provider, storeEnvironment, +root)` (`apps/api/internal/billingcustomer/lineage.go:16`) digests under domain +`mosaic-billing-lineage-v1`, while `billing_transaction_facts.purchase_chain_digest` +is `billing.AppleTransactionKey` (domain `mosaic-billing-apple-transaction-v1`) +or `billing.TokenDigest` (plain SHA-256). A lineage keyed with the package's own +helper can therefore **never** join to any fact, because every fact→lineage join +in the codebase compares `purchase_chain_digest` to `lineage_key_digest`. The +driver sets the lineage key from the fact's own `purchase_chain_digest`, which is +the only value that works. Whoever wires D-1 must resolve this. + +### D-2 — restore-sync reads a column that does not exist (**critical**) + +**Symptom.** Every restore-sync job that reaches stage 3 fails, is silently +rescheduled with backoff, burns its twelve attempts, and never produces an +outcome. Every restore reports `validation_pending` forever. + +**Location.** `apps/api/internal/platform/billingrestorepostgres/repository.go:306`: + +```sql +JOIN purchase_lineages l ON l.id = f.purchase_lineage_id +``` + +`billing_transaction_facts` has no `purchase_lineage_id` column. No migration +adds one — `00031` adds that column to `billing_association_evidence` only. Every +other fact→lineage join in the codebase uses +`purchase_chain_digest ↔ lineage_key_digest`. + +**Repro (in this run).** + +```text +PROBE: billingrestorepostgres stage-3 identity chain read (repository.go) + FAILED: ERROR: column f.purchase_lineage_id does not exist (SQLSTATE 42703) +PROBE: billing_transaction_facts has no such column + 0 +``` + +Or directly: `SELECT 1 FROM billing_transaction_facts f JOIN purchase_lineages l +ON l.id = f.purchase_lineage_id;` + +**Aggravating factor.** `ProcessNextRestoreSync` returns `(true, nil)` on a +`LoadChain` error — the job is rescheduled and the error is logged, so the +failure is invisible to the `(processed, error)` contract the worker loop reads. +`restoreFailedJobs` stays `0` on the projection-health surface while every +restore is broken; only `restoreBacklog` rises. + +### D-3 — the router panics whenever billing is enabled (**critical, startup**) + +**Symptom.** `cmd/api` panics during router construction whenever +`MOSAIC_BILLING_ENABLED` is set. The process never serves a request. + +```text +panic: chi: attempting to Mount() a handler on an existing path, '/environments/{environmentId}/billing' + …/transport/billingoperator.RegisterProjectRoutes(handler.go:67) + …/platform/httpserver.NewWithDependencies(router.go:256) + …/cmd/api/main.go +``` + +**Location.** Two modules mount the same chi path on the same Project subrouter: + +- `apps/api/internal/transport/billing/handler.go:87` — + `router.Route("/environments/{environmentId}/billing", …)` +- `apps/api/internal/transport/billingoperator/handler.go:67` — the same + pattern. + +`cmd/api/main.go` sets both `Billing` and `BillingOperator` under the same +`cfg.Billing.Enabled` branch, so they are always registered together. + +**Worse:** the collision cannot be avoided by disabling one. `httpserver.NewWithDependencies` +gates the `/v1` subtree and the authenticated `/projects/{projectId}` subtree on +`Billing != nil` (`router.go:187`, `router.go:193`), so `BillingOperator` cannot +be registered **at all** without the module it collides with. The entire Phase 9B +dashboard operator surface — customer list, customer lookup, customer detail, +entitlements, subscriptions, timeline, manual sync, restore jobs, identity +conflicts, and conflict resolution — is unreachable in every valid composition. + +**Repro.** `httpserver.NewWithDependencies(cfg, logger, Dependencies{Billing: b, +BillingOperator: o, PrincipalResolver: r})`. `router_test.go` never constructs +that combination, which is why nothing caught it. + +**Driver stand-in.** The operator surface is mounted on a second minimal mux in +`wire()` with the real handler, service, repository, and authorization; only the +mux and middleware stack are the demo's. Demonstration 12's conflict resolution +runs against it. + +### D-4 — a fact on one lineage recomputes the customer aggregate from that lineage alone (**critical — accidental revocation**) + +**Symptom.** When a customer holds more than one purchase lineage, committing a +fact on one of them rewrites the customer's authoritative Entitlement Snapshot +using **only that lineage**. Every other Entitlement Source disappears from the +snapshot and any Entitlement that depended on them flips to `inactive`. No +refund, no revocation, no expiry — the sources are simply not loaded. + +**Evidence (demonstration 5, reproduced on every run).** Before: three sources, +`pro` active via a lifetime purchase. After expiring one *subscription*: one +source, `pro` inactive, lifetime purchase still `validity_state: owned`. The +identical projection command with no lineage restriction restores all three +sources and `pro` to active. + +**Mechanism.** + +1. `enqueueProjectionForFact` (`billingpostgres/jobs.go:339–360`) writes + `scope_key = "customer:" + customerID` but stores + `detail = {"customerId": …, "lineageId": …}` with **both** populated. +2. `Job.Scope()` (`billingprojection/repository.go:224`) restores both onto + `Scope`. +3. `loadLineages` (`billingprojectionpostgres/repository.go:209`) filters + `AND ($4::text = '' OR l.id = $4)` — one lineage. +4. `Compute` (`billingprojection/service.go:312`) branches on + `input.Scope.CustomerID == ""`. It is not empty, so a **full customer + aggregate** is computed and committed from that one lineage's sources. + +**Aggravating factor — the coalescer hides the fix.** The partial unique index +`projection_jobs_scope_coalesce_idx ON projection_jobs(scope_key) WHERE status = +'queued'` (migration `00039`) keys only on `scope_key`. A correct customer-wide +trigger enqueued while a lineage-restricted `fact_committed` job is queued under +the same `customer:…` key is absorbed by it and never runs. Observed directly: +`billingprojection.Service.Enqueue` with an empty `LineageID` was silently +dropped in demonstrations 5 and 12. + +**Suggested direction (not applied).** Either clear `LineageID` when +`CustomerID` is present in `Job.Scope()`/`enqueueProjectionForFact`, or make +`Scope.Key()` and the coalescing index distinguish a lineage-restricted job from +a customer-wide one. The former is smaller and matches `Scope.Key()`'s existing +"customer wins" rule; the latter is what the index name implies. This is an +orchestrator decision, not a demo one. + +**Driver workaround.** From demonstration 5 onward `project()` finishes with a +direct customer-scoped `Project` call, so the later demonstrations reason about +the state the engine actually derives. The engine is not being worked around; the +job scoping is. + +### D-5 — the conditional GET can never return 304 (**medium**) + +**Symptom.** `GET /v1/sdk/billing/entitlements` with `If-None-Match` always +returns `200` and a full snapshot. The 304 path the handler documents and the +transport implements is unreachable. + +**Mechanism.** `billingaccess.Service.Sync` +(`apps/api/internal/billingaccess/sync.go:106`) requires +`request.KnownSnapshotVersion > 0 && == view.SnapshotVersion` as a precondition +of `Unchanged`. On the GET form there is no request body, so +`KnownSnapshotVersion` is always `0` and the precondition can never hold. The +transport's 304 branch (`transport/billingaccess/handler.go:337`) is dead code on +that verb. + +**Impact.** Bounded: the ratified cross-SDK flow is the POST form, which works +correctly and is what all three SDKs use. The cost is that the conditional-GET +bandwidth saving the surface advertises does not exist, and any third-party +integrator following ordinary HTTP conventions gets a full snapshot every poll on +the highest-QPS authenticated surface Mosaic serves. + +**Suggested direction (not applied).** Either accept `If-None-Match` alone as +sufficient on GET (dropping the monotonicity precondition for that verb, with the +entity tag already covering rule-version changes because it is derived from the +snapshot checksum), or document the GET form as non-conditional and remove the +dead branch. Contract-visible either way, so it needs the protocol owner. + +### D-0 — driver bug, recorded for completeness (fixed in the driver) + +The first driver run silently lost seven of eighteen notifications: they were +accepted with `202`, produced no Raw Billing Input, and collapsed into a single +hour-bucketed `signature_invalid`/`intake_attribution_failed` quarantine row. +The cause was the driver's synthetic certificate chain being valid for only 24 +hours while the demonstrations sign payloads with `signedDate` values months in +the past — `appstorejws` correctly validates the chain **as of the payload's +signedDate**. Mosaic behaved correctly; the driver did not. Fixed by widening the +synthetic chain's validity (`demoChainBackdate`). + +Worth noting for the reviewer regardless: an operator whose intake starts +rejecting notifications sees **one** quarantine row per credential per reason per +hour, with the notification UUIDs unrecoverable. That is the deliberate 9A +unbounded-growth control, and it is the right trade — but it means a systematic +signing failure is very hard to diagnose from the quarantine surface alone. + +--- + +## 10. Limitations — what only a live sandbox can prove + +Unchanged from the Phase 9A position (OD-12(b) makes live verification a named +blocking pre-production follow-up), plus what is specific to 9B: + +1. **That Mosaic accepts Apple's real signing chain.** The synthetic chain + proves the verifier accepts a root it was told to trust. +2. **Real provider transition semantics.** Every fact here was synthesized to a + documented shape. Grace, billing retry, account hold, pause, refund, + revocation, `REFUND_REVERSED`, prorated refunds, and Family Sharing were + normalized from documentation, not observed. This is exactly the OD-12(b) + list. +3. **Google Play end-to-end.** Phase 9A's demonstration covers Google ingestion; + this one is Apple-only, because Apple's payload shape is the one that lets a + driver control every effective timestamp a Phase 9B projection reads. Google's + `occurred_at` is lineage-constant by design and its state is re-queried live, + so a synthetic Google lifecycle would be testing the stub's state machine + rather than Mosaic's. Google projection paths (`cancellation_scheduled`, + `paused`, `resumed`, `grace_period_start`, `linkedPurchaseToken` + supersession) are therefore **not demonstrated here**. +4. **The three SDK clients.** Wire-level only, as classified in §2. +5. **Real webhook receivers.** The destination is a loopback stub. Real DNS, + real TLS chains, real intermediary behaviour, and real receiver semantics are + not exercised. +6. **Concurrency.** Every job here was drained serially by one worker. Advisory + lock serialization, the compare-and-swap on `current_projection_version`, and + the coalescing index under genuine contention are covered by unit and + integration tests, not by this demonstration. +7. **Performance.** No SLO is claimed. The only figures produced are the run + wall-clocks in §1. + +--- + +## 11. Reproduction, artifacts, and checks + +### The driver + +`apps/api/cmd/billingdemo`, extended with: + +| File | Contents | +| --- | --- | +| `demo9b.go` | the fourteen demonstrations, the one-minute demonstration, the 9A→9B bridge substitution, and the projection/webhook drivers | +| `demo9b_seed.go` | the `proj_demo9b` tenant and its idempotent reset | +| `demo9b_stubs.go` | the demonstration trust anchor (`//go:debug x509usefallbackroots=1`), the webhook destination stub and its independent signature verifier, the shared-vector loader | +| `demo9b_vectors.go` | typed Apple transaction / renewal / notification vectors | +| `demo9b_helpers.go` | HTTP helpers per surface, shared reads, transcript helpers, redaction | +| `main.go` | `-phase 9a|9b|all|oneminute`, the Phase 9B composition, the second operator mux (D-3) | +| `vectors.go` | `demoChainBackdate` (D-0) | + +Every file carries `//go:build billingdemo`. Verified excluded from default +builds: + +```text +$ go list -f '{{.GoFiles}}' ./cmd/billingdemo +package …/cmd/billingdemo: build constraints exclude all Go files in …/cmd/billingdemo +``` + +### Reproducing + +```bash +docker run -d --name mosaic-9b-demo-pg \ + -e POSTGRES_DB=mosaic -e POSTGRES_USER=mosaic -e POSTGRES_PASSWORD=mosaic_dev \ + -p 5439:5432 postgres:17-alpine + +cd apps/api +export DATABASE_URL="postgres://mosaic:mosaic_dev@127.0.0.1:5439/mosaic?sslmode=disable" +go run ./cmd/migrate up +go run -tags billingdemo ./cmd/billingdemo -phase 9b # fourteen demonstrations +go run -tags billingdemo ./cmd/billingdemo -phase oneminute # the one-minute demonstration +go run -tags billingdemo ./cmd/billingdemo -phase all # 9A followed by 9B +``` + +**Local-DB note.** Migration `00041` is a reserved no-op. A local database +already past version 42 from an earlier branch state must be recreated rather +than migrated forward. + +### Determinism + +Two consecutive full runs against the same database were compared after +normalizing generated identifiers, timestamps, digests, and the ephemeral stub +port. The complete diff is: + +```text +563c563 +< note: retry became available after 37s of real elapsed time; no clock was manipulated +--- +> note: retry became available after 23s of real elapsed time; no clock was manipulated +606c606 +< === demonstration complete in 48.611s === +--- +> === demonstration complete in 34.024s === +``` + +That is the jittered webhook retry backoff and the total wall-clock. Every one of +the other 604 transcript lines — every state, every checksum comparison, every +row count, every source set, every signature verification — is identical. + +### Checks run + +| Check | Result | +| --- | --- | +| `gofmt -l apps/api` | clean | +| `go build ./...` (default tags) | clean; driver excluded | +| `go vet ./...` | clean | +| `go vet -tags billingdemo ./...` | clean | +| `go test ./...` (unit) | pass | +| `DATABASE_TEST_URL=… go test -p 1 -count=1 ./internal/platform/...` (integration, incl. billing, projection, grant, operator, customer, webhook, migrations) | pass | +| `go run ./cmd/migrate up` on an empty database | 48/48 applied | +| `go run ./cmd/migrate preflight` | `verdict: compatible` | +| Full driver run, `-phase 9b` | green, twice | +| Driver run, `-phase oneminute` | green | + +Note: the integration suite must be run with `-p 1`. Several packages apply the +full migration set to the same database, and running them in parallel produces +spurious `duplicate key … pg_type_typname_nsp_index` failures that are an +artifact of the harness, not of the schema. + +### Cleanup + +```bash +docker rm -f mosaic-9b-demo-pg mosaic-9b-it-pg +``` + +--- + +## 12. Stage 4 verdict + +The fourteen demonstrations and the one-minute demonstration were executed +end-to-end against a real API surface, real application services, real worker job +functions, and real PostgreSQL, twice, deterministically. + +| # | Demonstration | Verdict | +| --- | --- | --- | +| 1 | Initial subscription | PASS, through production wiring (was a bridge substitution, D-1) | +| 2 | Renewal | PASS | +| 3 | Cancellation without immediate revocation | PASS | +| 4 | Expiration | PASS | +| 5 | Multiple sources | **FAIL** on the queued path (D-4); passes on the projection engine | +| 6 | Refund or revocation | PASS | +| 7 | Grace and recovery | PASS | +| 8 | Out-of-order fact | PASS | +| 9 | Upgrade or downgrade | PASS | +| 10 | Restore across devices | **FAIL** (D-2) | +| 11 | Offline cache (wire level) | PARTIAL — POST form passes, conditional GET fails (D-5) | +| 12 | Identity conflict | PASS | +| 13 | Webhook retry | PASS | +| 14 | Replay and rule versions | PASS (shadow projection deferred per OD-11(a)) | +| — | One-minute demonstration | PASS | + +**Phase 9B is not shippable in its current state.** The projection semantics, +the ordering model, the grant model, the entitlement aggregation, the contract +wire, the webhook subsystem, and the identity machinery all behave as specified +under demonstration. What is missing is the wiring between them: a purchase +cannot reach a lineage (D-1), the API cannot start with billing on (D-3), a +customer with two purchases loses one of them on the next fact (D-4), and no +restore can ever settle (D-2). Every one of those is a small, well-located change +— but each one is on the critical path of the phase's central claim, and D-4 is +squarely inside the "no known critical accidental-revocation path" acceptance +criterion. + +Recommended next step: return D-1 through D-5 to the orchestrator for assignment, +then re-run this driver unchanged. It is written to be re-run, and every +substitution it currently performs (`bridge()`, the second operator mux, the +direct customer-scope reprojection) should be deletable once the corresponding +defect is fixed — which makes the driver its own regression check for all five. + +--- + +## 13. Fixes verified (Stage 4 defect pass, 2026-07-29) + +Everything above §12 is the original finding record and is left exactly as it +was written. This section appends what changed and what a re-run of the same +driver produced. The same honesty rule applies: every verdict below is backed by +a run recorded here, and D-1 is reported as **not fixed** because it is not. + +### Disposition + +| Defect | Severity | Disposition | +| --- | --- | --- | +| D-1 — the 9A→9B seam is not wired | critical | **Fixed and verified.** | +| D-2 — restore-sync reads a column that does not exist | critical | **Fixed and verified.** | +| D-3 — the router panics whenever billing is enabled | critical | **Fixed and verified.** | +| D-4 — a fact on one lineage recomputes the aggregate from that lineage alone | critical | **Fixed and verified.** | +| D-5 — the conditional GET can never return 304 | medium | **Fixed by removal, verified.** | + +### D-3 — router composition + +Three modules opened their own `chi.Route()` on +`/environments/{environmentId}/billing`. The subrouter is now created once by +`httpserver.NewWithDependencies` and `billinghttp`, `billingoperatorhttp`, and +`billingwebhookhttp` each register into it through a new +`RegisterEnvironmentRoutes`. Every published URL is unchanged, so the +dashboard's generated client paths and `docs/backend/openapi.yaml` are +untouched. The `/v1` and Project subtree gates no longer depend on the Phase 9A +ingestion module; they check every dashboard-facing billing module. + +The driver's second operator mux is deleted. Demonstration 12 now runs against +the standard composition, on the same server as every other surface. + +Regression: `TestFullBillingCompositionMountsWithoutCollision` builds the full +production dependency set and asserts no panic plus a reachable route from each +colliding surface; `TestBillingOperatorRegistersWithoutPhase9AIngestion` asserts +the operator surface registers alone. + +### D-2 — restore chain read + +The stage-3 join now uses `purchase_chain_digest ↔ lineage_key_digest`, scoped +by Environment and provider. A `LoadChain` error is no longer absorbed as +`(true, nil)`: it reaches the worker loop, and an attempt-exhausted job is +completed as `failed` so `restoreFailedJobs` counts it. + +Demonstration 10 in the re-run, where the original run recorded +`queued / validation_pending` forever: + +```text +SQL: restore job state + status | attempt_count | outcome | uncertainty_reason | baseline_snapshot_version + ----------+---------------+-------------------------+--------------------+-------------------------- + completed | 2 | no_additional_purchases | none | 9 +``` + +And on the operator surface that reported the symptom in §7, `restoreBacklog` is +now `0` rather than `1`. + +Regressions: `TestLoadChainResolvesTheCustomerThroughTheLineageDigest` +(integration — the defect was a non-existent column, which only a real database +catches) and `TestChainReadFailureSurfacesToTheWorker` (unit, both the retryable +and the exhausted path). + +### D-4 — accidental revocation + +Ruling applied: a customer entitlement snapshot is only ever minted at customer +scope from **all** of the customer's lineages. `enqueueProjectionForFact` writes +either a customer scope with no lineage or a lineage scope with no customer, and +`Job.Scope()` enforces the same rule for rows queued before the fix. A +lineage-scoped command that finds its lineage has acquired a customer escalates +by enqueueing customer scope rather than deriving an aggregate itself. The +coalescing index keeps its meaning because `customer:…` and `lineage:…` keys can +no longer stand for two different amounts of work. + +Demonstration 5 in the re-run, through the queued path alone and with the +driver's direct-reprojection workaround deleted: + +```text +snapshot_version 4 | pro | active | source_count 3 | explanation_code permanent_source_active + active_subscription | inactive | subscription_expired | T-3h → T-1h + active_subscription | inactive | subscription_expired | T-30m → T-10m + one_time_non_consumable | active | one_time_purchase_owned | T-20m → (none) +``` + +Three sources, `pro` active through the lifetime purchase, after the +subscription expired — the state the original run could only reach by bypassing +the queue. + +Regression: `TestFactOnOneLineageDoesNotRevokeTheCustomersOthers` is +demonstration 5 reduced to its failing core, as an integration test. It was +confirmed to fail against the pre-fix code before the fix was restored. + +### D-5 — conditional GET + +Removed rather than made reachable. The `GET` form is a plain `200` +full-snapshot read with no `If-None-Match` parameter and no `304` response; the +POST body's `knownSnapshotVersion` is the one conditional mechanism, and it is +the one all three SDKs use. Handler, its pinning test, `docs/backend/openapi.yaml`, +and the backend doc's known-gap section are updated. + +**For the protocol owner:** `docs/protocol/authoritative-entitlement-v1.md` +still describes conditional `GET` as a server-side option. That file is +protocol-owned and was not edited here; it needs a one-line correction. + +### D-1 — the seam + +The canonical-domain half came first: `billingcustomer.LineageKey` now digests in the fact's own +domain (`billing.AppleTransactionKey` / `billing.TokenDigest`), so a lineage created through it +can join to the facts it was created for. That function had no production caller, so the change +was corrective and carried no migration. + +The seam itself is now wired, in two halves. + +**Structural half, inside the fact's own transaction.** `CompleteAttempt` materializes the +Purchase Lineage and the Subscription or One-Time Purchase Instance it owns before it enqueues +the projection, so the trigger is exactly as durable as the fact it points at. The lineage is +keyed on the **chain root**, resolved by walking supersession edges backwards, because a Google +plan change hands the chain a new token and keying on the fact's own digest would fragment one +subscription's history into pieces the projection loader — which walks those edges *forward from +the root* — would never reassemble. + +**Identity half, after the commit,** through `billing.LineageBinder`. It is the full OD-2 ladder, +with the resolver deciding which rung wins: + +1. **Submission-context evidence.** An observation submitted while holding a Customer Access + Token (`Mosaic-Customer-Token`) records `trusted_server_observation` evidence keyed on the + transaction reference. This is the only thing in a deployed system that can attach a *first* + purchase to an identified customer: a store notification arrives out of band and names + nobody, and the observation contract carries no customer member. `EvidenceForReference` reads + it back when the fact commits — its production caller at last. +2. **Provider correlators.** Apple's `appAccountToken` and Google's + `obfuscatedExternalAccountId`, read from the provider's *authoritative response* rather than + the notification, hashed inside the validator at the point they are parsed. No fact column + holds one; no log line, span attribute, or audit record sees the value or the digest. Phase + 9A's fact-shape exclusion is unchanged and the digest's home is `billing_association_evidence`. +3. **Prior lineage association**, contributed by `ResolveLineageCustomer` itself. +4. **Lazy purchase-anchored creation** (plan §5a rules 1 and 2), recorded as the new + `purchase_anchor` evidence type from migration `00049`. It is written *after* the customer + exists and is never offered to the resolver, so it can never select a customer. + +`RecordSupersession` gains its production caller too: a lineage-level edge is recorded when a +link is observed late — the successor token arrived first and was materialized before anything +said it superseded an earlier chain. A token handover *inside* one chain is not a lineage +replacement and correctly records no edge. + +An association that establishes an owner now also enqueues the **customer-scoped** projection. +Any job already queued for that lineage is lineage-scoped, because it was queued when the +lineage had no customer, and a lineage-scoped command deliberately mints no customer snapshot. + +**The driver's `bridge()` substitution is deleted, along with `materializeInstance`.** The +demonstration now reports purchases the way an SDK does — a token-bound observation through the +real public endpoint — and every lineage, instance, association, supersession edge, and +projection trigger below is written by production code. `grep -c SUBSTITUTION` over the +transcript returns **0**. + +Regressions: `TestValidatedFactBecomesACommittedEntitlementSnapshot` (submission evidence → +validated fact → committed snapshot with an active entitlement, through production wiring only) +and `TestPurchaseWithNoEvidenceAnchorsAndLaterIdentifies` (an anonymous purchase anchors, the +reason is recorded as `purchase_anchor`, and identifying the person afterwards attaches the alias +to that same customer rather than minting the duplicate the model exists to avoid). + +**Two observations from the re-run, both the fix working:** + +- Demonstration 12's diagnostic is now `multiple_customers_claim_lineage` rather than + `reassignment_requires_operator_resolution`. Both customers' backends present their own token + for the same transaction, which is two equally authoritative claims — so the resolver conflicts + before the reassignment downgrade is reached. The outcome an operator sees is identical: the + lineage freezes, nobody is granted anything, and the incumbent keeps the purchase. +- Snapshot versions across the run are lower than in the original transcript. The original + double-projected every change — a wrong lineage-restricted aggregate followed by a correcting + direct one — and each minted a version. One correct projection now mints one. + +### Protocol note + +Observation submissions accept an optional `Mosaic-Customer-Token` request header. No ratified +record schema changed: it is a credential, and a credential must not travel in a body Mosaic +seals and can replay. **For the protocol owner:** the Billing Ingestion Contract's transport +documentation should record the header alongside `Mosaic-SDK-Key`. + +### Re-run results + +Driver run unchanged apart from the deleted substitutions, against +`mosaic-9b-demo-pg`: + +| Run | Result | Substitutions remaining | +| --- | --- | --- | +| `-phase 9b` (first) | green, 1m16.5s | 0 | +| `-phase 9b` (second, consecutive) | green, 1m15.1s | 0 | +| `-phase oneminute` | green, 0.840s | 0 | + +The wall-clock is longer than the original 34–49 s because demonstration 13 now +retries a delivery on its *second* attempt, whose backoff is one step further up +the schedule than the first attempt's was. + +| # | Demonstration | Verdict after the fixes | +| --- | --- | --- | +| 1 | Initial subscription | PASS, through production wiring (was a bridge substitution, D-1) | +| 2 | Renewal | PASS | +| 3 | Cancellation without immediate revocation | PASS | +| 4 | Expiration | PASS | +| 5 | Multiple sources | **PASS** through the queued path (was FAIL, D-4) | +| 6 | Refund or revocation | PASS | +| 7 | Grace and recovery | PASS | +| 8 | Out-of-order fact | PASS | +| 9 | Upgrade or downgrade | PASS | +| 10 | Restore across devices | **PASS** (was FAIL, D-2) | +| 11 | Offline cache (wire level) | **PASS** — the GET form is a documented full-snapshot read (was PARTIAL, D-5) | +| 12 | Identity conflict | PASS, now on the standard composition (was on a second mux, D-3) and driven through the production observation surface (was a direct resolver call, D-1) | +| 13 | Webhook retry | PASS, retrying a replayed delivery — see the note below | +| 14 | Replay and rule versions | PASS | +| — | One-minute demonstration | PASS | + +**Demonstration 13 changed shape, and the reason is D-4's fix.** It used to +retry the delivery produced by a `REVOKE` on the conflict lineage. At that point +in the scenario the customer holds several granting sources, so revoking one +changes no Entitlement state — Mosaic correctly mints no snapshot and emits no +event, and the event the demonstration used to retry existed only because the +aggregate was being recomputed from a single lineage. The demonstration now +re-queues an already-committed delivery through the operator replay API +(`POST .../billing/webhook-deliveries/{deliveryId}/replay`), which preserves +every property it asserts — a stable event id, a byte-identical body across +attempts, a real jittered backoff, an append-only attempt history — and reaches +them through a surface an operator actually uses. + +### Checks run + +| Check | Result | +| --- | --- | +| `gofmt -l apps/api` | clean | +| `go build ./...` | clean | +| `go vet ./...` and `go vet -tags billingdemo ./...` | clean | +| `DATABASE_TEST_URL=… go test -p 1 -count=1 ./...` | pass | +| Driver `-phase 9b`, twice | green | +| Driver `-phase oneminute` | green | + +| `go run ./cmd/migrate up` → `down --confirm` → `up` on migration `00049` | clean; `preflight` verdict `compatible` | + +The driver's own transcript is the strongest single check: `grep -c SUBSTITUTION` over a full +`-phase 9b` run returns `0`. Every substitution §2 classified as such — the 9A→9B bridge, the +second operator mux, the direct customer-scope reprojection — is gone, and what remains +synthetic is only what §2 listed as unavoidable: the Apple signing chain, the provider stubs, the +webhook destination and its trust anchor, the dashboard principal resolver, and the SDKs +themselves. + +## Stage 5 final verification (2026-07-29) + +This section is append-only and supersedes two authority details in the earlier Stage 4 +transcript. A Customer Access Token presented with a **public SDK key** now records +`token_bound_submission`, below an established lineage association; it may attach an unattached +lineage but cannot move or freeze an attached one. Only the trusted-server observation endpoint, +authenticated by the Project's secret server key, records `trusted_server_observation`. +Demonstration 12 therefore opens its intentional conflict when Customer B's trusted backend +reports the lineage's next renewal transaction and the provider notification validates it. The +result is `reassignment_requires_operator_resolution`, the incumbent remains attached, the +lineage freezes, and the normal audited operator workflow resolves it. + +The Stage 5 review fixes also establish a strict, cross-platform version-0 placeholder for a +customer that has never projected. It is `pending`, empty, carries no prior version, and may be +used as `knownSnapshotVersion: 0`; the first ordinary committed snapshot replaces it at version +1. The canonical fixture and generated cache-decision vector are consumed by Flutter, iOS, and +Android. + +### Final demonstration results + +| Run | Result | Notes | +| --- | --- | --- | +| `go run -tags billingdemo ./cmd/billingdemo -phase 9b` | PASS, 1m4.724s | all 14 demonstrations; no substitutions; trusted-server identity conflict and stable-event retry green | +| `go run -tags billingdemo ./cmd/billingdemo -phase oneminute` | PASS, 0.946s | purchase, cancellation, lifetime source, expiration, SDK wire, and signed webhook | + +### Final conformance results + +| Check | Result | +| --- | --- | +| migration `up -> down --confirm -> up` through `00050` | PASS | +| `go run ./cmd/migrate preflight` | PASS: version 50, no pending migrations, not dirty, compatible | +| `GOCACHE=... go test ./...` | PASS | +| PostgreSQL `DATABASE_TEST_URL=... go test -p 1 ./...` | PASS on a disposable PostgreSQL 17 database | +| dashboard `npm run check` | PASS: format, lint, typecheck, 592 Vitest tests, 7 relay tests, production build | +| protocol `npm run check` | PASS: canonical validation and 184 tests | +| Flutter `flutter test`; `flutter analyze`; format check | PASS: 355 tests, 2 existing skips; no analyzer or format findings | +| iOS `swift test` | PASS: 203 tests, 1 existing skip | +| Android `./gradlew :mosaic:testDebugUnitTest` | PASS | +| `git diff --check` | PASS | + +The full driver uses local provider API/signing stubs and a local TLS webhook destination. Live +Apple sandbox and Google Play test verification remains the owner-approved blocking +pre-production follow-up; no live-provider result is claimed here. diff --git a/docs/reviews/phase-9b-entry.md b/docs/reviews/phase-9b-entry.md new file mode 100644 index 00000000..d39fc0cf --- /dev/null +++ b/docs/reviews/phase-9b-entry.md @@ -0,0 +1,77 @@ +# Phase 9B Entry Review: Subscription State and Authoritative Entitlements + +Date: 2026-07-28 +Owner: Muhideen Mujeeb Adeoye +Decision authority: Owner + +## Decision + +**Open Gate 9B (Subscription State and Authoritative Entitlements).** + +The Phase 9B preflight gate was run on 2026-07-28 against branch +`phase/9a-transaction-ingestion-validation` head `1867605`. Every +structural prerequisite passed (see "Preflight result" below). Three +items were reported as blockers; the owner reviewed the blocker report +and explicitly approved proceeding, with the dispositions recorded +here. Gate 9C remains closed. + +## Preflight result + +Passed: Phase 8 accepted for GA (`v1.0.0` = `f83ba26`); Phase 9A +accepted with tracked follow-ups (`docs/reviews/phase-9a.md`, head +`1867605`); Apple and Google server-side validation implemented and +test-verified; notifications authenticated and ingested; Raw Billing +Inputs, Validation Attempts, and the Billing Event Ledger append-only +with DB triggers; Normalized Transaction Facts immutable and +provider-independent; product resolution via mapping history with +quarantine; idempotent duplicate handling; history-preserving replay +and revalidation; reconciliation; schema-enforced sandbox/production +isolation; PostgreSQL system of record with clean Goose migrations; +encrypted and redacted provider credentials; Products and Entitlements +separate; entitlement state still provider-owned; no partial 9B engine +present (confirmed by the 9A full-diff boundary sweep); no unresolved +critical security, tenant-isolation, migration, backup, or +data-integrity defect. + +## Owner dispositions on reported blockers + +1. **Live Apple/Google sandbox verification** (9A follow-up #1, named + in `docs/reviews/phase-9a.md` as a hard 9B entry precondition): + **waived for 9B entry and carried as a tracked 9B follow-up.** No + live Apple sandbox or Google Play test environment is reachable + from this workspace; all 9A provider flows were demonstrated with + synthetic signed vectors and local API stubs. Consequence accepted + by the owner: Phase 9B builds on provider behaviour verified + against recorded official documentation and synthetic conformance + vectors, not live store traffic. Live verification remains required + before production use of Mosaic Billing and before the Billing + Ingestion Contract leaves `draft`. +2. **Billing Ingestion Contract v1 remains `draft`:** unchanged. The + standing owner decision from 9A holds — the contract is not + promoted to approved without live-sandbox evidence. Phase 9B builds + against the draft contract as frozen at 9A acceptance; any 9B + amendment to it follows the contract's own draft-amendment process. +3. **Phase 9B entry review absent:** resolved by this document. +4. **Unrelated uncommitted changes:** the two modified + `.claude/agents/*.md` files were committed separately + (`ecfe845`) before branching. + +## Baseline + +- Base: `ecfe845` on `phase/9a-transaction-ingestion-validation` + (one chore commit after the accepted 9A review head `1867605`). +- Branch: `phase/9b-subscription-state-entitlements`. +- GA tag: `v1.0.0` (`f83ba26`). +- 9A review: `docs/reviews/phase-9a.md` — Accepted with tracked + follow-ups; follow-ups #2–#8 remain the nonblocking backlog. + +## Standing constraints carried into 9B + +- Do not begin Phase 9C (migration, bulk import, cutover, financial + reporting, manual grants). +- No merge to `main`, no tags, without owner action. +- Phase 9A history is immutable; 9B must not silently repair 9A + defects — any discovered 9A defect is classified and surfaced. +- Live-sandbox verification (disposition 1) gates production use and + contract promotion, and must appear in the Phase 9B review's + tracked follow-ups if still outstanding at 9B acceptance. diff --git a/docs/reviews/phase-9b.md b/docs/reviews/phase-9b.md new file mode 100644 index 00000000..a77a1ad2 --- /dev/null +++ b/docs/reviews/phase-9b.md @@ -0,0 +1,173 @@ +# Phase 9B Review: Subscription State and Authoritative Entitlements + +Date: 2026-07-29 +Owner: Muhideen Mujeeb Adeoye +Decision authority: Owner + +## Status + +**Accepted with tracked follow-ups.** Phase 9B implementation and Stage 5 review are complete. +Phase 9C may proceed only after owner direction. This review does not merge, tag, promote any +draft contract, or start Phase 9C work. + +## Baseline + +- Base commit: `ecfe845`, after the accepted Phase 9A head `1867605`. +- Branch: `phase/9b-subscription-state-entitlements`; reviewed head: `13eec83570f62545f6a7ba36639bf5a72105a15d` with the Phase 9B worktree uncommitted. +- Worktree: intentionally dirty with the reviewed Phase 9B implementation. Required untracked files are listed under Decision and must be included in the integration commit. +- GA tag: `v1.0.0` at `f83ba26`. +- Accepted Phase 9A review: `docs/reviews/phase-9a.md`, accepted with tracked follow-ups. +- Contracts: Authoritative Entitlement v1 draft, Customer Access Token v1 draft, Billing State Webhook v1 draft; Billing Ingestion v1 remains draft. +- Projection rule: version 1, provider-independent deterministic ordering and replay. +- Product grant policy: immutable prospective versions; Product replacement; retroactive correction only as a validated additive superset with impact preview and confirmation. +- Offline access: bounded grace; refresh after 1 hour, valid until 7 days by default, hard combined horizon of 30 days; expiry resolves to `unknown`, never `inactive`. +- Store policy: verified grace may grant; billing retry/account hold and effective pause do not; cancellation changes renewal intent but preserves access through the paid period. +- Customer association: Project-scoped Billing Customer, Environment-scoped purchase and snapshot state; lazy purchase anchoring; protected correlator evidence; public SDK-key evidence cannot move or freeze an attached lineage; trusted or operator evidence controls reassignment. +- Locking: transaction-scoped advisory locks by customer or lineage, CAS on projection version, and partial uniqueness on queued scope; no database lock spans network I/O. +- Webhook signing: HMAC-SHA256 over version, timestamp, event ID, and exact body; stable event IDs, rotating active keys, HTTPS-only pinned destinations, no redirects. +- Official documentation used, accessed 2026-07-28: Apple App Store Server API transaction/renewal payloads, subscription status and history; App Store Server Notifications V2; StoreKit `currentEntitlements`, `RenewalState`, Family Sharing, and `AppStore.sync`; Google Play Developer API v3 subscriptions v2, revoke, voided purchases, and products; Play Billing lifecycle, RTDN, test guidance, and `ReplacementMode`. + +## Completed Deliverables + +- Billing Customers, aliases, append-only association evidence, conflicts, lazy purchase anchors, protected adoption, and `absorbed` anchor history. +- Environment-scoped purchase lineages, subscription instances, non-consumable one-time instances, supersession, and no consumable entitlement model. +- Provider-independent multi-axis subscription state machine, canonical ordering, immutable snapshots and timeline, checkpoints, projection jobs/attempts, and projection-rule version 1. +- Immutable Product Grant Versions, Entitlement Sources identified by `(lineage, Mosaic Product, grant version)`, and monotonic Customer Entitlement Snapshots. +- Trusted server reads, SDK sync, opaque revocable Customer Access Tokens, restore jobs, deterministic replay/checksum comparison, and projection health. +- Minimal `customer.entitlements.changed` webhook slice with destination management APIs, encryption, signing, delivery history, retry, replay, audit, and SSRF controls. Full webhook dashboard management remains deferred by OD-1. +- Billing dashboard customer search/detail, subscription timeline, explanations, conflicts, restores, replay, projection health, recovery links, paging, Environment route scope, and generated OpenAPI client. +- Flutter, iOS, and Android authoritative entitlement sync, strict decoding, atomic monotonic caches, bounded-offline decisions, token refresh, restore, listeners, identity clearing, and canonical version-0 never-projected placeholder support. +- Goose migrations through `00050`, OpenAPI, operational documentation, structured diagnostics, audit, metrics/spans, protocol fixtures/vectors, unit and PostgreSQL integration tests. + +## Product Review + +Demand is validated by Mosaic's native billing and paywall scope: customers need one authoritative, +explainable access decision across store lifecycle events and multiple purchase sources. Mosaic +Billing stays optional and does not replace provider-observed SDK commerce APIs. The shipped +subscription state and Entitlement aggregate make cancellation, grace, retry, pause, refund, +revocation, upgrades, permanent purchases, and offline access explicit without exposing provider +vocabulary as the public state model. + +The Product grant and bounded-offline policies match the approved plan. All OD-1 through OD-19 +owner decisions were implemented as approved, including opaque revocable tokens (OD-14), the +minimal webhook slice (OD-1), deferred shadow diff engine until a second rule exists (OD-11), and +the blocking pre-production live-store verification follow-up (OD-12). RevenueCat migration, +manual grants, financial reporting, cutover, and bulk reassociation remain Phase 9C or later. + +## UX Review + +Operators can search customers, inspect subscription state axes and append-only Timeline events, +trace every Entitlement to its sources and explanations, and distinguish cancellation, grace, +billing retry, pause, refund, revocation, unknown, and unavailable. Multiple sources do not hide +one another. Conflict, restore, replay, and projection-health surfaces expose status and direct +recovery paths without offering unsafe direct Entitlement mutation. Environment-scoped Billing +routes now retain their scope. Exact provider transaction-reference lookup remains a support-tool +enhancement rather than a Phase 9B correctness dependency. + +## Engineering Review + +PostgreSQL is the system of record and migrations are explicit. Facts, published grants, +snapshots, timeline entries, evidence, webhook events, and attempts preserve history. Projection +is serialized and transactional, ordered independently of arrival, checkpoint-independent under +replay, checksum-deterministic, and retry-safe after partial identity/adoption enqueue failures. +Both affected customer aggregates are reprojected on legitimate reassignment or adoption. + +Token scope, cache monotonicity, restore polling, worker leases/retries, webhook idempotency, and +failure recovery are covered at the lowest useful layers. The full Go suite, serial PostgreSQL +suite, migration drill, dashboard gate, protocol suite, three SDK suites, and both demonstrations +passed. No unavailable code checks remain; only live provider behavior is unavailable locally. + +## Protocol Review + +All three Phase 9B contracts are versioned drafts with closed producer schemas and compatibility +manifests. State semantics are provider-independent; `unknown` is persisted uncertainty while +`unavailable` is a read-time service result. Snapshot monotonicity and customer/Project/Environment +binding are explicit. Malformed, older, wrong-customer, or corrupt snapshots fail safely. + +The canonical version-0 record is strictly the pending, empty, never-projected placeholder; it is +accepted by all SDKs, may be sent as `knownSnapshotVersion: 0`, and is replaced by ordinary version +1. Conditional sync is `POST` with a `200 snapshotUnchanged` body; `GET` is unconditional and never +returns 304. Existing contracts remain compatible, fixtures contain no provider secret, and source +identity is the ratified lineage/Product/grant-version tuple. + +## Security Review + +Aliases are domain-separated digests and customer tokens are random opaque values stored only as +digests. Project and Environment boundaries are schema- and service-enforced. A public SDK key +cannot select an arbitrary customer or use token-bound submission evidence to move/freeze an +attached purchase. Apple transaction-reference possession is not accepted as ownership proof. + +Webhook secrets are encrypted, signatures pass shared vectors, destination delivery enforces the +documented SSRF policy, cross-tenant access tests pass, sensitive identifiers are excluded from +logs and responses, and high-risk identity, token, replay, conflict, grant, and webhook operations +are audited. There is no direct Entitlement mutation endpoint. + +## State-Machine Review + +The five state axes cover active, trial, grace period, billing retry, paused, expired, revoked, +refunded, unknown, renewal intent, billing condition, lifecycle, and uncertainty without collapsing +provider-specific events into the public contract. Transition tables cover cancellation, period +expiration, grace/recovery, retry, effective pause/resume, refund, revocation/reversal, upgrade, +downgrade, one-time ownership, late facts, unsupported states, and unknown evidence. Ordering and +projection-rule version 1 are recorded on derived state. + +## Entitlement Review + +Grant versions are immutable and selected by effective-time policy. Every contributing source is +preserved; a permanent source has no false expiry; source end and uncertainty remain explicit. +Effective dates and explanation codes are stable public data. Snapshot versions advance only on +material change, webhook changes derive from committed snapshots, and replay produces the same +checksum without rewriting history. + +## SDK Review + +All SDKs use an application-provided Customer Access Token, synchronize the same versioned +snapshot, treat ETag only as opaque equality, write caches atomically, reject regressions and +identity mismatches, and apply the same bounded-offline state machine. Tokens remain memory-only; +one forced refresh retry is bounded. Logout and identity change cancel inflight work and clear +customer state before another read. Restore results are explicit, listeners do not leak old grants, +and cross-platform fixtures cover snapshot, unchanged, placeholder, cache, freshness, and digest +behavior. Live StoreKit/Google Play device checks remain unavailable. + +## Webhook Review + +The shipped event vocabulary is limited to committed customer Entitlement changes. Event IDs and +bodies remain stable across retries; signatures bind timestamp, event ID, version, and exact body; +attempts are append-only; delivery ordering is defined per destination; replay is audited; retry +exhaustion is visible; and destination creation/delivery is protected by encryption and SSRF +controls. Delivery failure never rolls back access state. Full destination-management UI is not in +the approved OD-1 slice. + +## Phase Boundary Review + +No RevenueCat migration, historical customer import, dual-run cutover, bulk repair migration tool, +financial reporting, manual paid-access grant, or other Phase 9C implementation was introduced. + +## Demo Review + +The integrated driver passes initial purchase, renewal, cancellation with retained access, +expiration, multi-source aggregation, refund/revocation, grace/recovery, out-of-order replay, +Product transition, restore/cross-device sync, offline wire policy, conflict/no-double-grant, +stable-ID webhook retry, and deterministic replay. Per OD-11, the unimplemented rule version is +refused and replay/checksum comparison leaves state unchanged; no full shadow diff engine is +claimed. The one-minute purchase-to-webhook demonstration also passes. Exact evidence is in +`docs/reviews/phase-9b-demo-evidence.md`. + +## Decision + +**Phase 9B accepted with tracked follow-ups; proceed to Phase 9C only on owner direction.** + +Tracked follow-ups: + +1. Before production use or promotion of any Billing contract from draft, verify Apple and Google + sandbox/test transitions for purchase, renewal, grace, billing retry/account hold, pause, + refund, revocation, restore, and provider notification ordering. +2. Before integration, include every required untracked artifact. In particular, `git add -u` is + insufficient: migration `00050`, both version-0 protocol fixtures, the dashboard list-heading + module, and this review document are new files. +3. Treat exact provider transaction-reference lookup and full webhook destination UI as later + operator-product work, not as direct Entitlement mutation or a reason to weaken access controls. + +Final Stage 5 product, UX, protocol, and quality reviews were completed. The targeted quality +rereview found no blocking defect and accepted closure with the live-provider and integration +follow-ups above. diff --git a/examples/android-example/README.md b/examples/android-example/README.md index 6052deb0..711d5e23 100644 --- a/examples/android-example/README.md +++ b/examples/android-example/README.md @@ -194,3 +194,47 @@ device-independent proofs run on the JVM: ../../sdk/android/gradlew -p ../../sdk/android :mosaic-google-play:testDebugUnitTest \ --tests 'dev.mosaic.sdk.googleplay.MosaicGooglePlayAdapterTest' ``` + +### Authoritative entitlements + +Authoritative entitlements are inert unless a Customer Access Token is supplied. +The example takes one as a launch extra purely to stand in for the application +backend that a real app must run: a public SDK key can never select a Billing +Customer, so there is no client-only way to obtain one. Mint the token through +the trusted server API, then: + +```bash +adb shell am start -n dev.mosaic.example/.MainActivity \ + --es mosaic.sdk.key SDK_KEY \ + --es mosaic.sdk.endpoint http://10.0.2.2:8080 \ + --es mosaic.application.id APPLICATION_ID \ + --es mosaic.placement onboarding_complete \ + --es mosaic.customer.token CUSTOMER_ACCESS_TOKEN \ + --es mosaic.customer.id BILLING_CUSTOMER_ID +``` + +The third status line reports the authoritative state of the `pro` Entitlement, +the accepted snapshot version, and the cache state (`fresh`, +`refresh_recommended`, `stale_within_grace`, `expired`, `missing`, `invalid`, or +`different_customer`). Two of those readings are worth understanding rather than +treating as failures: `unknown` and `unavailable` mean Mosaic could not answer, +never that the customer has no access, and the example deliberately does not +render them as a denial. + +The **Restore and sync** button appears only when a token was supplied. It runs +the ordinary provider restore and then waits a bounded three attempts for Mosaic +to validate the result, so "recovered, Mosaic validation pending" is the expected +reading immediately after a fresh-device restore — not an error. + +Omitting `mosaic.customer.token` shows the signed-out line and leaves every other +part of the example unchanged, which is what a host that has not adopted Mosaic +Billing sees. + +Emulator note: a real restore needs a Play-enabled image, a licence tester +account, and `--ez mosaic.google.play true`; a bare AVD cannot exercise it. The +device-independent proofs run on the JVM: + +```bash +../../sdk/android/gradlew -p ../../sdk/android :mosaic:testDebugUnitTest \ + --tests 'dev.mosaic.sdk.Customer*' +``` diff --git a/examples/android-example/app/src/main/kotlin/dev/mosaic/example/MainActivity.kt b/examples/android-example/app/src/main/kotlin/dev/mosaic/example/MainActivity.kt index 88c18a95..2bcca573 100644 --- a/examples/android-example/app/src/main/kotlin/dev/mosaic/example/MainActivity.kt +++ b/examples/android-example/app/src/main/kotlin/dev/mosaic/example/MainActivity.kt @@ -6,14 +6,17 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,6 +38,13 @@ import dev.mosaic.sdk.MosaicPlacement import dev.mosaic.sdk.MosaicPurchaseProvider import dev.mosaic.sdk.MosaicCommerceUpdateAcceptance import dev.mosaic.sdk.MosaicCommerceUpdateAcceptanceDisposition +import dev.mosaic.sdk.MosaicCustomerAccessToken +import dev.mosaic.sdk.MosaicCustomerAccessTokenProvider +import dev.mosaic.sdk.MosaicCustomerAccessTokenResult +import dev.mosaic.sdk.MosaicCustomerEntitlementSnapshotState +import dev.mosaic.sdk.MosaicCustomerEntitlementState +import dev.mosaic.sdk.MosaicCustomerSyncResult +import kotlinx.coroutines.launch import dev.mosaic.sdk.googleplay.MosaicGooglePlayAdapter import dev.mosaic.sdk.revenuecat.MosaicRevenueCatAdapter import java.net.URI @@ -114,6 +124,16 @@ class MainActivity : ComponentActivity() { val endpoint = intent.getStringExtra(SDK_ENDPOINT_EXTRA)?.let(URI::create) val applicationId = intent.getStringExtra(APPLICATION_ID_EXTRA)?.takeIf(String::isNotBlank) val purchaseProvider = configuredHostedProvider() + // Stands in for the application backend Mosaic Billing requires. A real app calls its own + // authenticated server, which mints the token through Mosaic's trusted API; a public SDK key + // can never select a Billing Customer, so there is no client-only version of this. + val customerToken = intent.getStringExtra(CUSTOMER_TOKEN_EXTRA)?.takeIf { it.length >= 16 } + val customerId = intent.getStringExtra(CUSTOMER_ID_EXTRA)?.takeIf(String::isNotBlank) + val tokenProvider = customerToken?.let { value -> + MosaicCustomerAccessTokenProvider { _ -> + MosaicCustomerAccessTokenResult.Issued(MosaicCustomerAccessToken(value), customerId) + } + } val mosaic = Mosaic.configure( sdkKey, purchaseProvider, @@ -124,6 +144,8 @@ class MainActivity : ComponentActivity() { // validation sooner; the example never treats it as proof of anything. transactionObservationEnabled = intent.getBooleanExtra(TRANSACTION_OBSERVATION_ENABLED_EXTRA, false), + // Null unless a token was supplied, which leaves authoritative entitlements inert. + customerAccessTokenProvider = tokenProvider, ) val hosted = mosaic.hostedConfiguration(applicationContext) val placement = intent.getStringExtra(PLACEMENT_EXTRA)?.takeIf(String::isNotBlank) @@ -144,9 +166,43 @@ class MainActivity : ComponentActivity() { "${observations.acceptedCount} accepted for validation · " + "${observations.droppedCount} dropped · ${observations.lastSafeCode ?: "no code"}" } + // The authoritative state is observed, not polled: identity changes and background + // refreshes both move it, and a Compose host should see both without asking. + val authoritative by hosted.customerEntitlements.collectAsState() + var restoreStatus by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + Column(Modifier.fillMaxSize()) { Text(analyticsStatus, modifier = Modifier.padding(12.dp)) Text(observationStatus, modifier = Modifier.padding(horizontal = 12.dp)) + Text( + describeAuthoritative(authoritative) + restoreStatus, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + if (tokenProvider != null) { + Button( + onClick = { + scope.launch { + restoreStatus = " · restoring…" + // Two axes, reported separately: what the store did, and what + // Mosaic could conclude from it. + restoreStatus = when (val result = hosted.restoreAndSyncCustomerEntitlements()) { + is MosaicCustomerSyncResult.AuthoritativeEntitlementsUpdated -> + " · restored, snapshot ${result.snapshot.snapshotVersion}" + is MosaicCustomerSyncResult.NativeRecoveryCompleted -> + " · recovered, Mosaic validation pending" + is MosaicCustomerSyncResult.NoAdditionalPurchases -> + " · no additional purchases" + else -> " · restore: ${result.providerOutcome}" + } + } + }, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + Text("Restore and sync") + } + } MosaicPlacement( client = hosted, placement = placement, @@ -204,5 +260,34 @@ class MainActivity : ComponentActivity() { const val ANALYTICS_ENABLED_EXTRA = "mosaic.analytics.enabled" const val ANALYTICS_FLUSH_EXTRA = "mosaic.analytics.flush" const val TRANSACTION_OBSERVATION_ENABLED_EXTRA = "mosaic.observations.enabled" + const val CUSTOMER_TOKEN_EXTRA = "mosaic.customer.token" + const val CUSTOMER_ID_EXTRA = "mosaic.customer.id" + } +} + +/** + * Renders the four authoritative states distinctly. + * + * `unknown` and `unavailable` are deliberately not shown as "no access": they mean Mosaic could not + * answer, and an app that renders them as a denial revokes paying customers during an outage. + */ +private fun describeAuthoritative(state: MosaicCustomerEntitlementSnapshotState): String = when (state) { + MosaicCustomerEntitlementSnapshotState.Loading -> "Authoritative: waiting for Mosaic." + MosaicCustomerEntitlementSnapshotState.SignedOut -> + "Authoritative: no customer (pass mosaic.customer.token to enable)." + is MosaicCustomerEntitlementSnapshotState.Unavailable -> + "Authoritative: unavailable (${state.reason.wireName}) · " + + "last known snapshot ${state.lastKnown?.snapshotVersion ?: "none"}" + is MosaicCustomerEntitlementSnapshotState.Available -> { + val pro = state.snapshot.entry("pro")?.state + val access = when (pro) { + is MosaicCustomerEntitlementState.Active -> if (pro.isStale) "active (stale)" else "active" + is MosaicCustomerEntitlementState.Inactive -> "inactive" + is MosaicCustomerEntitlementState.Unknown -> "unknown" + is MosaicCustomerEntitlementState.Unavailable -> "unavailable" + null -> "no entry" + } + "Authoritative pro: $access · snapshot ${state.snapshot.snapshotVersion} · " + + "cache ${state.cacheState.wireName}" } } diff --git a/examples/flutter-example/README.md b/examples/flutter-example/README.md index 9ba680bb..e7012828 100644 --- a/examples/flutter-example/README.md +++ b/examples/flutter-example/README.md @@ -151,6 +151,59 @@ persistent storage, applies accepted/permanently-rejected/retryable results, advances through the canonical retry delay, and proves the retained event is later accepted. +## Phase 9B authoritative entitlements + +The **Customer** tab shows Mosaic's authoritative answer to "what may this +customer access, and why", which is separate from the provider-observed +entitlements the paywall tabs use. + +Mosaic Billing requires an application backend: your server mints the Customer +Access Token. The example stubs that with a `--dart-define` so the tab can be +pointed at a real Environment without shipping a secret in source. + +```bash +flutter run \ + --dart-define=MOSAIC_HOSTED_BASE_URL=http://127.0.0.1:8080 \ + --dart-define=MOSAIC_PUBLIC_SDK_KEY=public_example_key \ + --dart-define=MOSAIC_CUSTOMER_USER_ID=user_example_0001 \ + --dart-define=MOSAIC_CUSTOMER_ACCESS_TOKEN=mcat_... +``` + +Issue the token against a Billing Customer with the trusted-server API: + +```bash +curl -X POST http://127.0.0.1:8080/v1/billing/server/customer-tokens \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"customerAccessTokenContractVersion":"1", + "recordType":"customerAccessTokenIssuanceRequest", + "payload":{"billingCustomerId":"", + "audience":"sdk_sync","scopes":["entitlements:read"], + "correlationId":"example-0001"}}' +``` + +With no token defined the tab is the signed-out demonstration: every read +reports **Unavailable**, never Inactive. That distinction is the point of the +screen. + +What to exercise: + +- **Identify** then **Refresh** to accept a snapshot; the snapshot version, + `asOf`, cache state, validity window, and projection health are all shown. +- **Refresh** again to see the conditional request answered as unchanged, with + the freshness window sliding rather than the version advancing. +- **Restore and sync** to see the multi-stage result: the provider outcome and + Mosaic's outcome are reported separately, and `restored` appears only once an + accepted snapshot reflects it. +- Stop the API and refresh: the cache keeps serving, the state stays Active, and + the activity log records `unavailable` rather than any claim about access. +- **Sign out** to see the token handle and the cached snapshot disappear + together. + +The diagnostics panel shows the token *handle* and expiry. The token value is +structurally unavailable to the UI: it is memory-only and never enters a +diagnostic. + ## Run optional RevenueCat commerce The example includes the optional `mosaic_revenuecat` package but does not diff --git a/examples/flutter-example/lib/main.dart b/examples/flutter-example/lib/main.dart index 667f52f3..b3371008 100644 --- a/examples/flutter-example/lib/main.dart +++ b/examples/flutter-example/lib/main.dart @@ -44,6 +44,18 @@ const String _revenueCatPublicSdkKey = String.fromEnvironment( 'REVENUECAT_PUBLIC_SDK_KEY', ); +/// Stub Customer Access Token. In a real application this value never appears +/// in the client: the host's own backend mints it. Mosaic Billing requires an +/// application backend, so an empty value here means "signed out", which the +/// SDK reports as `unavailable` rather than `inactive`. +const String _customerAccessToken = String.fromEnvironment( + 'MOSAIC_CUSTOMER_ACCESS_TOKEN', +); +const String _customerUserId = String.fromEnvironment( + 'MOSAIC_CUSTOMER_USER_ID', + defaultValue: 'user_example_0001', +); + var _revenueCatReady = false; MosaicStorePlatform? get _runtimeStorePlatform => @@ -112,6 +124,7 @@ final class _MosaicExampleShellState extends State { children: const [ PaywallPlayground(), HostedPaywallPlayground(), + CustomerEntitlementsPlayground(), ], ), bottomNavigationBar: NavigationBar( @@ -128,6 +141,11 @@ final class _MosaicExampleShellState extends State { selectedIcon: Icon(Icons.cloud), label: 'Hosted', ), + NavigationDestination( + icon: Icon(Icons.verified_user_outlined), + selectedIcon: Icon(Icons.verified_user), + label: 'Customer', + ), ], ), ); @@ -704,3 +722,335 @@ MockMosaicPurchaseProvider _fallbackPurchaseProvider() { ], ); } + +/// Phase 9B: Mosaic's authoritative answer to "what may this customer access, +/// and why". It is deliberately separate from the provider-observed +/// entitlements the paywall tabs use. +final class CustomerEntitlementsPlayground extends StatefulWidget { + const CustomerEntitlementsPlayground({super.key}); + + @override + State createState() => + _CustomerEntitlementsPlaygroundState(); +} + +final class _CustomerEntitlementsPlaygroundState + extends State { + static const String _entitlementKey = 'pro'; + + late final Mosaic _mosaic = Mosaic.configure( + publicSdkKey: _publicSdkKey, + baseUrl: Uri.parse(_hostedBaseUrl), + purchaseProvider: _fallbackPurchaseProvider(), + // The host's backend mints this. The stub reads a --dart-define so the + // example can be run against a real Environment without shipping a secret. + customerTokenProvider: (request) async { + if (_customerAccessToken.isEmpty || request.userId == null) return null; + return MosaicCustomerToken( + value: _customerAccessToken, + tokenId: 'example-token', + expiresAt: DateTime.now().toUtc().add(const Duration(minutes: 55)), + ); + }, + ); + + final List _log = []; + MosaicCustomerRestoreResult? _restore; + var _busy = false; + + @override + void initState() { + super.initState(); + _mosaic.addListener(_onChanged); + unawaited(_mosaic.loadIdentity()); + } + + @override + void dispose() { + _mosaic + ..removeListener(_onChanged) + ..dispose(); + super.dispose(); + } + + void _onChanged() { + if (mounted) setState(() {}); + } + + void _record(String message) { + if (!mounted) return; + setState(() { + _log.insert(0, message); + if (_log.length > 12) _log.removeLast(); + }); + } + + Future _run(Future Function() action) async { + setState(() => _busy = true); + try { + await action(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _identify() => _run(() async { + await _mosaic.identify(_customerUserId); + _record('Identified $_customerUserId.'); + await _refresh(); + }); + + Future _signOut() => _run(() async { + await _mosaic.resetUserIdentity(); + _record('Signed out. Token and cache cleared.'); + }); + + Future _refresh() async { + final result = await _mosaic.refreshCustomerEntitlements(); + _record(switch (result) { + MosaicCustomerEntitlementUpdated(:final snapshot) => + 'Accepted snapshot v${snapshot.snapshotVersion}.', + MosaicCustomerEntitlementUnchanged(:final snapshotVersion) => + 'Unchanged at v$snapshotVersion; freshness slid.', + MosaicCustomerEntitlementRejected( + :final reasonCode, + :final cacheAction + ) => + 'Rejected: $reasonCode (cache ${cacheAction.name}).', + MosaicCustomerEntitlementUnavailable(:final reasonCode) => + 'Unavailable: $reasonCode.', + }); + } + + Future _restorePurchases() => _run(() async { + final result = await _mosaic.restorePurchasesAndSync(); + if (!mounted) return; + setState(() => _restore = result); + _record( + 'Restore: ${result.outcome.wireValue} ' + '(provider ${result.providerOutcome.wireValue}).', + ); + }); + + @override + Widget build(BuildContext context) { + final check = _mosaic.checkCustomerEntitlement(_entitlementKey); + final diagnostics = _mosaic.customerEntitlementDiagnostics; + return Scaffold( + appBar: AppBar(title: const Text('Authoritative entitlements')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _AccessCard(check: check), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _identify, + icon: const Icon(Icons.login), + label: const Text('Identify'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _signOut, + icon: const Icon(Icons.logout), + label: const Text('Sign out'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : () => _run(_refresh), + icon: const Icon(Icons.sync), + label: const Text('Refresh'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _restorePurchases, + icon: const Icon(Icons.restore), + label: const Text('Restore and sync'), + ), + ], + ), + const SizedBox(height: 20), + _Section( + title: 'Snapshot', + rows: { + 'Snapshot version': + diagnostics.snapshotVersion?.toString() ?? '—', + 'As of': diagnostics.asOf?.toIso8601String() ?? '—', + 'Cache state': diagnostics.cacheState.name, + 'Valid until': diagnostics.validUntil?.toIso8601String() ?? '—', + 'Stale grace': '${diagnostics.staleGraceSeconds}s', + 'Billing customer': diagnostics.billingCustomerId ?? '—', + 'Projection': diagnostics.projectionState?.name ?? '—', + }, + ), + const SizedBox(height: 16), + _Section( + title: 'Diagnostics', + rows: { + 'Enabled': diagnostics.enabled ? 'yes' : 'no', + 'Identity generation': '${diagnostics.identityGeneration}', + // The token handle only. The token value is structurally + // unavailable to this screen. + 'Token handle': diagnostics.token.tokenId ?? '—', + 'Token expires': + diagnostics.token.expiresAt?.toIso8601String() ?? '—', + 'Last reason': diagnostics.lastReasonCode ?? '—', + }, + ), + if (_restore case final restore?) ...[ + const SizedBox(height: 16), + _RestoreStages(result: restore), + ], + const SizedBox(height: 16), + _Section( + title: 'Activity', + rows: { + for (var index = 0; index < _log.length; index += 1) + '${index + 1}': _log[index], + }, + ), + ], + ), + ), + ); + } +} + +final class _AccessCard extends StatelessWidget { + const _AccessCard({required this.check}); + + final MosaicCustomerEntitlementCheck check; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // Four labels, and neither unknown nor unavailable is written as a + // negative: "Mosaic could not find out" is not "you do not have it". + final (label, detail, color) = switch (check.state) { + MosaicCustomerAccessState.active => ( + 'Active', + check.isStale + ? 'Served from the bounded-grace window; awaiting confirmation.' + : 'Mosaic has validated a granting source.', + Colors.green.shade800, + ), + MosaicCustomerAccessState.inactive => ( + 'Inactive', + 'Mosaic looked and found no qualifying source.', + theme.colorScheme.onSurfaceVariant, + ), + MosaicCustomerAccessState.unknown => ( + 'Unknown', + 'Mosaic could not find out. This is not a revocation.', + Colors.orange.shade900, + ), + MosaicCustomerAccessState.unavailable => ( + 'Unavailable', + 'Mosaic could not answer. Sign in to read authoritative state.', + Colors.orange.shade900, + ), + }; + return Semantics( + container: true, + liveRegion: true, + label: 'Entitlement ${check.entitlementKey} is $label. $detail', + child: Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(check.entitlementKey, style: theme.textTheme.labelLarge), + const SizedBox(height: 4), + Text( + label, + style: theme.textTheme.headlineSmall?.copyWith(color: color), + ), + const SizedBox(height: 6), + Text(detail, style: theme.textTheme.bodyMedium), + if (check.isStale) ...[ + const SizedBox(height: 6), + Text( + 'Stale: showing last confirmed access.', + style: theme.textTheme.labelMedium?.copyWith( + color: Colors.orange.shade900, + ), + ), + ], + if (check.isTestSource) ...[ + const SizedBox(height: 6), + const Text('Granted by a provider test transaction.'), + ], + if (check.reasonCode case final reason?) ...[ + const SizedBox(height: 6), + Text('Reason: $reason', style: theme.textTheme.labelMedium), + ], + ], + ), + ), + ), + ); + } +} + +final class _RestoreStages extends StatelessWidget { + const _RestoreStages({required this.result}); + + final MosaicCustomerRestoreResult result; + + @override + Widget build(BuildContext context) { + return _Section( + title: 'Restore stages (${result.outcome.wireValue})', + rows: { + for (final stage in result.stages) stage.name.name: stage.detail, + }, + ); + } +} + +final class _Section extends StatelessWidget { + const _Section({required this.title, required this.rows}); + + final String title; + final Map rows; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + if (rows.isEmpty) + Text('—', style: theme.textTheme.bodyMedium) + else + for (final entry in rows.entries) + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 150, + child: Text( + entry.key, + style: theme.textTheme.labelMedium, + ), + ), + Expanded( + child: Text( + entry.value, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/examples/ios-example/MosaicExample/ContentView.swift b/examples/ios-example/MosaicExample/ContentView.swift index 2f8824f7..be9cd650 100644 --- a/examples/ios-example/MosaicExample/ContentView.swift +++ b/examples/ios-example/MosaicExample/ContentView.swift @@ -162,6 +162,8 @@ private struct HostedConfigurationPreview: View { .padding(.horizontal) .padding(.bottom, 10) + Divider() + CustomerEntitlementsPanel(model: model) Divider() if let mosaic = model.mosaic { MosaicPlacementPaywall( @@ -192,11 +194,21 @@ private struct HostedConfigurationPreview: View { } @MainActor -private final class HostedConfigurationModel: ObservableObject { +final class HostedConfigurationModel: ObservableObject { @Published private(set) var statusText = "Add hosted SDK settings to the scheme." @Published private(set) var isLoading = false @Published private(set) var releaseIdentity = "none" + @Published private(set) var entitlementSummary = "No customer token provider configured." + @Published private(set) var restoreStages: [String] = [] + @Published var customerSelection: ExampleCustomerTokenProvider.State = .signedOut { + didSet { + guard customerSelection != oldValue else { return } + Task { await applyCustomerSelection() } + } + } private(set) var mosaic: Mosaic? + let customerTokenProvider = ExampleCustomerTokenProvider() + private var entitlementObservation: Task? let placement: String let setupMessage: String @@ -268,7 +280,8 @@ private final class HostedConfigurationModel: ObservableObject { baseURL: baseURL, applicationVersion: applicationVersion, transactionObservations: transactionObservationsEnabled ? .enabled : .disabled, - purchaseProvider: purchaseProvider + purchaseProvider: purchaseProvider, + customerTokenProvider: customerTokenProvider ) mosaic = configured await configured.setAnalyticsCollection( @@ -280,6 +293,7 @@ private final class HostedConfigurationModel: ObservableObject { configured.transactionObservationSink()) await refreshCommerce(for: configured) await updateStatus(for: configured) + observeEntitlements(configured) } catch { statusText = "Hosted SDK settings are invalid. Check the key and base URL." } @@ -396,6 +410,113 @@ private final class HostedConfigurationModel: ObservableObject { + "\(diagnostics.persistedAssignmentCount) persisted · \(time)" } + // MARK: Authoritative entitlements + + /// Watches the update stream rather than polling. Each identity transition + /// emits its own state, so the UI never has to infer "the previous user's + /// grants no longer apply" from silence. + private func observeEntitlements(_ mosaic: Mosaic) { + entitlementObservation?.cancel() + entitlementObservation = Task { [weak self] in + for await update in await mosaic.customerEntitlementUpdates() { + guard let self, !Task.isCancelled else { return } + await self.apply(update) + } + } + } + + private func apply(_ update: MosaicCustomerEntitlementUpdate) { + switch update { + case .loading: + entitlementSummary = "Loading…" + case .signedOut: + entitlementSummary = "Signed out · no customer, so no authoritative answer." + case .cleared(let reason): + entitlementSummary = "Cleared · \(reason.rawValue)" + case .unavailable(let reason): + // Never "no access": unavailable is about Mosaic, not about the customer. + entitlementSummary = "Unavailable · \(reason.rawValue) · this is not 'inactive'" + case .snapshot(let value): + let pro = value.snapshot.entry(forKey: "pro") + let state = pro.map { "\($0.state.rawValue)" } ?? "no pro entry" + entitlementSummary = + "v\(value.snapshot.snapshotVersion) · \(describe(value.cacheState)) · pro: \(state)" + + " · \(value.snapshot.entries.count) entries" + } + } + + private func describe(_ state: MosaicCustomerEntitlementCacheState) -> String { + switch state { + case .fresh: "fresh" + case .refreshRecommended: "refresh recommended" + case .staleWithinGrace(let until): "STALE within grace until \(until)" + case .expired: "expired" + case .missing: "no cache" + case .invalid: "cache invalid" + case .differentCustomer: "different customer" + } + } + + private func applyCustomerSelection() async { + guard let mosaic else { return } + await customerTokenProvider.set(customerSelection) + restoreStages = [] + // Identity changes go through the SDK so the token generation is bumped, + // in-flight work is cancelled, and the cache is cleared before any read. + do { + switch customerSelection { + case .signedOut: + try await mosaic.resetIdentity() + case .customerA: + try await mosaic.identify(userID: "ios_example_customer_a") + case .customerB: + try await mosaic.identify(userID: "ios_example_customer_b") + case .backendFailing: + _ = await mosaic.refreshCustomerEntitlements() + } + } catch { + entitlementSummary = "Identity update rejected safely" + } + } + + func refreshEntitlements() async { + guard let mosaic else { return } + let result = await mosaic.refreshCustomerEntitlements() + let diagnostics = await mosaic.customerEntitlementDiagnostics() + statusText = + "Entitlements \(String(describing: result)) · " + + "\(diagnostics.acceptedSnapshotCount) accepted · " + + "\(diagnostics.rejectedSnapshotCount) rejected · " + + (diagnostics.lastRejectionReason ?? "no rejection") + } + + func restoreAndSync() async { + guard let mosaic else { return } + restoreStages = ["running…"] + let result = await mosaic.restoreAndSyncCustomerEntitlements() + restoreStages = result.stages.map(Self.describe) + statusText = + "Restore · \(String(describing: result.outcome)) · " + + "authoritatively updated: \(result.authoritativeEntitlementsUpdated)" + } + + func clearCustomerState() async { + guard let mosaic else { return } + await mosaic.clearCustomerState() + restoreStages = [] + } + + private static func describe(_ stage: MosaicRestoreAndSyncStage) -> String { + switch stage { + case .providerRestoreStarted: "provider restore started" + case .providerRestoreFinished(let result): "provider restore · \(String(describing: result))" + case .authoritativeSyncStarted: "authoritative sync started" + case .authoritativeSnapshotAccepted(let version): "snapshot accepted · v\(version)" + case .authoritativeValidationPending(let attempts): "validation pending · \(attempts) attempts" + case .authoritativeSyncUnavailable(let reason): "sync unavailable · \(reason.rawValue)" + } + } + private func updateStatus(for mosaic: Mosaic) async { switch await mosaic.configurationStatus() { case .available(let metadata, let source, let diagnostics): @@ -458,6 +579,107 @@ extension MosaicPlacementDecisionResult { } } +/// Stands in for the host application's own backend. +/// +/// In a real app this calls an authenticated endpoint on your server, which +/// asks Mosaic for a Customer Access Token with its `secret_server` key and +/// returns it. Mosaic Billing requires an application backend: a public SDK key +/// identifies an application and can never select a Billing Customer, so there +/// is no anonymous mode to fall back to. +/// +/// The example mints a fake token so the sign-in, sign-out, and switch-customer +/// flows can be exercised without a server. It also models the two failure modes +/// worth seeing in a demo: a signed-out user and a backend that cannot answer. +actor ExampleCustomerTokenProvider: MosaicCustomerTokenProvider { + enum State: String, CaseIterable, Identifiable { + case signedOut + case customerA + case customerB + case backendFailing + + var id: String { rawValue } + + var label: String { + switch self { + case .signedOut: "Signed out" + case .customerA: "Customer A" + case .customerB: "Customer B" + case .backendFailing: "Backend failing" + } + } + } + + private var state: State = .signedOut + private(set) var forcedRefreshCount = 0 + + func set(_ state: State) { self.state = state } + func current() -> State { state } + + func customerAccessToken(forceRefresh: Bool) async -> MosaicCustomerTokenResult { + if forceRefresh { forcedRefreshCount += 1 } + switch state { + case .signedOut: return .signedOut + case .backendFailing: return .unavailable + case .customerA: return .token(MosaicCustomerAccessToken("mcat_example_customer_a")) + case .customerB: return .token(MosaicCustomerAccessToken("mcat_example_customer_b")) + } + } +} + +/// Shows what the SDK can honestly say about authoritative access, including the +/// states that are easy to forget: stale-but-serving, expired, and unavailable. +@MainActor +struct CustomerEntitlementsPanel: View { + @ObservedObject var model: HostedConfigurationModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Authoritative entitlements") + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + Picker("Customer", selection: $model.customerSelection) { + ForEach(ExampleCustomerTokenProvider.State.allCases) { state in + Text(state.label).tag(state) + } + } + .pickerStyle(.menu) + .accessibilityLabel("Simulated customer session") + } + + Text(model.entitlementSummary) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel("Entitlement state: \(model.entitlementSummary)") + + if !model.restoreStages.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(model.restoreStages.enumerated()), id: \.offset) { _, stage in + Text("• \(stage)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Restore stages: \(model.restoreStages.joined(separator: ", "))") + } + + HStack(spacing: 8) { + Button("Refresh") { Task { await model.refreshEntitlements() } } + .buttonStyle(.bordered) + Button("Restore & sync") { Task { await model.restoreAndSync() } } + .buttonStyle(.bordered) + Button("Clear") { Task { await model.clearCustomerState() } } + .buttonStyle(.bordered) + } + .disabled(model.mosaic == nil) + } + .padding(.horizontal) + .padding(.bottom, 8) + } +} + private actor ExampleStoreKitUpdateAcceptor: MosaicCommerceUpdateAcceptor { private var accepted = Set() diff --git a/examples/ios-example/README.md b/examples/ios-example/README.md index 6d1b01b3..f8deb806 100644 --- a/examples/ios-example/README.md +++ b/examples/ios-example/README.md @@ -45,6 +45,34 @@ The footer shows the latest normalized paywall interaction or terminal result. The example uses `MosaicImageResolver.missing` intentionally so the fixture's declared same-geometry image placeholder demonstrates asset fallback. +## Authoritative customer entitlements (Phase 9B) + +The hosted tab has an **Authoritative entitlements** panel that exercises the +Phase 9B surface without needing a real backend. + +The customer picker drives a mock token provider (`ExampleCustomerTokenProvider`) +with four states, chosen to cover the cases that are easy to get wrong: + +| Selection | What the SDK sees | What to look for | +| --- | --- | --- | +| Signed out | `.signedOut` from the provider | `signedOut`, not "no access" | +| Customer A / B | a fake token per customer | switching clears the previous customer's cache before any read | +| Backend failing | `.unavailable` from the provider | `unavailable · tokenProviderFailed` — **never** `inactive` | + +The readout shows the accepted snapshot version, the cache state (including +`STALE within grace`, which a real UI must surface), and the `pro` entry state. +**Restore & sync** lists each stage so the two axes of a restore are visible: +what StoreKit did, and whether an accepted snapshot actually reflects it. + +In a real app the token provider calls your own authenticated backend, which +mints a Customer Access Token with your Mosaic `secret_server` key. Mosaic +Billing has no anonymous mode. + +Simulator note: StoreKit Testing in Xcode produces transactions with no App +Store record, so the SDK deliberately never observes them. Restore stages will +show the provider result and then `validation pending` against a local API, +which is the correct behaviour rather than a failure. + ## Local endpoint configuration The simulator defaults to: diff --git a/mosaic-phase-9b-subscription-state-authoritative-entitlements.md b/mosaic-phase-9b-subscription-state-authoritative-entitlements.md new file mode 100644 index 00000000..a8fd1e7f --- /dev/null +++ b/mosaic-phase-9b-subscription-state-authoritative-entitlements.md @@ -0,0 +1,5573 @@ +# Orchestration Prompt — Mosaic Phase 9B: Subscription State and Authoritative Entitlements + +Read all of the following before making changes: + +- `AGENTS.md` +- `README.md` +- `SECURITY.md` +- `docs/product/vision.md` +- `docs/product/principles.md` +- `docs/product/roadmap.md` +- `docs/product/mosaic-agentic-plan.md` +- `docs/architecture/overview.md` +- `docs/architecture/conventions/backend.md` +- `docs/architecture/conventions/frontend.md` +- `docs/architecture/conventions/protocol.md` +- `docs/architecture/conventions/sdk.md` +- `docs/architecture/conventions/testing.md` +- all accepted ADRs +- `docs/plans/phase-4a-revenuecat-custom-providers.md` +- `docs/plans/phase-4b-native-store-providers.md` +- `docs/plans/phase-6-analytics-identity-privacy.md` +- `docs/plans/phase-8-operational-hardening.md` +- `docs/plans/phase-9a-transaction-ingestion-validation.md` +- `docs/reviews/phase-8.md` +- `docs/reviews/phase-9-entry.md` +- `docs/reviews/phase-9a.md` +- post-GA billing demand evidence +- accepted Phase 9A incident, reconciliation, and provider-validation findings +- open issues marked as Phase 9B blockers + +We are implementing: + +# Mosaic Phase 9B: Subscription State and Authoritative Entitlements + +Do not begin Phase 9C. + +--- + +# Preflight Gate + +Before delegating or modifying code, verify all of the following: + +- Phase 8 is accepted as ready for General Availability. +- Mosaic v1.0.0 or an accepted GA baseline commit exists. +- `docs/reviews/phase-9-entry.md` explicitly approves Mosaic Billing. +- Phase 9A is accepted or accepted with tracked nonblocking follow-ups. +- `docs/reviews/phase-9a.md` exists. +- Apple transactions are validated server-side. +- Google purchases are validated server-side. +- Apple and Google notifications are authenticated and ingested. +- Raw Billing Inputs are append-only. +- Validation Attempts are append-only. +- the Billing Event Ledger is append-only. +- Normalized Transaction Facts are provider-independent and immutable. +- Product resolution uses stable Mosaic Product IDs and mapping history. +- unknown and ambiguous Products enter quarantine. +- duplicate provider notifications are idempotent. +- replay and revalidation preserve prior history. +- reconciliation discovers missed provider facts. +- sandbox and production are isolated. +- PostgreSQL remains the runtime system of record. +- Goose migrations apply successfully. +- no production in-memory persistence exists. +- provider credentials are encrypted and redacted. +- Products and Entitlements remain separate domain concepts. +- existing RevenueCat, StoreKit 2, Google Play Billing, and custom-provider integrations remain optional. +- customer Entitlement state is still provider-owned before this phase. +- no partial authoritative subscription-state engine has been introduced outside an accepted plan. +- no unresolved critical security, tenant-isolation, migration, backup, or data-integrity defect exists. +- the current branch has no unrelated uncommitted changes. + +If any prerequisite fails, stop and return a blocker report. + +Do not silently repair Phase 9A defects while pretending to implement Phase 9B. + +If Phase 9A has unresolved provider-validation ambiguity, Product-resolution ambiguity, or append-only ledger defects, classify those as Phase 9A blockers and stop. + +--- + +# Required Git branch + +Create or use a dedicated branch. + +Recommended branch: + +```text +phase/9b-subscription-state-entitlements +``` + + +Base the branch on the accepted Phase 9A baseline. + +Do not merge automatically. + +Do not tag automatically. + +Do not begin Phase 9C automatically. + +--- + +# Phase Objective + +Build Mosaic’s authoritative customer subscription and Entitlement layer from the validated facts produced by Phase 9A. + +Phase 9B must allow Mosaic to: + +1. associate validated provider facts with the correct Mosaic customer +2. group provider facts into stable purchase or subscription lineages +3. project deterministic subscription state +4. preserve a complete subscription timeline +5. evaluate versioned Product-to-Entitlement grants +6. compute authoritative customer Entitlement state +7. explain exactly why access is active, inactive, unknown, or unavailable +8. synchronize state across devices and platforms +9. restore and refresh customer access safely +10. cache Entitlement state in SDKs for bounded offline use +11. provide server-side Entitlement APIs for protected application backends +12. notify application backends through signed, retryable webhooks +13. replay the complete projection deterministically from validated facts +14. repair projection defects without rewriting Phase 9A history +15. preserve Product and Entitlement meaning across replacements and version changes + +The core promise is: + +> Given the same validated billing facts, Product mapping history, identity evidence, and Entitlement grant versions, Mosaic always computes the same explainable customer-access result. + +--- + +# Strict Phase Boundary + +Phase 9B owns: + +```text +Validated provider facts +→ customer association +→ subscription lineage +→ subscription state projection +→ versioned Product grant evaluation +→ authoritative customer Entitlements +→ SDK and server synchronization +→ signed access-change webhooks +``` + +Phase 9B does not own: + +- RevenueCat customer migration +- RevenueCat historical transaction import +- bulk customer import +- dual-run migration +- provider cutover +- migration dry runs +- legacy-state comparison +- large-scale reconciliation of imported historical customers +- bulk repair tooling for migration +- migration rollback +- automatic provider decommissioning +- operational migration runbooks +- billing support console for mass migration +- financial accounting +- settlement reconciliation +- tax +- invoicing +- MRR, ARR, or LTV +- consumable Products +- credit Products +- metered Products +- quantity-based Products +- manual paid-access grants +- promotional support-issued Entitlements +- arbitrary operator override of validated billing state +- Stripe Billing +- Paddle +- Lemon Squeezy +- AI billing decisions +- autonomous access changes outside the state engine + +Those belong to Phase 9C or later product decisions. + +Do not begin Phase 9C. + +--- + +# Non-Negotiable Architecture + +Continue using: + +- Go +- modular monolith +- REST APIs +- PostgreSQL +- `github.com/jackc/pgx/v5/pgxpool` +- `github.com/pressly/goose/v3` +- Chi +- Chi middleware +- Chi CORS +- Chi Render behind Mosaic response helpers +- Ozzo Validation +- `github.com/riandyrn/otelchi` +- OpenTelemetry +- Zerolog +- the accepted background-worker system +- the accepted secret-encryption system +- the accepted SDK networking and persistence systems + +Do not introduce: + +- production in-memory persistence +- another primary database +- Kafka +- a new queue platform +- microservices +- event-streaming infrastructure without an ADR and measured need +- automatic schema creation +- automatic production migration from API startup +- an ORM without an accepted ADR +- mutable billing history +- last-write-wins customer state +- heuristic customer identity merging +- client-authoritative Entitlements +- unsigned access webhooks +- server requests on every client-side feature check + +The authoritative projection may be materialized in PostgreSQL, but its source remains the immutable validated billing history and accepted versioned business rules. + +--- + +# Current Official Provider Semantics Requirement + +Before implementation, inspect current official Apple and Google documentation relevant to state projection. + +At minimum review: + +## Apple + +- transaction and original-transaction lineage +- signed transaction fields +- signed renewal information +- expiration +- grace period +- billing retry +- revocation +- refund +- upgrade and downgrade indicators +- subscription group behaviour +- ownership type and Family Sharing where supported +- environment +- offer type where relevant +- transaction reason and renewal reason where relevant +- current Entitlements and transaction-history semantics +- StoreKit restore and synchronization behaviour + +## Google + +- subscription purchase state +- line items +- base plans and offers +- linked purchase tokens +- replacement and upgrade or downgrade behaviour +- expiration +- auto-renew state +- account hold +- grace period +- pause +- cancellation +- revocation +- refund +- acknowledgement facts +- test purchase semantics +- Product type distinctions +- active purchase and history queries + +Use official provider documentation. + +Do not guess: + +- whether a provider state grants access +- whether cancellation immediately removes access +- whether a refund is full, partial, or revoking +- whether a linked token supersedes a prior purchase +- whether paused state grants access +- whether billing retry grants access +- whether Family Sharing applies +- whether a provider timestamp is an event time, effective time, or receipt time + +Record official sources, versions, and normalization decisions in: + +```text +docs/plans/phase-9b-subscription-state-authoritative-entitlements.md +``` + +--- + +# Architectural Principles + +## 1. Facts Are Immutable + +Validated provider facts are append-only. + +Do not edit a validated fact because a later fact changes its meaning. + +A later fact creates a new fact and triggers reprojection. + +## 2. Projections Are Rebuildable + +Subscription and Entitlement projections are derived state. + +They must be reproducible from: + +- validated provider facts +- Product resolution history +- customer-association evidence +- versioned Product-to-Entitlement grants +- versioned projection rules +- explicit operator-approved repair metadata where allowed + +## 3. State Is Explainable + +Every projected subscription and Entitlement state must answer: + +- which validated facts contributed +- which Product was resolved +- which customer was associated +- which grant version applied +- which projection version computed the result +- why access is active, inactive, unknown, or unavailable +- what timestamp the result is valid as of + +## 4. Unknown Is Not Inactive + +Missing, conflicting, or unavailable evidence must not silently become inactive. + +Supported uncertainty states must remain explicit. + +## 5. Provider Facts Remain Provider-Specific at the Boundary + +Normalize only the concepts needed by Mosaic. + +Do not erase provider details required for correct replay, diagnostics, or future rule changes. + +## 6. Customer Access Changes Only Through the Engine + +No handler, webhook endpoint, SDK endpoint, dashboard action, or provider adapter may directly activate or revoke an Entitlement. + +All access changes flow through the authoritative projection engine. + +## 7. One Source Cannot Revoke Another Unrelated Source + +If several valid purchase sources grant the same Entitlement, the Entitlement remains active while at least one valid source grants it. + +A refund or revocation affects its own source unless provider semantics prove a broader relationship. + +## 8. Historical Meaning Is Preserved + +Product replacements, mapping changes, and Entitlement-grant changes must not rewrite the meaning of prior validated purchases. + +## 9. Server APIs Are Authoritative + +Client SDK state is a bounded cache for UI and offline behaviour. + +Protected backend resources should use Mosaic’s server-side Entitlement API or verified webhook state. + +## 10. No Heuristic Identity Merge + +Customers are associated only through explicit trusted evidence. + +Do not merge based on email similarity, device metadata, IP address, display name, or provider metadata heuristics. + +--- + +# Domain Invariants + +The implementation must preserve these invariants. + +## Billing History + +- Raw Billing Inputs remain immutable. +- Validation Attempts remain immutable. +- Normalized Transaction Facts remain immutable. +- Billing Event Ledger entries remain append-only. +- validated facts cannot be deleted through ordinary user workflows. +- replay does not mutate original facts. +- revalidation creates new attempts. +- fact supersession is represented explicitly rather than through destructive updates. + +## Tenant and Environment Isolation + +- a Customer belongs to one Project. +- a Subscription Instance belongs to one Project and one Environment. +- a provider fact cannot project state into another Project. +- sandbox facts cannot affect production state. +- production facts cannot affect sandbox state. +- Entitlement definitions cannot cross Projects. +- Product grant rules cannot cross Projects. +- webhooks cannot deliver one tenant’s state to another tenant. + +## Identity + +- one customer alias cannot resolve to two active Customers in the same Project at the same time. +- an installation alias and application-user alias are typed and distinguishable. +- identity association history is immutable. +- alias reassignment requires an explicit accepted workflow and audit history. +- historical events are not rewritten when identity changes. +- unresolved customer association does not grant access. + +## Product and Grants + +- a validated provider Product resolves through mapping history. +- a historical fact never resolves only through the current mapping when a historical mapping is required. +- Product-to-Entitlement grants are versioned. +- a grant rule has an effective interval or version. +- changing a grant does not silently rewrite historical access meaning. +- an archived Product remains resolvable for historical facts. +- Product replacement preserves lineage. + +## Subscription Projection + +- the same ordered facts and rule versions produce the same projection. +- projection versions are monotonic per Subscription Instance. +- a projection never claims to be current beyond its computed `as_of`. +- out-of-order facts trigger deterministic reprojection. +- duplicate facts do not duplicate access. +- cancellation does not revoke access before the validated effective end unless provider facts require it. +- refund, revocation, and expiration have explicit effective times. +- an invalid or unknown state does not silently become active or inactive. + +## Entitlement Projection + +- Entitlement state derives from one or more grant sources. +- every active Entitlement source points to a Product, subscription or purchase lineage, grant version, and validated facts. +- revoking one source does not remove unrelated valid sources. +- an Entitlement with at least one active source remains active. +- an Entitlement with no active sources but unresolved critical evidence may be unknown rather than inactive. +- Entitlement snapshots are versioned. +- Entitlement changes are auditable. + +## Webhooks + +- webhook events are immutable. +- webhook delivery is at least once. +- webhook consumers receive stable event IDs. +- retries do not create new logical webhook events. +- webhook signatures are verifiable. +- delivery attempts are append-only. +- webhooks never contain provider secrets. +- an access-change webhook is emitted only after a committed authoritative projection. + +## SDK State + +- SDK cache does not overwrite newer snapshots with older snapshots. +- offline cache is bounded by an explicit validity policy. +- SDK cache cannot create server-side access. +- SDK cache failure does not corrupt provider purchase state. +- logout or identity change cannot leak one customer’s cached Entitlements to another customer. + +--- + +# Consistency Model + +Phase 9B must document consistency guarantees explicitly. + +## Strongly Consistent Boundaries + +The following operations should be strongly consistent within one PostgreSQL transaction where practical: + +- committing a new Subscription Projection version +- committing corresponding Customer Entitlement Snapshot changes +- recording projection source links +- updating the current authoritative snapshot pointer +- creating authoritative access-change webhook events +- updating projection checkpoints +- resolving idempotency for one projection command + +A consumer must not observe: + +- a new subscription state without its matching Entitlement state +- a new Entitlement state without its source links +- a webhook event for state that was not committed +- a current-snapshot pointer to an incomplete snapshot + +## Eventually Consistent Boundaries + +The following may be eventually consistent: + +- provider notification arrival +- validation retries +- projection worker execution +- SDK refresh +- application webhook delivery +- dashboard aggregate refresh +- cross-device cache refresh + +Every eventually consistent surface must expose: + +- last updated time +- current projection version +- pending state where relevant +- safe refresh or retry behaviour +- bounded stale-state policy + +## Read-Your-Writes + +Trusted server APIs that submit a validated projection command should return the committed projection version when synchronous projection is used. + +When asynchronous projection is used, return: + +- accepted job ID +- current known projection version +- status endpoint +- retry guidance + +Do not claim immediate access change before the projection commits. + +## Ordering + +Ordering must be deterministic per Subscription Instance. + +Do not depend solely on worker arrival order. + +The Stage 1 plan must define the canonical ordering tuple, considering: + +- provider effective timestamp +- provider event timestamp +- transaction sequence or lineage +- provider transaction identifier +- normalized fact type precedence where required +- received timestamp only as a final deterministic tie-breaker +- fact ID as a final stable tie-breaker + +Provider-specific ordering rules must be documented. + +--- + +# Failure Model + +The implementation must account for the following failures. + +## Duplicate Facts + +Expected behaviour: + +- deduplicate logical provider facts +- preserve repeated validation attempts +- avoid duplicate projection changes +- avoid duplicate webhooks + +## Out-of-Order Facts + +Expected behaviour: + +- store the fact +- detect projection impact +- reproject from the correct checkpoint +- preserve prior projection history +- emit a new authoritative snapshot only when state changes or projection metadata requires it + +## Missing Facts + +Expected behaviour: + +- preserve current valid state until the accepted staleness or uncertainty policy requires otherwise +- expose pending reconciliation +- avoid fabricating expiration or renewal +- use reconciliation from Phase 9A +- move to unknown when evidence is insufficient according to approved policy + +## Provider Unavailable + +Expected behaviour: + +- preserve last authoritative server projection +- mark validation or synchronization freshness +- retry according to policy +- avoid converting unavailable to inactive +- keep SDK cache within its accepted offline window + +## Projection Worker Crash + +Expected behaviour: + +- transaction rollback +- idempotent retry +- no partial current snapshot +- no orphan webhook event +- observable failed job +- safe replay + +## Concurrent Facts + +Expected behaviour: + +- serialize projection per Subscription Instance or Customer scope according to the plan +- use row-level locks, advisory locks, or accepted compare-and-swap +- avoid last-write-wins +- re-read current projection version inside the transaction + +## Identity Conflict + +Expected behaviour: + +- quarantine or hold association +- do not grant access to either candidate Customer automatically +- expose operator-safe diagnostics +- preserve provider facts +- require explicit trusted resolution + +## Product Mapping Conflict + +Expected behaviour: + +- quarantine affected facts +- do not guess +- preserve historical mapping versions +- reproject only after accepted repair + +## Grant Rule Change + +Expected behaviour: + +- create a new grant version +- define whether it applies prospectively, retrospectively, or both +- require an explicit owner-approved policy +- never silently rewrite historical Entitlement meaning + +## Webhook Destination Failure + +Expected behaviour: + +- committed state remains authoritative +- retry with bounded backoff +- preserve delivery attempts +- expose dead-letter or exhausted state +- permit authorized replay +- do not roll back state because delivery failed + +## SDK Offline + +Expected behaviour: + +- use bounded cached snapshot +- expose snapshot age +- preserve last known customer identity +- deny or report unknown after cache validity expires according to policy +- never extend access indefinitely unless explicitly approved + +## Clock Skew + +Expected behaviour: + +- server uses trusted server time for snapshot issuance +- provider effective timestamps remain distinct +- SDK uses server-issued `valid_until` +- client clock is not the only source of truth +- large skew produces diagnostics + + +# Core Domain Model + +## Billing Customer + +A Billing Customer is the Project-scoped authoritative subject whose access Mosaic computes. + +A Billing Customer should contain: + +- stable Billing Customer ID +- Project ID +- lifecycle status +- created timestamp +- updated timestamp +- current authoritative Entitlement Snapshot ID +- current projection version +- last projected timestamp +- deletion or anonymization metadata where policy requires it +- diagnostics state +- audit metadata + +A Billing Customer is not: + +- an email address +- a provider customer object +- a device +- an installation +- a store account +- a RevenueCat subscriber record +- an arbitrary analytics subject + +The relationship between analytics identity and billing identity must be explicit. + +Do not assume that an analytics subject is automatically a Billing Customer. + +## Customer Alias + +A Customer Alias links an accepted external identity to one Billing Customer. + +Alias types may include: + +- application user ID +- installation ID +- Apple app-account token or accepted equivalent +- Google obfuscated account identifier or accepted equivalent +- trusted application-backend identity +- provider-specific lineage identity where it represents the same customer rather than only a transaction + +Each alias should contain: + +- alias ID +- Project ID +- alias type +- protected alias value or approved irreversible representation +- Billing Customer ID +- effective start +- effective end where applicable +- source authority +- verification status +- created metadata +- revoked metadata +- audit history + +Alias values must be protected according to their sensitivity. + +Do not expose raw sensitive provider identity values unnecessarily. + +## Customer Association Evidence + +A validated provider fact may be associated with a Billing Customer only through accepted evidence. + +Evidence may include: + +- verified application account token +- verified obfuscated external account ID +- trusted server observation containing an authorized Billing Customer reference +- previously accepted provider lineage associated with the same Billing Customer +- an explicit restore or link operation authenticated for the same user +- an operator-approved identity repair workflow defined later + +Phase 9B should support only evidence types approved in the Stage 1 plan. + +Do not associate a fact using: + +- email +- IP address +- display name +- Product choice +- device model +- locale +- approximate timing +- transaction amount +- analytics behaviour + +If association is unresolved or conflicting, the fact remains valid but access projection for a customer must not proceed automatically. + +## Purchase Lineage + +A Purchase Lineage groups validated facts that describe one provider purchase chain. + +Examples may include: + +- Apple original transaction chain +- Google subscription chain connected through linked purchase tokens +- one-time non-consumable ownership lineage +- provider-specific purchase chain defined by validated identifiers + +A Purchase Lineage should contain: + +- Purchase Lineage ID +- Project ID +- Environment ID +- Application ID +- provider +- provider lineage key or protected representation +- Product lineage +- Billing Customer ID when resolved +- lineage type +- sandbox or production +- created timestamp +- current projection checkpoint +- diagnostic status +- source mapping version +- audit metadata + +Do not merge two provider lineages because they share a Product or customer unless the accepted identity and provider semantics prove they belong together. + +## Subscription Instance + +A Subscription Instance is Mosaic’s authoritative projection unit for one recurring purchase lineage. + +It should contain: + +- Subscription Instance ID +- Purchase Lineage ID +- Billing Customer ID +- Project ID +- Environment ID +- Application ID +- provider +- current Mosaic Product ID +- current provider Product identity +- subscription group or base-plan context where relevant +- current authoritative Subscription Snapshot ID +- current projection version +- created timestamp +- updated timestamp +- terminal timestamp where applicable +- diagnostic status + +The Subscription Instance row may point to the current projection, but it must not be the only record of state history. + +## One-Time Purchase Instance + +A One-Time Purchase Instance represents validated ownership of a supported non-consumable Product. + +It should contain: + +- One-Time Purchase Instance ID +- Purchase Lineage ID +- Billing Customer ID +- Product ID +- provider +- acquisition timestamp +- current validity state +- revocation or refund effective timestamp where applicable +- current projection version +- source fact links +- audit metadata + +Consumables remain excluded. + +## Subscription Snapshot + +A Subscription Snapshot is an immutable projected state for one Subscription Instance at one projection version. + +It should contain: + +- Subscription Snapshot ID +- Subscription Instance ID +- projection version +- projection-rule version +- computed-at timestamp +- `as_of` timestamp +- access state +- renewal intent +- billing state +- period start +- period end +- grace-period end +- billing-retry start +- pause start or resume time where supported +- cancellation effective time +- expiration effective time +- revocation effective time +- refund effective time +- current Product ID +- prior Product ID where transition applies +- provider status metadata required for explanation +- uncertainty status +- terminal status +- source fact range or references +- checksum +- projection reason +- created metadata + +Snapshots are immutable. + +Changing state creates a new Snapshot. + +## Subscription Timeline Entry + +A Timeline Entry is an immutable human- and machine-readable explanation of a relevant change. + +Timeline types may include: + +- purchase started +- purchase validated +- trial started +- renewal validated +- auto-renew enabled +- auto-renew disabled +- cancellation requested +- grace period started +- grace period ended +- billing retry started +- billing recovered +- pause scheduled +- pause started +- pause ended +- Product upgraded +- Product downgraded +- expiration +- refund +- revocation +- customer association changed +- Product resolution repaired +- projection replayed +- projection rule upgraded + +A Timeline Entry should contain: + +- Timeline Entry ID +- Subscription Instance ID or One-Time Purchase Instance ID +- effective timestamp +- observed timestamp +- entry type +- old snapshot ID where applicable +- new snapshot ID where applicable +- Product IDs +- source fact IDs +- explanation code +- safe explanation details +- projection-rule version +- created timestamp + +Timeline entries must not expose raw provider secrets. + +## Entitlement Definition + +An Entitlement Definition already exists from earlier phases. + +Phase 9B must preserve: + +- stable Entitlement ID +- Project scope +- key +- internal name +- description +- lifecycle state +- created metadata +- archive metadata + +An Entitlement Definition describes access meaning. + +It is not customer state. + +## Product-to-Entitlement Grant Version + +A Product-to-Entitlement Grant Version defines which Entitlements a Product grants and when that rule applies. + +It should contain: + +- Grant Version ID +- Project ID +- Product ID +- Entitlement ID +- grant-policy version +- effective start +- effective end where applicable +- supported purchase types +- access policy for active state +- access policy for grace period +- access policy for billing retry where approved +- access policy for paused state where approved +- access policy for one-time ownership +- created metadata +- actor +- reason + +The plan must define whether Product grant changes are: + +- prospective only +- retroactive +- selectable by effective date +- or represented through Product replacement + +Do not silently choose retroactive behaviour. + +## Entitlement Source + +An Entitlement Source explains one valid reason that a Billing Customer currently has or may have an Entitlement. + +It should contain: + +- Entitlement Source ID +- Billing Customer ID +- Entitlement ID +- source type +- Subscription Instance ID or One-Time Purchase Instance ID +- Product ID +- Grant Version ID +- source Snapshot ID +- source start +- source end +- source state +- uncertainty state +- effective priority only where needed +- explanation code +- created metadata + +Source types may include: + +- active subscription +- trial +- verified grace period +- accepted billing-retry access +- valid one-time non-consumable purchase +- Family Sharing source where explicitly supported + +Manual access grants remain excluded unless later approved. + +## Customer Entitlement Snapshot + +A Customer Entitlement Snapshot is an immutable authoritative view of all Entitlements for one Billing Customer at one projection version. + +It should contain: + +- Customer Entitlement Snapshot ID +- Billing Customer ID +- projection version +- projection-rule version +- computed-at timestamp +- `as_of` timestamp +- Entitlement entries +- source references +- unknown or unavailable evidence +- checksum +- previous Snapshot ID +- change reason +- created metadata + +A current pointer may identify the latest committed Snapshot. + +The Snapshot must be reconstructable from underlying source projections and grant versions. + +## Entitlement Entry + +Each Entitlement entry should contain: + +- Entitlement ID +- Entitlement key +- authoritative state +- effective start +- effective end where known +- refresh recommended at +- source count +- source IDs +- primary explanation +- uncertainty reason where relevant +- Product IDs +- Subscription Instance IDs +- Snapshot version + +Suggested authoritative states: + +- active +- inactive +- unknown +- unavailable + +Do not add a large flat state enum where source-level detail provides the explanation. + +## Access Decision Snapshot + +An Access Decision Snapshot is the server response shape or materialized view used by application backends and SDK synchronization. + +It should include: + +- Billing Customer ID +- Entitlement Snapshot version +- issued-at timestamp +- `as_of` timestamp +- refresh-after timestamp +- valid-until timestamp for bounded client caching +- Entitlement entries +- data freshness +- projection status +- correlation ID +- optional signature or integrity metadata if approved + +An Access Decision Snapshot is not a bearer credential unless explicitly designed as one. + +--- + +# Subscription State Model + +Do not model every provider concept as one flat enum. + +Use multiple explicit axes and derive a canonical human-readable state. + +## Access State + +Suggested access states: + +- active +- inactive +- unknown +- unavailable + +This axis answers whether the subscription source currently grants access under accepted policy. + +## Lifecycle State + +Suggested lifecycle states: + +- trialing +- active +- grace_period +- billing_retry +- paused +- expired +- revoked +- refunded +- superseded +- unknown + +This axis explains the provider lifecycle. + +## Renewal Intent + +Suggested renewal-intent values: + +- auto_renew_enabled +- auto_renew_disabled +- provider_managed +- paused +- unknown + +Cancellation usually changes renewal intent before it changes access. + +Do not model `cancelled` as immediate inactive access unless provider facts prove the effective access end has occurred. + +## Billing State + +Suggested billing-state values: + +- current +- retrying +- grace +- failed +- refunded +- revoked +- unknown + +## Uncertainty State + +Suggested uncertainty values: + +- none +- provider_unavailable +- missing_fact +- identity_unresolved +- Product_unresolved +- conflicting_facts +- projection_failed +- stale_validation +- unsupported_provider_state + +Unknown and unavailable must remain explainable. + +--- + +# Canonical State Derivation + +The plan must define a deterministic derivation from the state axes. + +Example conceptual derivation: + +```text +if revoked is effective: + lifecycle = revoked + access = inactive + +else if refund invalidates ownership and is effective: + lifecycle = refunded + access = inactive + +else if current verified period is active: + if trial: + lifecycle = trialing + else: + lifecycle = active + access = active + +else if verified grace period is active and policy grants access: + lifecycle = grace_period + access = active + +else if verified billing retry is active: + lifecycle = billing_retry + access = policy-dependent active or inactive + +else if verified pause is effective: + lifecycle = paused + access = provider-and-policy-dependent + +else if verified period ended: + lifecycle = expired + access = inactive + +else: + lifecycle = unknown + access = unknown +``` + +This is conceptual only. + +The implementation must use accepted provider normalization and approved access policies. + +Do not use this example as a substitute for reviewing current provider semantics. + +--- + +# Subscription State Transition Table + +The Stage 1 plan must produce a complete transition table. + +At minimum, evaluate transitions such as: + +| Prior state | Validated fact | Effective result | Access expectation | +|---|---|---|---| +| none | initial purchase validated | trialing or active | active | +| trialing | renewal validated | active | active | +| active | renewal validated | active with new period | active | +| active | auto-renew disabled | active with renewal off | active until period end | +| active | cancellation effective at period end | active until period end | active | +| active | grace period begins | grace period | policy-defined, normally active when provider confirms grace | +| grace period | payment recovered | active | active | +| grace period | grace ends without recovery | expired | inactive | +| active | billing retry begins | billing retry | policy-defined | +| billing retry | payment recovered | active | active | +| billing retry | retry ends without recovery | expired | inactive | +| active | pause scheduled | active until pause effective | active | +| active | pause effective | paused | provider-defined | +| paused | resume effective | active or billing retry | provider-defined | +| active | refund effective | refunded | inactive where refund invalidates source | +| active | revocation effective | revoked | inactive | +| expired | late renewal fact validated | active with reprojection | active after effective renewal | +| active Product A | upgrade fact effective | active Product B | active, Product grants re-evaluated | +| active Product A | downgrade scheduled | Product A until effective change | active | +| Product A period end | downgrade effective to Product B | active Product B | active | +| any | conflicting valid facts | unknown or deterministic provider-precedence result | never guess | +| any | duplicate fact | unchanged | unchanged | +| any | out-of-order fact | deterministic reprojection | derived from effective timeline | + +The actual table must cover supported Apple and Google facts separately where semantics differ. + +Do not force provider-specific behaviour into a misleading universal transition. + +--- + +# Transaction Ordering and Effective Time + +Each fact should distinguish: + +- provider effective time +- provider event time +- provider transaction time +- provider expiration time +- received time +- validation time +- projection time + +The plan must define which timestamp drives each transition. + +Examples: + +- cancellation notice may change renewal intent immediately but access only at period end +- refund may be effective at a provider-supplied revocation time +- late renewal may reactivate a period whose effective time predates receipt +- upgrade may have immediate or next-period effect +- downgrade may be deferred +- pause may have a scheduled future effective time + +Do not sort only by `received_at`. + +## Deterministic Tie-Breaking + +If two facts have the same effective timestamp, use a documented provider-aware precedence and stable tie-breaker. + +Potential tie-break inputs: + +- provider sequence number +- transaction lineage position +- provider fact type precedence +- provider transaction ID +- fact ID + +The same facts must order identically during replay. + +## Supersession + +Some provider facts supersede or replace earlier purchase lineage. + +Represent supersession explicitly. + +Do not delete the earlier Subscription Instance. + +A superseded source may stop granting access while remaining visible in history. + +--- + +# Projection Checkpoints + +Projection may use checkpoints for efficiency. + +A checkpoint should contain: + +- Subscription Instance ID +- last projected ordered-fact position +- projection-rule version +- current Snapshot ID +- checksum +- created timestamp + +Checkpoint rules: + +- checkpoints are derived and rebuildable +- replay may ignore checkpoints +- out-of-order facts invalidate affected checkpoints +- checkpoint corruption must not corrupt source facts +- checkpoint use must not change deterministic output + +Do not use a checkpoint as the only copy of state history. + +--- + +# Projection Rule Versioning + +Every Subscription Snapshot and Customer Entitlement Snapshot must record the projection-rule version. + +Changing state semantics requires: + +- documented reason +- version increment +- compatibility analysis +- shadow replay +- impact report +- owner approval when access may change +- controlled promotion +- rollback plan + +Do not silently deploy new projection semantics that change customer access. + +## Shadow Projection + +For access-affecting rule changes, support a shadow projection process. + +Shadow projection should: + +- replay selected or all customers using the candidate rule version +- compare current and candidate state +- report changes +- classify expected and unexpected differences +- avoid changing authoritative pointers +- support sampling and full runs +- produce auditable results + +Promotion requires explicit approval. + +--- + +# Customer Association + +## Association Before Projection + +A validated fact may project to a customer only when accepted association evidence exists. + +If no customer can be resolved: + +- preserve the fact +- record unresolved association +- expose diagnostics +- allow accepted restore or trusted linking workflows +- do not create an arbitrary Customer silently + +## Creating a Billing Customer + +A Billing Customer may be created when: + +- a trusted application backend identifies a new customer +- an SDK presents an accepted customer access token bound to an application user +- an approved anonymous installation mode creates an installation-scoped customer +- a validated provider identity is explicitly linked through an accepted flow + +The Stage 1 plan must define which creation modes are supported. + +## Conflicting Association + +If one purchase lineage appears associated with more than one Billing Customer: + +- freeze projection for the disputed lineage +- preserve the last authoritative state according to approved safety policy +- mark uncertainty +- expose a conflict +- require trusted resolution +- audit all actions + +Do not duplicate the same purchase across two customers. + +## Reassociation + +Reassociation is high risk. + +Phase 9B should support reassociation only if essential for restore and identity correction. + +The plan must define: + +- authorization +- proof required +- effect on historical snapshots +- webhook behaviour +- SDK cache invalidation +- audit history +- replay +- rollback + +Bulk reassociation remains Phase 9C. + +--- + +# Product-to-Entitlement Grant Evaluation + +## Effective Grant Selection + +For each active purchase source, select the applicable Product-to-Entitlement Grant Version using accepted rules. + +The plan must define whether selection uses: + +- purchase effective time +- current projection time +- Product version +- explicit grant policy version stored with the transaction fact +- another accepted deterministic key + +Do not use whichever grant row is currently active without historical analysis. + +## Prospective Versus Retroactive Changes + +The owner must approve one or more supported policies. + +Possible policies: + +### Prospective + +New grant rules apply only to new purchases or renewals after the effective time. + +### Retroactive + +New grant rules intentionally update existing valid purchase sources. + +### Product Replacement + +Historical purchases keep old grants; new Product versions carry new grants. + +The dashboard must show the selected policy before a grant change is published. + +Do not silently apply retroactive changes. + +## Multiple Entitlement Sources + +If several sources grant one Entitlement: + +```text +Pro Monthly subscription ++ Lifetime purchase ++ Family-shared source += Pro Entitlement active while any accepted source remains active +``` + +The Entitlement entry should expose all contributing sources. + +Do not select one source and discard the others. + +## Source End + +An Entitlement Source ends when: + +- its Subscription Snapshot no longer grants access +- its One-Time Purchase is revoked or refunded +- its Product grant version ends according to policy +- customer association is invalidated +- a Family Sharing source ends +- a superseding provider fact ends the source + +Ending one source triggers customer Entitlement reprojection. + +--- + +# Authoritative Entitlement Computation + +For each Billing Customer: + +1. load current accepted subscription and one-time purchase projections +2. identify valid access-granting sources +3. resolve applicable Product grant versions +4. produce Entitlement Sources +5. group sources by Entitlement +6. derive authoritative state +7. preserve unknown evidence +8. create an immutable Customer Entitlement Snapshot +9. atomically update the current Snapshot pointer +10. enqueue access-change webhooks if the committed state changed + +## Active + +An Entitlement is active when at least one accepted source actively grants it. + +## Inactive + +An Entitlement is inactive when: + +- no source actively grants it +- all relevant evidence is resolved +- no critical unknown condition prevents a definitive result + +## Unknown + +An Entitlement is unknown when Mosaic cannot safely determine active or inactive because of: + +- unresolved customer identity +- conflicting validated facts +- unresolved Product mapping +- projection failure +- stale or missing critical provider evidence according to policy +- unsupported provider state + +## Unavailable + +An Entitlement response may be unavailable when the authoritative service cannot currently provide a valid snapshot. + +Unavailable is a service-delivery state, not customer access state. + +## Effective End + +When several sources grant one Entitlement, the effective end may be: + +- the latest known end among active finite sources +- absent for a valid permanent source +- unknown if one active source has uncertain end + +Do not report a misleading finite expiry when a permanent source exists. + +## Explanation + +Every Entitlement entry must expose a safe explanation. + +Example: + +```text +Entitlement: pro +State: active +Reason: Active yearly subscription +Product: Pro Yearly +Source: Subscription 01... +Valid through: 2027-04-01T12:00:00Z +Additional source: Lifetime Purchase +``` + +Do not expose raw provider tokens or secret identifiers. + + +# Subscription Scenarios + +## Initial Purchase + +When an initial validated purchase fact is associated with a Billing Customer: + +1. resolve Product mapping history +2. create or locate the Purchase Lineage +3. create Subscription Instance or One-Time Purchase Instance +4. order the fact +5. project state +6. evaluate Product grants +7. create Customer Entitlement Snapshot +8. update authoritative pointers atomically +9. enqueue access-change webhook if state changed +10. make the new Snapshot available to server and SDK APIs + +Do not grant access from an unvalidated client observation. + +## Renewal + +A validated renewal should: + +- extend or create the accepted subscription period +- preserve the same lineage where provider semantics require it +- update current Product where applicable +- preserve prior Snapshots +- re-evaluate grant versions +- keep access active +- emit timeline and webhook changes only when relevant + +A renewal that does not change Entitlement state may still create a new Subscription Snapshot and Timeline Entry. + +The plan must decide whether it emits an Entitlement webhook when only expiry changes. + +## Cancellation + +Cancellation normally means future renewal is disabled. + +It must not automatically revoke current access. + +Expected behaviour: + +- set renewal intent to auto-renew disabled +- preserve access through the validated current period +- show cancellation effective time +- emit subscription-state webhook +- emit Entitlement webhook only when the Entitlement payload or expiry semantics change according to policy +- transition to expired only when the effective end is reached without a valid renewal + +## Expiration + +Expiration occurs when: + +- validated period end has passed +- no valid renewal extends the period +- no accepted grace or billing-retry access applies +- no later out-of-order fact reactivates the lineage + +Expiration should: + +- create a new Subscription Snapshot +- end related Entitlement Sources +- recompute Customer Entitlements +- emit access-change webhook if state changes +- preserve the complete timeline + +## Grace Period + +Grace-period handling must be provider-aware and policy-approved. + +The plan must define: + +- how grace start is identified +- how grace end is identified +- whether verified grace grants access +- how provider unavailable affects grace +- how late recovery is handled +- whether SDK cache may extend through grace end +- webhook semantics + +Do not synthesize a grace period merely because payment failed. + +## Billing Retry + +Billing retry and grace period are not automatically identical. + +The plan must define: + +- normalized provider facts +- access policy +- retry start +- retry end +- recovery +- expiration +- unknown state when provider semantics are insufficient + +Do not grant indefinite access during billing retry. + +## Pause + +For providers supporting pause: + +- preserve access until pause is effective where provider facts require it +- represent paused lifecycle explicitly +- apply provider- and policy-approved access during pause +- represent scheduled resume +- handle resume, cancellation, or expiration +- preserve prior periods + +Do not treat a scheduled pause as immediate inactive access. + +## Refund + +A validated refund should: + +- identify the affected transaction or purchase lineage +- determine effective time +- determine whether it invalidates ownership +- create a refund Timeline Entry +- project the affected source +- recompute Entitlements +- preserve unrelated sources +- emit webhooks if state changes + +Partial or ambiguous refunds must not be normalized as full revocation without provider evidence. + +If partial refunds are outside supported Product types, quarantine or mark unsupported. + +## Revocation + +A validated revocation should: + +- mark the affected source invalid at the provider effective time +- reproject state +- recompute Entitlements +- preserve unrelated sources +- emit high-priority access-change webhooks +- invalidate SDK cache on next refresh +- remain auditable + +Do not delete the purchase history. + +## Upgrade + +An upgrade may be immediate or provider-scheduled. + +The plan must define provider-specific mapping for: + +- old Product +- new Product +- effective time +- proration context where informational +- prior lineage +- new lineage or linked token +- Entitlement grant changes +- overlapping periods + +Mosaic does not compute financial proration. + +Expected access behaviour: + +- preserve valid access during transition +- avoid duplicate access sources when one source supersedes another +- apply new Product grants at the accepted effective time +- preserve old Product history +- emit subscription and Entitlement changes + +## Downgrade + +A downgrade is often scheduled for a future period. + +Expected behaviour: + +- current Product remains authoritative until effective change +- renewal intent records the scheduled Product where provider facts support it +- new Product grants apply only at effective time +- timeline shows scheduled change +- historical snapshots remain unchanged + +Do not switch Product grants at scheduling time unless provider semantics require immediate effect. + +## One-Time Non-Consumable + +A validated one-time non-consumable purchase should: + +- create or update One-Time Purchase Instance +- grant configured Entitlements +- remain active without a recurring expiration +- become inactive only after validated refund, revocation, association invalidation, or accepted Product rule change +- preserve acquisition history + +Do not create recurring subscription periods for a one-time Product. + +## Family Sharing + +Family Sharing support must be explicit and provider-aware. + +The Stage 1 plan must determine: + +- which provider facts identify shared ownership +- whether server validation exposes sufficient ownership information +- whether the customer association is safe +- whether the Product permits sharing +- how a shared source ends +- how restore behaves +- how SDK diagnostics explain shared access + +Do not create a Mosaic family graph unless separately approved. + +Do not grant Family Sharing access based only on a client flag. + +If server-side evidence is insufficient, mark the feature unsupported or unknown. + +## Multiple Active Purchases + +A customer may have: + +- more than one subscription lineage +- one subscription and one lifetime purchase +- overlapping old and new Products +- purchases across providers +- purchases across platforms + +The engine must: + +- project each source independently +- detect invalid duplicate association where necessary +- aggregate Entitlements from all accepted sources +- avoid double-counting access +- explain contributing sources +- preserve provider independence + +## Cross-Provider Purchase + +If one customer purchases equivalent Products through Apple and Google: + +- each purchase remains a separate source +- the same Entitlement may have multiple active sources +- refunding one source does not revoke the other +- customer identity must be explicitly linked +- Product mapping history remains provider-specific + +Do not collapse provider lineages destructively. + +--- + +# Restore and Cross-Device Synchronization + +## Restore Objective + +Restore should recover access already supported by validated provider facts or trigger accepted provider synchronization. + +Restore must not fabricate access from local receipt presence alone. + +## Restore Flow + +A recommended flow is: + +```text +SDK initiates native provider restore or sync +→ SDK obtains provider transaction references +→ SDK submits untrusted transaction observations +→ Phase 9A validates or recognizes duplicates +→ Phase 9B associates facts with Billing Customer +→ projection runs +→ SDK polls or refreshes authoritative Entitlement Snapshot +→ restored access is displayed +``` + +The exact platform flow must follow current provider semantics. + +## Restore Result + +Suggested server-aware restore outcomes: + +- restored +- no additional purchases found +- validation pending +- identity unresolved +- Product unresolved +- provider unavailable +- failed + +Do not return restored until authoritative server state reflects the restored source. + +The native provider’s local restore success may be reported separately from Mosaic authoritative sync completion. + +## Customer Access Token + +A public SDK key alone is insufficient to authorize access to an arbitrary Billing Customer’s Entitlements. + +The Stage 1 plan must define a secure customer-authentication mechanism. + +Preferred model: + +```text +Host application backend +→ authenticates its user +→ requests short-lived Mosaic Customer Access Token using secret server key +→ returns token to app +→ SDK uses token for Entitlement synchronization +``` + +A Customer Access Token should be: + +- short lived +- scoped to Project and Environment +- scoped to one Billing Customer +- audience restricted +- revocable where practical +- signed +- free of provider secrets +- minimal in claims +- safe to refresh through the host backend + +Do not let the SDK retrieve arbitrary customer state using only a guessable application user ID. + +## Anonymous Mode + +If anonymous installation-scoped Entitlements are supported: + +- issue an opaque installation credential +- bind it to Project, Environment, and installation +- use rate limiting +- support rotation +- prevent querying another installation +- document transition to identified user +- prevent cache leakage after identity change + +The owner must approve anonymous authoritative access. + +## Cross-Device Synchronization + +When the same identified Billing Customer uses several devices: + +- all devices should receive the same authoritative Snapshot version +- provider purchase source may originate on any supported platform +- SDK cache remains device-local +- server state remains customer-scoped +- identity token controls access +- one device’s logout does not revoke the server customer +- identity reassociation follows accepted rules + +## Sync API Behaviour + +The SDK sync API should return: + +- current Entitlement Snapshot version +- issued-at +- `as_of` +- refresh-after +- valid-until +- Entitlement entries +- source summaries +- projection status +- pending validation or reconciliation status where safe +- ETag or version token +- safe diagnostics + +Support conditional requests. + +A `304 Not Modified` should preserve the cached Snapshot. + +--- + +# Offline SDK Entitlement Cache + +## Purpose + +SDK cache supports: + +- UI continuity +- temporary offline feature gating +- reduced network use +- startup speed + +It does not replace server authorization for protected backend resources. + +## Cache Policy + +The plan must define: + +- storage location +- encryption or secure-storage use +- cache key +- customer identity binding +- Snapshot version +- issued-at +- refresh-after +- valid-until +- hard expiry +- stale grace if any +- logout invalidation +- identity-change invalidation +- application reinstall behaviour +- clock-skew handling + +Do not allow one customer’s cache to be reused by another customer. + +## Offline Access Policy + +The owner must approve the offline access policy. + +Possible policies include: + +### Strict + +After `valid_until`, state becomes unknown and premium UI is not granted. + +### Bounded Grace + +Previously active Entitlements remain locally active for a short approved interval after `valid_until`, while clearly marked stale. + +### Server-Only + +SDK cache informs UI, but protected actions always require application-server authorization. + +The selected policy may differ by Entitlement sensitivity, but such complexity requires explicit approval. + +Do not silently grant unlimited offline access. + +## Cache Integrity + +Use: + +- atomic writes +- version checks +- identity binding +- checksum or accepted integrity protection +- secure storage where available for sensitive metadata +- last-known-valid preservation + +If a new Snapshot is malformed or older: + +- reject it +- preserve current cache +- emit diagnostics + +## Cache State Model + +Suggested cache states: + +- fresh +- refresh recommended +- stale but within approved grace +- expired +- missing +- invalid +- belongs to different customer + +The SDK public API should not hide these distinctions when they matter. + +--- + +# Server Access Decision API + +Provide a trusted server API for application backends. + +Example conceptual endpoint: + +```text +GET /api/v1/public/customers/{customer_id}/entitlements +``` + +The actual path should follow existing API conventions. + +Authentication requires a secret server key with appropriate Project and Environment scope. + +The response should include: + +- Billing Customer ID +- Snapshot version +- `as_of` +- Entitlements +- source summaries +- projection status +- pending state +- ETag +- safe diagnostics +- correlation ID + +Do not expose provider credentials or raw purchase tokens. + +## Entitlement Check API + +Provide a focused endpoint where justified: + +```text +POST /api/v1/public/entitlements/check +``` + +Request may include: + +- Billing Customer ID +- Entitlement keys +- expected Snapshot version where useful + +Response should include: + +- per-key state +- explanation +- Snapshot version +- `as_of` +- stale or pending status + +Do not return a bare boolean without explanation and version context. + +## Customer Creation and Token API + +Where accepted, provide trusted endpoints for: + +- create or get Billing Customer +- attach application user alias +- issue Customer Access Token +- revoke or rotate installation credential +- inspect identity conflicts +- request authoritative sync + +These endpoints require strong authorization and tenant scope. + +Do not let a public SDK key create arbitrary identified customers without an approved abuse model. + +--- + +# Application Webhooks + +Phase 9B may notify application backends of committed authoritative changes. + +## Webhook Event Types + +Suggested events: + +- `customer.entitlements.changed` +- `subscription.state.changed` +- `subscription.period.changed` +- `subscription.renewal_intent.changed` +- `subscription.expired` +- `subscription.revoked` +- `subscription.refunded` +- `customer.billing_identity.conflict` +- `customer.projection.failed` +- `customer.projection.recovered` + +Avoid provider-specific event names in the primary public webhook contract. + +Provider facts remain available through diagnostics or linked source data. + +## Webhook Event Model + +A webhook event should contain: + +- stable event ID +- event type +- event-contract version +- Project ID +- Environment ID +- Billing Customer ID +- Subscription Instance ID where applicable +- Entitlement Snapshot version +- prior Snapshot version +- `occurred_at` +- `created_at` +- changed fields +- current safe state +- source projection version +- correlation ID + +Do not include: + +- provider secrets +- raw purchase tokens +- private keys +- complete raw provider payloads + +## Signing + +Use the accepted webhook-signing system. + +A signature should cover: + +- timestamp +- event ID +- raw request body +- signing version + +Support: + +- key rotation +- multiple active verification keys during rotation +- replay-window guidance +- documented verification examples +- test endpoint + +Do not invent a second unrelated signing system if one exists. + +## Delivery + +Webhook delivery is at least once. + +Support: + +- bounded timeout +- retry with exponential backoff and jitter +- stable event ID +- attempt history +- response code +- safe response excerpt limits +- exhausted state +- manual replay +- disable destination +- audit events +- tenant isolation + +Webhook failure must not roll back customer state. + +## Ordering + +Webhook order may be delayed or retried. + +Consumers must use: + +- event ID +- Entitlement Snapshot version +- Subscription projection version +- occurred-at + +Document that consumers should ignore older versions after applying a newer version. + +Do not promise exactly-once delivery. + +## Webhook Destinations + +A destination should contain: + +- Project and Environment +- URL +- enabled event types +- encrypted signing secret +- status +- created metadata +- last success +- last failure +- delivery statistics +- disabled metadata + +Validate URLs against SSRF policy. + +Do not allow unsafe internal-network destinations without an explicit self-hosted policy. + +--- + +# Projection Processing Architecture + +A recommended processing flow is: + +```text +Phase 9A validated fact committed +→ projection job enqueued +→ acquire lineage lock +→ load ordered validated facts +→ resolve Billing Customer +→ project Subscription or One-Time Purchase +→ evaluate Product grant versions +→ project Customer Entitlements +→ commit Snapshots and current pointers atomically +→ create webhook events +→ release lock +→ dispatch webhooks asynchronously +``` + +## Locking + +Use an accepted per-lineage or per-customer serialization mechanism. + +Options may include: + +- PostgreSQL advisory lock +- row-level lock +- compare-and-swap projection version +- accepted job uniqueness + +The Stage 1 plan must choose and justify the mechanism. + +Do not hold a database lock while calling external providers or webhook destinations. + +## Transaction Boundary + +The authoritative transaction should include: + +- new Subscription Snapshot +- Timeline Entries +- Entitlement Sources +- Customer Entitlement Snapshot +- current pointer updates +- projection checkpoint +- webhook event creation +- audit event where required + +Webhook delivery attempts occur outside the projection transaction. + +## Idempotency + +A projection command should include an idempotency key based on: + +- target lineage or customer +- highest fact version or ordered position +- projection-rule version +- grant-rule version set + +Repeated execution must not create duplicate logical Snapshots. + +It may record an operational retry attempt separately. + +## No-Change Projection + +If replay produces the same authoritative state: + +- avoid emitting a duplicate access-change webhook +- optionally record projection metadata or a no-change audit event +- advance checkpoint safely +- preserve deterministic checksum + +--- + +# PostgreSQL Persistence Model + +The Stage 1 plan must inspect existing tables and add only necessary migrations. + +Likely concepts include: + +- billing customers +- customer aliases +- customer association evidence +- purchase lineages +- subscription instances +- one-time purchase instances +- subscription snapshots +- subscription timeline entries +- projection checkpoints +- projection jobs +- projection attempts +- projection-rule versions +- Product-to-Entitlement grant versions +- Entitlement sources +- customer Entitlement snapshots +- customer Entitlement snapshot entries +- current customer Entitlement pointers +- customer access tokens or token metadata +- installation credentials where approved +- webhook destinations +- webhook signing keys +- webhook events +- webhook delivery attempts +- restore or synchronization jobs +- shadow projection runs +- shadow projection differences +- billing identity conflicts +- projection audit events + +Do not create Phase 9C migration tables. + +Do not duplicate Phase 9A validated-fact tables. + +## Constraints + +Use appropriate: + +- primary keys +- foreign keys +- tenant scope +- Environment scope +- unique constraints +- check constraints +- explicit deletion behaviour +- immutable-row protections where practical +- justified indexes + +Important constraints may include: + +- alias unique by Project, type, and protected value while active +- Purchase Lineage unique by provider, Environment, Application, and lineage key +- projection version unique within Subscription Instance +- Entitlement Snapshot version unique within Billing Customer +- one current Snapshot pointer per Billing Customer +- one Entitlement entry per Entitlement per Snapshot +- webhook event ID unique +- delivery attempt number unique within webhook event and destination +- grant-version intervals cannot overlap for the same Product and Entitlement under the accepted policy +- customer token metadata cannot cross Environment + +## Deletion + +Billing history should not be cascade deleted casually. + +The Stage 1 plan must define: + +- customer deletion or anonymization +- retention +- audit preservation +- legal and product requirements +- backup implications +- webhook history +- provider fact retention + +Do not use broad `ON DELETE CASCADE` without analysis. + +--- + +# Security Model + +## Authorization + +Enforce server-side authorization for: + +- customer lookup +- alias management +- customer token issuance +- Entitlement APIs +- projection replay +- shadow projection +- webhook destination management +- webhook replay +- customer diagnostics +- restore and sync jobs + +Dashboard visibility is not authorization. + +## Customer Access Tokens + +If used, tokens must: + +- be short lived +- be audience scoped +- be Project and Environment scoped +- bind one Billing Customer +- use accepted signing keys +- support key rotation +- avoid sensitive provider data +- be validated on every SDK sync request +- be rate limited +- be revocable through accepted mechanisms + +Do not use a Project secret key inside mobile applications. + +## PII and Sensitive Identifiers + +Protect: + +- application user IDs where sensitive +- installation credentials +- provider account tokens +- purchase lineage identifiers +- provider transaction references +- webhook signing secrets + +Use: + +- encryption +- approved hashing +- redaction +- least privilege +- access audit + +Do not log raw values unnecessarily. + +## SSRF + +Webhook destinations and any operator-supplied URLs require SSRF protections. + +Validate: + +- scheme +- DNS resolution +- private-network policy +- redirects +- timeouts +- response-size limits +- IP changes +- self-hosted exceptions + +## Rate Limits + +Apply suitable limits to: + +- SDK Entitlement sync +- customer token issuance +- server Entitlement checks +- restore and sync requests +- projection replay +- shadow projection +- webhook test +- webhook replay +- customer search + +## Audit + +Audit: + +- customer creation +- alias attachment +- alias conflict resolution +- Product grant changes +- projection-rule changes +- shadow projection approval +- authoritative rule promotion +- replay +- reassociation +- webhook destination changes +- webhook key rotation +- manual sync +- access token issuance policy changes + +Do not put secrets into audit records. + +--- + +# Observability + +Use OpenTelemetry and Zerolog. + +Instrument: + +- projection jobs +- projection latency +- facts processed +- out-of-order replays +- projection no-change rate +- projection failure +- identity conflict +- Product resolution conflict +- Entitlement Snapshot changes +- customer sync +- SDK cache responses +- ETag hits +- token issuance +- webhook events +- webhook delivery latency +- webhook retry count +- exhausted webhook delivery +- restore jobs +- shadow projection runs +- shadow projection differences +- replay +- lock contention +- worker backlog +- stale projections + +Useful dimensions may include: + +- Project ID +- Environment ID +- provider +- projection-rule version +- Product ID +- Entitlement ID +- safe state codes + +Do not attach: + +- raw user IDs +- purchase tokens +- transaction payloads +- secrets +- raw webhook bodies + +## Alerts + +Define alerts for: + +- projection backlog +- projection failure rate +- stale authoritative state +- identity conflict spikes +- Product resolution quarantine +- webhook delivery exhaustion +- shadow projection unexpected changes +- high lock contention +- restore failure +- token issuance failure +- cross-tenant authorization failure +- database constraint failure + +Alerts should be actionable and vendor-neutral. + +--- + +# Performance and Capacity + +Do not invent arbitrary targets without measurement. + +Stage 1 must propose owner-approved budgets for: + +- fact-to-authoritative-projection latency +- server Entitlement-check latency +- SDK sync latency +- cached SDK check latency +- projection replay throughput +- shadow projection throughput +- webhook event creation +- webhook delivery latency +- customer timeline query +- dashboard customer search +- lock contention +- worker backlog recovery + +Record: + +- median +- p95 +- p99 where appropriate +- throughput +- error rate +- dataset +- concurrency +- environment +- resource use + +Do not optimize with new infrastructure before measuring PostgreSQL and the existing worker system. + + +# Required Agent Execution Model + +Use no more than four concurrent agents. + +Run Phase 9B in six stages. + +--- + +# Stage 1A: Product, Protocol, Backend, and Quality Inspection + +Use exactly: + +1. `mosaic-product` +2. `mosaic-protocol` +3. `mosaic-backend` +4. `mosaic-quality` + +All four agents are read-only during Stage 1A. + +--- + +## Product Agent + +Review: + +- Phase 9B scope +- accepted Phase 9A evidence +- customer-access use cases +- Product and Entitlement boundaries +- subscription-state requirements +- offline access expectations +- restore expectations +- cross-device synchronization +- application-backend authorization +- webhook requirements +- grace-period policy +- billing-retry policy +- pause policy +- Family Sharing expectations +- Product grant change policy +- identity conflict policy +- Phase 9C exclusions +- operational support expectations + +Return: + +- required scope +- deferred scope +- customer terminology +- subscription terminology +- Entitlement terminology +- access-state terminology +- Product grant policy options +- offline access policy options +- grace-period access policy +- billing-retry access policy +- pause access policy +- webhook event requirements +- observable acceptance criteria +- owner decisions +- product risks +- smallest complete Phase 9B workflow + +Confirm: + +- Phase 9B does not perform RevenueCat migration +- Phase 9B does not perform dual-run cutover +- Phase 9B does not add financial reporting +- manual paid-access grants remain excluded +- existing commerce adapters remain usable without Mosaic Billing +- Mosaic Billing remains optional +- server state is authoritative +- client cache is bounded +- Product replacement preserves history + +Do not modify production code. + +--- + +## Protocol Agent + +Inspect: + +- Billing Ingestion Contract v1 +- Commerce Provider Contract +- Analytics Event Contract +- current identity contracts +- current Product and Entitlement models +- current SDK result types +- current webhook contracts +- current Configuration Delivery capability model + +Propose separate versioned contracts where necessary: + +```text +Authoritative Entitlement Contract v1 +Customer Access Token Contract v1 +Billing State Webhook Contract v1 +``` + +Avoid introducing a contract when an accepted existing contract can be extended compatibly. + +Define provider-independent concepts for: + +- Billing Customer +- Subscription Snapshot +- renewal intent +- lifecycle state +- access state +- uncertainty +- Entitlement Snapshot +- Entitlement entry +- Entitlement source summary +- Customer Access Token claims +- SDK sync request and response +- ETag and version semantics +- webhook event +- webhook delivery metadata +- restore status +- projection status +- safe diagnostics + +Do not put provider-native payloads into public contracts. + +Do not modify production files during Stage 1A. + +--- + +## Backend Agent + +Inspect: + +- Phase 9A schema +- Billing Event Ledger +- Normalized Transaction Facts +- Provider Product Mapping history +- reconciliation +- replay +- worker architecture +- job idempotency +- customer or analytics identity models +- Entitlement definitions +- Product-to-Entitlement grants +- current server API authentication +- current SDK authentication +- current webhook infrastructure +- current secret-signing infrastructure +- PostgreSQL transaction patterns +- advisory or row-lock use +- OpenAPI +- audit system +- backup and restore +- current tests + +Propose: + +- Billing Customer model +- alias model +- customer association evidence +- Purchase Lineage +- Subscription Instance +- One-Time Purchase Instance +- Subscription Snapshot +- Timeline +- projection checkpoints +- projection-rule versions +- Product grant versions +- Entitlement Sources +- Customer Entitlement Snapshots +- current authoritative pointers +- serialization strategy +- transaction boundaries +- event ordering +- state machine +- projection worker +- replay +- shadow projection +- customer sync +- token issuance +- webhook events and delivery +- restore workflow +- PostgreSQL migrations +- minimum sufficient tests + +Do not modify production code during Stage 1A. + +--- + +## Quality Agent + +Perform a correctness, security, and operational readiness audit. + +Review: + +- Phase 9A fact immutability +- Product mapping history +- customer identity evidence +- cross-tenant boundaries +- sandbox and production boundaries +- potential double-grant paths +- potential accidental revocation paths +- provider-state ambiguity +- duplicate and out-of-order handling +- transaction ordering +- lock and concurrency risks +- replay determinism +- Product grant history +- offline cache risks +- customer token risks +- webhook signing +- SSRF +- privacy and PII +- retention +- backup implications +- existing SDK identity behaviour + +Return: + +- Phase 9B blockers +- unsafe assumptions +- required ADR checkpoints +- security risks +- correctness risks +- operational risks +- acceptable deferred work +- smallest required mitigations + +Do not modify production code. + +--- + +# Stage 1B: Dashboard and SDK Inspection + +Use exactly: + +1. `mosaic-dashboard` +2. `mosaic-flutter` +3. `mosaic-ios` +4. `mosaic-android` + +All four agents are read-only during Stage 1B. + +--- + +## Dashboard Agent + +Inspect: + +- Catalog +- Product and Entitlement detail +- Provider mappings +- transaction ledger +- quarantine +- existing customer or analytics identity UI +- permission system +- audit UI +- job status UI +- webhook settings +- current tables, filters, timelines, and diagnostics +- design-system components +- generated REST client +- existing tests + +Return: + +- Billing Customer information architecture +- customer search +- customer detail +- subscription timeline +- Entitlement detail +- Entitlement source explanation +- projection status +- identity conflict UI +- restore and sync UI +- shadow projection UI +- webhook destination UI +- webhook delivery UI +- server-access token guidance +- required empty, loading, permission, error, and recovery states +- exact files requiring modification +- minimum sufficient tests + +Do not modify code. + +--- + +## Flutter Agent + +Inspect: + +- current identity API +- installation identity +- customer token support +- networking +- secure storage +- Configuration Delivery cache +- commerce adapters +- restore flow +- analytics queue +- application lifecycle +- diagnostics +- current tests + +Return: + +- customer authentication design +- Entitlement sync API +- local cache design +- offline state model +- identity-change behaviour +- restore-and-refresh behaviour +- listener or stream API +- server-check integration +- exact files requiring modification +- minimum sufficient tests + +Do not modify code. + +--- + +## iOS Agent + +Perform the equivalent inspection for: + +- Swift API +- secure storage +- Keychain use +- StoreKit restore and sync +- application lifecycle +- concurrency +- current Entitlements +- background limitations +- diagnostics + +Do not modify code. + +--- + +## Android Agent + +Perform the equivalent inspection for: + +- Kotlin API +- encrypted or protected local storage +- Google Billing restore or active-purchase recovery +- lifecycle +- coroutines +- diagnostics + +Do not modify code. + +--- + +# Stage 1 Integration Contract + +After Stage 1A and Stage 1B: + +1. reconcile all reports +2. resolve terminology centrally +3. surface owner decisions +4. select authoritative customer authentication +5. select offline access policy +6. select grace-period access policy +7. select billing-retry access policy +8. select pause access policy +9. select Product grant change policy +10. select projection serialization strategy +11. select projection rule-version strategy +12. select webhook signing and delivery reuse +13. define state ordering +14. define Family Sharing support or exclusion +15. do not begin implementation with unresolved access-affecting decisions + +Create: + +```text +docs/plans/phase-9b-subscription-state-authoritative-entitlements.md +``` + +The plan must define: + +- current provider documentation consulted +- Billing Customer model +- Customer Alias model +- customer-association evidence +- Purchase Lineage +- Subscription Instance +- One-Time Purchase Instance +- Subscription Snapshot +- state axes +- canonical state derivation +- complete transition tables +- provider-specific transition mappings +- transaction ordering +- effective-time rules +- supersession +- projection checkpoints +- projection-rule versioning +- shadow projection +- Product-to-Entitlement Grant Version +- prospective and retroactive policy +- Entitlement Source +- Customer Entitlement Snapshot +- Access Decision Snapshot +- server authorization +- Customer Access Token +- anonymous mode if approved +- SDK cache +- offline policy +- restore +- cross-device sync +- upgrades and downgrades +- refunds and revocations +- grace and billing retry +- pause +- Family Sharing support or explicit exclusion +- webhooks +- replay +- concurrency +- PostgreSQL migrations +- REST resources +- dashboard information architecture +- SDK architecture +- observability +- security +- performance targets +- minimum sufficient tests +- explicit Phase 9C exclusions +- integrated demonstration + +Stop if any owner-level access or security decision remains unresolved. + +--- + +# ADR Checkpoints + +Claude must stop and request an ADR or owner decision before introducing any of the following if not already accepted: + +- new primary database +- new queue or streaming platform +- new token-signing system +- new encryption-key system +- new webhook-signing system +- retroactive Product grant changes +- indefinite offline access +- manual customer Entitlement grants +- cross-Project identity +- automatic customer merge +- family-account graph +- projection semantics that revoke existing access +- new public breaking SDK API +- new authoritative state outside the projection engine +- direct provider calls inside the Entitlement read path +- synchronous webhook delivery inside the projection transaction + +--- + +# Stage 2: Protocol, Backend, and Dashboard Implementation + +Use exactly: + +1. `mosaic-protocol` +2. `mosaic-backend` +3. `mosaic-dashboard` + +These agents have non-overlapping write ownership. + +--- + +## Protocol Agent Ownership + +Own only: + +- authoritative Entitlement contract schemas +- customer-access-token contract documentation +- billing-state webhook schemas +- contract fixtures +- contract changelogs +- compatibility documentation +- generated contract artifacts where established + +Do not modify: + +- Paywall Protocol semantics +- Configuration Delivery semantics +- Commerce Provider Contract semantics +- Placement Decision Contract semantics +- Analytics Event Contract semantics +- Experiment Assignment Contract semantics +- Billing Ingestion Contract semantics +- backend implementation files +- dashboard implementation files +- SDK implementation files + +--- + +## Protocol Work Package 1: Authoritative Entitlement Contract v1 + +Implement: + +- Billing Customer reference +- Entitlement Snapshot version +- issued-at +- `as_of` +- refresh-after +- valid-until +- Entitlement entries +- states +- source summaries +- Product references +- Subscription references +- uncertainty +- projection status +- ETag or version semantics +- diagnostics-safe metadata +- unknown field behaviour +- unsupported version behaviour + +Create meaningful fixtures for: + +- active subscription +- active trial +- active grace period +- inactive expired subscription +- unknown state +- one-time purchase +- multiple active sources +- refund of one source while another remains active +- bounded offline cache +- newer Snapshot +- older Snapshot rejection +- different-customer rejection + +--- + +## Protocol Work Package 2: Customer Access Token Contract v1 + +Document: + +- token issuer +- audience +- subject +- Project +- Environment +- Billing Customer +- issued-at +- expiry +- token ID +- key ID +- scopes +- version +- refresh expectations +- revocation expectations +- clock-skew policy + +Do not put Entitlement state or provider secrets in the token unless an accepted design explicitly requires a signed offline claim. + +Prefer using the token for authentication and retrieving a current Snapshot. + +--- + +## Protocol Work Package 3: Billing State Webhook Contract v1 + +Implement: + +- event ID +- event type +- contract version +- Project +- Environment +- Billing Customer +- Subscription Instance +- current Snapshot version +- previous Snapshot version +- changed Entitlements +- state summary +- occurred-at +- created-at +- projection-rule version +- source reason +- correlation ID +- signing metadata documentation + +Create fixtures for: + +- Entitlement activated +- Entitlement deactivated +- expiry extended +- subscription cancelled but access remains active +- refund +- revocation +- unknown-state transition +- webhook retry with same event ID + +Do not include provider secrets or raw tokens. + +--- + +## Backend Agent Ownership + +Own: + +- `apps/api/**` +- `apps/worker/**` +- PostgreSQL migrations +- authoritative projection engine +- customer identity +- Entitlement engine +- server APIs +- SDK sync endpoints +- webhook delivery +- replay and shadow projection +- OpenAPI +- observability +- backend tests +- backend documentation + +Do not modify dashboard, protocol, or SDK production files. + +--- + +# Backend Work Packages + +## Backend Work Package 1: PostgreSQL Migrations + +Add only migrations required by the accepted Phase 9B model. + +Likely concepts include: + +- billing customers +- customer aliases +- customer association evidence +- purchase lineages +- subscription instances +- one-time purchase instances +- subscription snapshots +- subscription timeline entries +- projection checkpoints +- projection attempts +- projection-rule versions +- Product-to-Entitlement Grant Versions +- Entitlement Sources +- customer Entitlement Snapshots +- customer Entitlement Snapshot entries +- current Snapshot pointers +- customer token metadata +- installation credentials where approved +- restore or synchronization jobs +- identity conflicts +- shadow projection runs +- shadow projection differences +- webhook destinations +- webhook signing keys +- webhook events +- webhook delivery attempts +- projection audit events + +Do not create: + +- RevenueCat migration tables +- bulk customer import tables +- cutover tables +- financial ledger tables +- manual paid-access grant tables +- Phase 9C repair-batch tables + +Use: + +- primary keys +- foreign keys +- Project and Environment isolation +- unique constraints +- check constraints +- immutable-row protections where practical +- explicit deletion behaviour +- justified indexes + +Run migrations against: + +- a clean Phase 9A database +- representative Phase 9A data +- accepted GA backup fixture where available + +--- + +## Backend Work Package 2: Billing Customer and Alias Service + +Implement: + +- create or get Billing Customer through trusted flow +- list and retrieve Billing Customers +- attach application-user alias +- attach installation alias where approved +- attach provider identity evidence where approved +- inspect aliases +- revoke alias +- detect conflict +- preserve history +- permissions +- audit events +- stable errors + +Do not: + +- merge customers heuristically +- reassign a purchase lineage automatically +- expose protected alias values unnecessarily +- let public SDK keys query arbitrary customers + +Use optimistic or transactional conflict protection. + +--- + +## Backend Work Package 3: Customer Association Resolver + +Implement deterministic association using approved evidence. + +Return: + +- resolved +- unresolved +- conflicting +- quarantined +- unsupported evidence + +The resolver should: + +- use explicit authority ranking +- preserve evidence links +- avoid side effects during dry-run +- be deterministic +- be replayable +- emit diagnostics +- support accepted restore flows + +Do not guess. + +A conflict should create or update a Billing Identity Conflict record. + +--- + +## Backend Work Package 4: Purchase Lineage Service + +Implement: + +- create or locate Purchase Lineage +- enforce provider and Environment scope +- link validated facts +- detect duplicates +- resolve Product mapping history +- attach Billing Customer +- identify subscription versus one-time purchase +- detect supersession +- preserve historical mapping +- audit events + +Do not merge lineages because they share a Product. + +--- + +## Backend Work Package 5: Projection Ordering + +Implement one canonical ordering component. + +It must: + +- order provider facts deterministically +- preserve provider effective time +- use provider-specific sequence where required +- use stable tie-breakers +- detect out-of-order additions +- identify affected checkpoint +- support replay +- expose safe diagnostics + +Do not duplicate ordering logic across several services. + +Document the ordering version. + +--- + +## Backend Work Package 6: Subscription Projection Engine + +Implement a pure or mostly pure deterministic projection core. + +Inputs should include: + +- ordered validated facts +- prior accepted checkpoint where valid +- Product resolution history +- projection-rule version +- approved provider semantics +- current server time only where explicitly required + +Outputs should include: + +- Subscription Snapshot candidate +- Timeline Entries +- checkpoint +- warnings +- uncertainty +- source fact links +- Product transition +- no-change indicator + +The projection core should not: + +- perform HTTP calls +- write to the database directly +- deliver webhooks +- query dashboard state +- mutate Phase 9A facts + +Persist outputs through an application service transaction. + +--- + +## Backend Work Package 7: One-Time Purchase Projection + +Implement deterministic projection for supported non-consumables. + +Support: + +- acquisition +- duplicate acquisition fact +- refund +- revocation +- Product replacement history +- customer association +- current validity +- Timeline Entries +- source links + +Do not support consumables. + +--- + +## Backend Work Package 8: Subscription State Policies + +Implement the owner-approved policies for: + +- trial access +- active access +- grace-period access +- billing-retry access +- paused access +- cancellation +- expiration +- refund +- revocation +- unknown state +- provider unavailable +- stale validation + +Policies must be versioned. + +Do not hardcode access-affecting policy across handlers. + +Provide one versioned policy interface or module. + +--- + +## Backend Work Package 9: Product Grant Versioning + +Implement: + +- create Grant Version +- list versions +- validate intervals +- select effective version +- publish change +- prospective or approved retroactive policy +- impact preview +- Product usage +- Entitlement usage +- audit events + +Do not mutate an active historical Grant Version. + +If retroactive change is approved: + +- require impact analysis +- require shadow projection +- require explicit confirmation +- record actor and reason + +--- + +## Backend Work Package 10: Entitlement Projection Engine + +Implement a deterministic engine that: + +- loads current purchase-source projections +- selects grant versions +- creates Entitlement Sources +- groups by Entitlement +- preserves uncertainty +- derives active, inactive, unknown, or unavailable state +- computes effective dates +- creates immutable Customer Entitlement Snapshot candidate +- identifies changes from prior Snapshot +- produces safe explanations +- produces webhook change set + +Do not write to the database inside the pure computation core. + +Do not collapse source history. + +--- + +## Backend Work Package 11: Authoritative Projection Transaction + +Implement one atomic application-service transaction that: + +1. acquires accepted lock +2. reloads current projection version +3. loads new validated facts +4. projects Subscription or One-Time Purchase +5. writes immutable Snapshots +6. writes Timeline Entries +7. writes Entitlement Sources +8. writes Customer Entitlement Snapshot +9. updates current pointers +10. writes checkpoint +11. creates webhook events +12. writes audit event +13. commits +14. releases lock + +Do not call external services inside the transaction. + +Repeated execution must be idempotent. + +--- + +## Backend Work Package 12: Projection Job Scheduling + +Trigger projection when: + +- Phase 9A validates a new fact +- Product resolution is repaired +- customer association is established +- grant version changes +- projection-rule version is promoted +- reconciliation discovers a fact +- replay or revalidation changes normalization +- authorized manual sync is requested + +Use the existing worker system. + +Support: + +- job uniqueness +- retries +- bounded backoff +- dead-letter or exhausted state +- observability +- customer or lineage serialization + +Do not enqueue an unbounded duplicate job storm. + +--- + +## Backend Work Package 13: Replay + +Implement: + +- replay one Subscription Instance +- replay one Billing Customer +- replay a bounded Project scope where operationally approved +- select projection-rule version +- preserve prior Snapshots +- compare checksums +- create new projection versions only according to policy +- report no-change +- audit actor +- expose job status + +Do not delete old Snapshots. + +Do not make bulk migration replay tooling; that remains Phase 9C. + +--- + +## Backend Work Package 14: Shadow Projection + +Implement: + +- candidate projection-rule version +- customer or bounded sample selection +- current versus candidate comparison +- Entitlement differences +- subscription-state differences +- expected versus unexpected classification +- summary +- detailed samples +- no authoritative pointer change +- audit history + +Promotion must be a separate authorized action. + +Do not auto-promote because a shadow run completed. + +--- + +## Backend Work Package 15: Customer Access Token Service + +Implement the accepted token model. + +Support: + +- trusted server request +- Billing Customer scope +- Project and Environment scope +- short expiry +- accepted scopes +- key ID +- key rotation +- revocation or invalidation policy +- audit events +- rate limiting +- safe response + +Do not: + +- expose Project secret keys to mobile apps +- allow one token to access another customer +- include provider secrets +- create long-lived unbounded tokens +- accept an unverified public user ID as authorization + +--- + +## Backend Work Package 16: SDK Entitlement Sync Endpoint + +Implement: + +- Customer Access Token authentication +- optional installation credential mode where approved +- ETag or Snapshot version +- conditional request +- current Snapshot response +- `304 Not Modified` +- issued-at +- `as_of` +- refresh-after +- valid-until +- projection status +- pending validation state +- safe source summaries +- rate limiting +- observability + +Do not call provider APIs synchronously on every read. + +Read the authoritative committed projection. + +--- + +## Backend Work Package 17: Trusted Server Entitlement APIs + +Implement: + +- Billing Customer lookup +- Entitlement Snapshot retrieval +- multi-key Entitlement check +- Subscription list +- Subscription Snapshot +- Timeline +- current sources +- projection status +- pending conflict status +- ETag +- pagination where needed +- authorization +- audit where sensitive + +Do not return a bare boolean without version and state context. + +--- + +## Backend Work Package 18: Restore and Sync Jobs + +Implement accepted server-side coordination for restore. + +Support: + +- create sync job +- Billing Customer +- provider +- Application +- Environment +- submitted transaction observations +- Phase 9A validation references +- projection status +- completion +- unresolved identity +- unresolved Product +- provider unavailable +- retry +- audit events + +Do not claim restore completed before authoritative projection reflects it. + +--- + +## Backend Work Package 19: Webhook Destinations and Signing + +Reuse the accepted webhook system where available. + +Implement: + +- create destination +- event subscriptions +- signing secret +- one-time secret display +- key rotation +- destination test +- enable and disable +- SSRF validation +- permission checks +- audit events + +Do not store signing secrets in plaintext. + +--- + +## Backend Work Package 20: Webhook Event Creation + +Create webhook events only after committed authoritative state. + +Support accepted event types. + +Use stable event IDs. + +Include Snapshot versions. + +Do not emit duplicate logical access-change events during no-change replay. + +--- + +## Backend Work Package 21: Webhook Delivery Worker + +Implement: + +- at-least-once delivery +- signature +- timestamp +- bounded timeout +- retries +- jitter +- attempt history +- response-size cap +- exhausted state +- manual replay +- destination disable +- metrics +- logs +- audit + +Do not perform delivery inside the projection transaction. + +--- + +## Backend Work Package 22: Projection Diagnostics and Health + +Implement safe operational views for: + +- projection backlog +- projection failure +- stale customer state +- identity conflicts +- Product conflicts +- unknown Entitlements +- shadow projection +- webhook backlog +- exhausted webhook delivery +- restore jobs +- lock contention +- rule versions + +Do not expose secrets or raw provider payloads. + +--- + +## Backend Work Package 23: OpenAPI and Documentation + +Document all new REST resources. + +Generate the dashboard client through the accepted workflow. + +Document: + +- server authentication +- customer token flow +- SDK sync +- server Entitlement checks +- state semantics +- unknown and unavailable +- webhook verification +- restore flow +- replay +- shadow projection +- grant version changes +- Phase 9C exclusions + +--- + +## Backend Work Package 24: Observability + +Add OpenTelemetry spans and metrics for: + +- association +- lineage resolution +- ordering +- subscription projection +- Entitlement projection +- transaction commit +- replay +- shadow projection +- token issuance +- SDK sync +- webhook creation +- webhook delivery +- restore +- lock wait +- stale projection +- conflict + +Use Zerolog with safe IDs. + +Never log token values, raw aliases, provider tokens, or webhook secrets. + +--- + +# Dashboard Agent Ownership + +Own: + +- Billing Customer features +- customer search +- customer detail +- subscription timeline +- Entitlement explanation +- projection status +- identity conflict UI +- restore and sync UI +- grant-version UI +- replay and shadow projection UI +- webhook settings and delivery UI +- feature-specific tests and documentation + +Use the accepted design system. + +Do not redesign Catalog, Studio, Placements, Analytics, or Experiments. + +Do not add Phase 9C migration UI. + +--- + +# Dashboard Work Packages + +## Dashboard Work Package 1: Billing Customer Search + +Implement search using accepted safe identifiers. + +Support: + +- Billing Customer ID +- approved application user ID lookup +- installation ID lookup where permission allows +- provider lineage reference lookup through protected workflow +- Project and Environment context +- pagination +- permission states +- no-result state +- identity-conflict indicator + +Do not expose broad PII search. + +--- + +## Dashboard Work Package 2: Billing Customer Detail + +Show: + +- Billing Customer ID +- lifecycle +- current Entitlement Snapshot version +- `as_of` +- last projected +- pending facts +- pending projection +- identity aliases +- identity conflicts +- subscriptions +- one-time purchases +- current Entitlements +- webhook status +- audit summary + +Do not label unresolved state as inactive. + +--- + +## Dashboard Work Package 3: Subscription Detail and Timeline + +Show: + +- provider +- Product +- lineage +- current lifecycle state +- access state +- renewal intent +- billing state +- current period +- grace +- billing retry +- pause +- scheduled Product change +- Timeline +- source facts +- projection version +- projection-rule version +- warnings +- replay action where allowed + +Do not show raw purchase tokens. + +--- + +## Dashboard Work Package 4: Entitlement Explanation + +For each Entitlement show: + +- state +- effective dates +- all contributing sources +- Product +- Subscription or one-time purchase +- Grant Version +- Snapshot version +- uncertainty +- explanation +- last updated +- refresh status + +Users should be able to answer: + +> Why does this customer have access? + +and: + +> Why does this customer not have access? + +without reading raw provider payloads. + +--- + +## Dashboard Work Package 5: Identity Conflict + +Implement: + +- conflict list +- affected aliases +- affected purchase lineage +- candidate Customers +- evidence +- current safety state +- authorized resolution action where approved +- audit history +- replay status +- cache invalidation guidance + +Do not offer a one-click heuristic merge. + +--- + +## Dashboard Work Package 6: Product Grant Versions + +Implement: + +- grant list +- version history +- effective dates +- prospective or approved retroactive mode +- impacted Products +- impacted Entitlements +- impact preview +- create new version +- shadow projection requirement +- publish +- audit + +Do not edit a published Grant Version in place. + +--- + +## Dashboard Work Package 7: Replay and Shadow Projection + +Implement: + +- select customer or subscription +- select projection-rule version +- replay +- job status +- comparison +- no-change result +- changed state +- shadow sample +- difference summary +- unexpected changes +- promotion readiness +- permission checks +- audit + +Do not promote automatically. + +--- + +## Dashboard Work Package 8: Restore and Synchronization + +Implement: + +- start restore or sync +- provider +- Application +- Environment +- current customer +- job status +- validation pending +- Product unresolved +- identity unresolved +- completed +- retry +- diagnostics +- recovery actions + +Explain the distinction between: + +- native provider restore +- server validation +- authoritative Mosaic projection + +--- + +## Dashboard Work Package 9: Webhook Destinations + +Implement: + +- destination list +- create destination +- URL +- event types +- one-time signing secret +- test +- rotate secret +- disable +- delete where safe +- last success +- last failure +- delivery statistics +- permission states +- audit summary + +Do not re-display signing secrets. + +--- + +## Dashboard Work Package 10: Webhook Deliveries + +Implement: + +- event list +- event type +- Billing Customer +- Snapshot version +- destination +- attempts +- response codes +- next retry +- exhausted state +- replay +- safe response excerpt +- correlation ID +- filters +- diagnostics + +Do not expose sensitive destination response data beyond accepted limits. + +--- + +## Dashboard Work Package 11: Projection Health + +Show: + +- current backlog +- stale projections +- failed projections +- identity conflicts +- unknown Entitlements +- Product resolution conflicts +- restore backlog +- webhook backlog +- shadow-run status +- lock contention +- rule versions +- links to runbooks + +--- + +# Stage 2 Validation + +After each bounded work package, run relevant: + +- Go formatting +- Go static checks +- Goose migration checks +- backend unit and integration tests +- worker tests +- dashboard formatting +- dashboard lint +- dashboard type checks +- dashboard tests +- OpenAPI generation validation +- contract fixture validation + +Follow minimum sufficient testing. + +Do not add tests merely because files are new. + + +# Stage 3: SDK Authoritative Entitlement Synchronization + +Use exactly: + +1. `mosaic-flutter` +2. `mosaic-ios` +3. `mosaic-android` + +Each agent owns only its SDK, platform adapter modules, example applications, tests, and documentation. + +Do not modify the canonical Authoritative Entitlement Contract. + +--- + +# Shared SDK Requirements + +Each SDK must support: + +- Customer Access Token configuration +- optional approved anonymous installation credential +- authoritative Entitlement Snapshot fetch +- ETag or Snapshot version +- `304 Not Modified` +- Snapshot validation +- identity binding +- atomic cache replacement +- last-known-valid Snapshot +- issued-at +- `as_of` +- refresh-after +- valid-until +- cache state +- bounded offline policy +- manual refresh +- lifecycle-aware refresh +- refresh deduplication +- restore and sync coordination +- Entitlement listeners or streams +- subscription-summary access +- safe diagnostics +- logout and identity change +- customer-token expiry +- customer-token refresh handoff +- unsupported-contract handling +- no cross-customer cache leakage + +The SDK must not: + +- grant server-side access +- use the public SDK key to query arbitrary customers +- treat local provider state as authoritative Mosaic state +- overwrite a newer Snapshot with an older Snapshot +- extend offline access indefinitely +- expose provider secrets +- block purchase completion on Entitlement sync +- silently convert unknown to inactive +- fetch provider APIs directly for every Entitlement check + +--- + +# Shared SDK Public API Shape + +The actual language-specific API should remain idiomatic. + +Conceptually support: + +```text +configure customer authentication +identify or change customer +refresh Entitlements +read current Entitlement Snapshot +check one Entitlement +observe Entitlement changes +inspect cache state +start restore and authoritative sync +clear customer state on logout +``` + +## Entitlement Result + +Do not return only a boolean. + +A check should expose: + +- state +- Snapshot version +- `as_of` +- cache state +- source summary +- effective dates +- unknown or unavailable reason +- whether refresh is recommended +- whether server verification is required + +Example conceptual states: + +```text +active +inactive +unknown +unavailable +``` + +## Snapshot Listener + +Support an observable mechanism: + +- Dart stream or listener +- Swift async sequence, observation, or documented callback +- Kotlin Flow or documented callback + +Emit only accepted Snapshot changes. + +Do not emit a change for a rejected older or malformed Snapshot. + +--- + +# Customer Authentication in SDKs + +## Token Provider + +Prefer a callback or provider abstraction through which the host application supplies a current Customer Access Token. + +The SDK should support: + +- initial token +- refresh callback +- expiry awareness +- token replacement +- unauthorized response handling +- token-clear on logout +- bounded concurrent refresh +- safe diagnostics + +Do not embed the application’s secret server key. + +## Token Refresh + +When the server returns unauthorized because the token expired: + +1. invoke the accepted host token-refresh callback +2. avoid parallel refresh storms +3. retry once according to policy +4. preserve current cache +5. expose unavailable if refresh fails +6. do not silently switch customers + +## Identity Binding + +A Snapshot response must match the expected Billing Customer identity or accepted opaque subject. + +If it does not: + +- reject the Snapshot +- preserve current safe cache according to policy +- clear cache if cross-customer exposure is possible +- emit high-severity diagnostic +- do not show the mismatched Entitlements + +--- + +# SDK Cache Requirements + +## Storage + +Use accepted platform persistence. + +Protect sensitive metadata where appropriate. + +The cache should include: + +- contract version +- Billing Customer binding +- Snapshot version +- issued-at +- `as_of` +- refresh-after +- valid-until +- Entitlement entries +- checksum or accepted integrity metadata +- write timestamp + +Do not store access tokens in ordinary unprotected preferences if a secure alternative is required. + +## Atomicity + +Cache writes must be atomic. + +If a write fails: + +- preserve prior cache +- do not leave a partial Snapshot +- emit diagnostics +- continue with prior accepted state where safe + +## Monotonicity + +Reject a Snapshot when: + +- version is older than current +- `as_of` regresses unexpectedly +- customer binding differs +- contract version is unsupported +- checksum is invalid +- required fields are missing + +A lower version may be accepted only through an explicit reset or Environment change. + +## Identity Change + +When the host changes Billing Customer: + +- stop in-flight refresh for prior customer +- clear or isolate prior customer cache +- replace token provider context +- begin new customer sync +- avoid showing prior customer Entitlements during transition +- emit an explicit loading or unavailable state + +Do not keep old active Entitlements visible for a new customer. + +## Logout + +On logout: + +- clear current Customer Access Token +- clear or isolate identified-customer cache +- reset listeners +- preserve installation identity according to Phase 6 policy +- return to approved anonymous or no-customer state +- avoid leaking previous customer access + +--- + +# Offline Behaviour + +Implement the owner-approved policy exactly. + +## Fresh Cache + +When before `refresh_after`: + +- return cached authoritative state +- avoid unnecessary network call +- permit background refresh only according to policy + +## Refresh Recommended + +Between `refresh_after` and `valid_until`: + +- return cached state +- initiate or recommend refresh +- expose cache freshness +- avoid blocking UI + +## Expired Cache + +After `valid_until`: + +- apply approved strict, bounded-grace, or server-only policy +- preserve last Snapshot for diagnostics +- do not pretend it is current +- return unknown or stale-active according to approved policy +- never extend indefinitely + +## Client Clock + +Use server-issued timestamps. + +Handle client clock skew through accepted tolerance. + +If the client clock is clearly invalid: + +- expose diagnostics +- prefer conservative policy +- avoid indefinite access + +--- + +# Restore and Authoritative Sync in SDKs + +Provide a high-level operation conceptually similar to: + +```text +restore purchases +→ native provider sync +→ submit transaction observations +→ wait or poll for validation +→ refresh authoritative Entitlement Snapshot +→ return combined result +``` + +The result should distinguish: + +- native restore completed +- server validation pending +- authoritative Entitlements updated +- nothing found +- identity unresolved +- Product unresolved +- provider unavailable +- server unavailable +- failed + +Do not collapse all stages into one ambiguous boolean. + +## Purchase Completion Refresh + +After a successful local provider purchase: + +- submit the accepted transaction observation +- do not block the provider purchase result on network delivery +- trigger an Entitlement refresh +- expose validation pending if server state is not updated yet +- preserve local provider result separately +- avoid granting indefinite authoritative access before server validation + +The host application may optimistically update UI according to its own policy, but Mosaic’s authoritative Entitlement result remains pending until server projection commits. + +--- + +# Flutter Agent + +Implement: + +- Dart Authoritative Entitlement models +- Customer Access Token provider +- Entitlement sync client +- ETag support +- persistent cache +- atomic replacement +- cache states +- offline policy +- identity binding +- logout +- refresh deduplication +- retry +- Entitlement stream +- check API +- restore-and-sync API +- purchase-triggered refresh +- diagnostics +- example application +- documentation + +Use idiomatic Dart. + +Do not block the UI isolate. + +Reuse accepted networking and storage abstractions. + +Do not create a second identity system. + +--- + +# iOS Agent + +Implement: + +- Swift Authoritative Entitlement models +- Customer Access Token provider +- async refresh +- ETag +- Keychain or accepted secure storage for token material +- atomic Snapshot persistence +- offline policy +- identity binding +- logout +- application lifecycle refresh +- async sequence, Observation, or accepted listener +- check API +- StoreKit restore and authoritative sync +- purchase-triggered refresh +- diagnostics +- SwiftUI example +- documentation + +Use Swift concurrency. + +Do not block the main actor. + +Respect iOS background limitations. + +Do not claim background sync is guaranteed. + +--- + +# Android Agent + +Implement: + +- Kotlin Authoritative Entitlement models +- Customer Access Token provider +- coroutine refresh +- ETag +- accepted protected storage for token material +- atomic Snapshot persistence +- offline policy +- identity binding +- logout +- lifecycle-aware refresh +- Flow or accepted listener +- check API +- Google Play restore or active-purchase recovery and authoritative sync +- purchase-triggered refresh +- diagnostics +- Compose example +- documentation + +Use Kotlin coroutines. + +Do not block the main thread. + +Respect Android background-execution limits. + +--- + +# Stage 3 Cross-Platform Conformance + +Use shared Authoritative Entitlement Contract fixtures. + +Go, Dart, Swift, and Kotlin must agree on: + +- state names +- unknown and unavailable semantics +- Snapshot version +- issued-at +- `as_of` +- refresh-after +- valid-until +- source summaries +- customer binding +- older Snapshot rejection +- unsupported contract rejection +- multiple sources +- permanent source +- refund of one source +- cache-state interpretation +- token-expiry handling outcomes + +Do not accept semantic divergence. + +--- + +# Stage 4: Integrated Demonstration + +Use sandbox and test data only. + +Do not use production customer data. + +## Demonstration 1: Initial Subscription + +1. create Billing Customer +2. issue Customer Access Token +3. complete validated Apple or Google purchase +4. associate fact with Billing Customer +5. project Subscription Snapshot +6. apply Product-to-Entitlement Grant Version +7. create Customer Entitlement Snapshot +8. fetch through server API +9. fetch through Flutter, iOS, and Android SDKs +10. confirm active Entitlement +11. inspect source explanation +12. inspect signed webhook delivery + +## Demonstration 2: Renewal + +1. ingest validated renewal +2. project new period +3. preserve prior Snapshot +4. extend Entitlement effective end +5. deliver state-change webhook according to policy +6. refresh SDKs +7. confirm monotonic Snapshot version + +## Demonstration 3: Cancellation Without Immediate Revocation + +1. ingest validated auto-renew-disabled fact +2. show renewal intent off +3. keep access active through period end +4. show scheduled expiration +5. confirm Entitlement remains active +6. confirm dashboard explanation +7. confirm webhook semantics + +## Demonstration 4: Expiration + +1. reach validated period end with no renewal +2. project expired state +3. remove subscription Entitlement Source +4. recompute Customer Entitlements +5. emit access-change webhook +6. refresh SDKs +7. confirm inactive state + +## Demonstration 5: Multiple Sources + +1. create active subscription source +2. create valid lifetime purchase source +3. grant same Entitlement +4. revoke or expire subscription +5. confirm Entitlement remains active through lifetime source +6. inspect both source histories + +## Demonstration 6: Refund or Revocation + +1. ingest validated refund or revocation +2. reproject affected source +3. preserve unrelated sources +4. update Entitlement state +5. deliver high-priority webhook +6. refresh SDKs +7. confirm history remains intact + +## Demonstration 7: Grace and Recovery + +1. ingest verified grace-period fact +2. apply approved access policy +3. show grace end +4. ingest payment recovery +5. project active state +6. preserve Timeline +7. refresh SDKs + +## Demonstration 8: Out-of-Order Fact + +1. project expiration +2. ingest late renewal with earlier effective time +3. detect out-of-order fact +4. invalidate checkpoint +5. reproject deterministically +6. create new authoritative Snapshot +7. preserve prior Snapshots +8. confirm expected access + +## Demonstration 9: Upgrade or Downgrade + +1. start on Product A +2. ingest validated Product transition +3. preserve old Product history +4. apply Product B grants at effective time +5. avoid double-grant errors +6. show Timeline and webhooks +7. confirm provider mapping history + +## Demonstration 10: Restore Across Devices + +1. identify same Billing Customer on two devices +2. purchase on device A +3. validate and project +4. refresh device B +5. confirm same Snapshot version +6. run restore on a third device +7. submit observations +8. confirm duplicate-safe validation +9. refresh authoritative state + +## Demonstration 11: Offline Cache + +1. fetch fresh Snapshot +2. disconnect network +3. read active Entitlement from cache +4. advance through refresh-recommended state +5. reach approved expiry policy +6. confirm state changes according to policy +7. restore network +8. fetch newer Snapshot +9. confirm atomic cache update + +## Demonstration 12: Identity Conflict + +1. associate one purchase lineage with Billing Customer A +2. submit conflicting trusted identity evidence for Billing Customer B +3. detect conflict +4. prevent double grant +5. preserve last accepted authoritative state according to policy +6. show dashboard conflict +7. resolve through approved workflow +8. replay projection +9. invalidate affected SDK caches + +## Demonstration 13: Webhook Retry + +1. commit Entitlement change +2. create webhook event +3. fail destination +4. retry with same event ID +5. recover destination +6. deliver successfully +7. inspect attempt history +8. confirm state never rolled back + +## Demonstration 14: Replay and Shadow Projection + +1. replay one customer with current rule version +2. confirm deterministic checksum +3. run candidate rule in shadow +4. compare differences +5. leave authoritative pointer unchanged +6. approve or reject candidate explicitly + +--- + +# Stage 5: Product, UX, Protocol, and Quality Review + +Use exactly: + +1. `mosaic-product` +2. `mosaic-ux` +3. `mosaic-protocol` +4. `mosaic-quality` + +All four are read-only. + +--- + +## Product Review + +Confirm: + +- Phase 9B remained within scope +- Mosaic Billing remains optional +- validated facts remain the source +- authoritative state is explainable +- Product and Entitlement boundaries remain correct +- customer identity is explicit +- unknown is not inactive +- cancellation does not revoke early +- multiple sources behave correctly +- offline policy is bounded +- server APIs are authoritative +- migration and cutover remain Phase 9C +- manual paid-access grants remain excluded +- financial reporting remains excluded + +Return: + +- Approve +- Approve with changes +- Reject +- Owner decision required + +--- + +## UX Review + +Review: + +- Billing Customer search +- customer detail +- subscription timeline +- Entitlement explanation +- renewal intent +- grace and billing retry +- expiration +- refund and revocation +- multiple sources +- identity conflict +- restore +- replay +- shadow projection +- grant versions +- webhook destinations +- webhook deliveries +- projection health +- unknown and unavailable language +- error recovery +- dead ends + +Do not approve a UI that: + +- describes cancellation as immediate expiry +- hides uncertainty +- hides contributing sources +- offers unsafe manual state override +- conflates provider facts with customer access +- conflates restore with immediate validation + +--- + +## Protocol Review + +Confirm: + +- Authoritative Entitlement Contract is versioned +- Customer Access Token contract is documented +- Billing State Webhook Contract is versioned +- state semantics are provider-independent +- unknown and unavailable are distinct +- source summaries are safe +- Snapshot monotonicity is explicit +- older Snapshot rejection is explicit +- customer binding is explicit +- existing Paywall, Commerce, Placement, Analytics, Experiment, and Billing Ingestion contracts remain compatible +- no provider secrets appear in fixtures + +--- + +## Quality Review + +Review: + +- PostgreSQL migrations +- tenant isolation +- Environment isolation +- append-only fact use +- customer association +- identity conflict +- Product mapping history +- event ordering +- out-of-order reprojection +- duplicate handling +- state machine +- cancellation semantics +- grace semantics +- billing-retry semantics +- pause semantics +- refund +- revocation +- upgrades and downgrades +- one-time purchases +- multiple sources +- grant versioning +- projection determinism +- transaction boundaries +- locks +- checkpoints +- replay +- shadow projection +- server APIs +- token security +- SDK cache +- offline expiry +- logout and identity change +- restore +- webhook signing +- webhook SSRF +- webhook retries +- observability +- backup implications +- minimum sufficient tests +- absence of Phase 9C migration work + +Return findings ordered by severity with exact paths and symbols. + +--- + +# Final Fix Pass + +After reviews: + +1. classify findings +2. assign backend findings to `mosaic-backend` +3. assign dashboard findings to `mosaic-dashboard` +4. assign Flutter findings to `mosaic-flutter` +5. assign iOS findings to `mosaic-ios` +6. assign Android findings to `mosaic-android` +7. assign contract findings to `mosaic-protocol` +8. reject speculative feature additions +9. rerun affected checks +10. rerun complete Phase 9B conformance +11. request one targeted final quality review + +Limit the fix-and-review cycle to two rounds. + +If blocking issues remain after two rounds, classify Phase 9B as rejected pending fixes. + +--- + +# Minimum Sufficient Testing Policy + +Follow: + +```text +docs/architecture/conventions/testing.md +``` + +Do not add tests merely because code is new. + +Do not recreate tests for: + +- PostgreSQL internals +- pgx +- Goose +- Chi +- JWT or cryptography-library internals +- Keychain internals +- Android storage internals +- provider SDK internals +- HTTP client internals +- background-job library internals + +The following identifies risks, not one required test per bullet. + +## Projection Determinism Risk Coverage + +Protect: + +- same facts and rule versions produce same Subscription Snapshot +- same sources and grant versions produce same Entitlement Snapshot +- duplicate facts do not duplicate access +- out-of-order fact triggers correct reprojection +- checkpoint and full replay agree +- no-change replay does not emit duplicate access-change webhook +- older projection cannot overwrite newer projection +- rule version is recorded + +## State Transition Risk Coverage + +Protect: + +- initial purchase grants access +- renewal extends state +- cancellation preserves access until effective end +- expiration removes only affected source +- grace follows approved policy +- billing retry follows approved policy +- pause follows provider and approved policy +- refund affects correct source +- revocation affects correct source +- upgrade applies new Product grants at effective time +- downgrade remains scheduled until effective time +- one-time purchase remains active until validated invalidation + +## Entitlement Aggregation Risk Coverage + +Protect: + +- multiple sources aggregate correctly +- revoking one source preserves another valid source +- permanent source produces no misleading finite expiry +- unknown evidence remains unknown +- Product grant version is selected deterministically +- grant change follows prospective or approved retroactive policy +- archived Product remains historically resolvable +- Entitlement Snapshot source links are complete + +## Identity Risk Coverage + +Protect: + +- public SDK key cannot query arbitrary customer +- Customer Access Token cannot cross Project or Environment +- Customer Access Token cannot access another customer +- alias conflict does not double grant +- identity change does not leak old cache +- logout clears identified-customer state +- historical events are not rewritten +- reassociation is audited and replayed + +## Concurrency Risk Coverage + +Protect: + +- concurrent facts do not create conflicting current Snapshots +- current pointer and Snapshot commit atomically +- webhook event creation commits with state +- lock failure retries safely +- projection job retry is idempotent +- simultaneous refreshes do not corrupt SDK cache + +## SDK Cache Risk Coverage + +Protect: + +- cache survives SDK reconstruction +- older Snapshot is rejected +- malformed Snapshot preserves prior cache +- wrong-customer Snapshot is rejected +- refresh-after and valid-until follow policy +- offline expiry follows approved behaviour +- token expiry invokes accepted refresh +- failed token refresh preserves safe state +- identity change prevents cache leakage +- `304` preserves cache + +## Webhook Risk Coverage + +Protect: + +- signature verifies +- secret rotation works +- retries keep stable event ID +- webhook failure does not roll back state +- older webhook can be identified by Snapshot version +- exhausted delivery remains replayable +- SSRF policy rejects unsafe destination +- cross-tenant replay fails + +## Security Risk Coverage + +Protect: + +- provider identifiers are redacted where required +- customer aliases are protected +- Project and Environment isolation holds +- token secrets are not logged +- signing secrets are not re-displayed +- sensitive diagnostics are permission-controlled +- no direct handler mutates Entitlements +- no Phase 9C migration endpoint exists + +Every agent report must explain: + +- tests added +- risk protected +- existing tests reused +- checks run +- unavailable provider or device checks +- why no new test was added where none was necessary + +--- + +# Required Phase 9B Acceptance Criteria + +Phase 9B is complete only when all of the following are true. + +## Preconditions and Boundaries + +- Phase 9A remains accepted. +- PostgreSQL remains the runtime system of record. +- no production in-memory fallback exists. +- Goose migrations exist for Phase 9B persistence. +- Phase 9A facts remain immutable. +- Phase 9B does not mutate raw provider facts. +- Mosaic Billing remains optional. +- RevenueCat, StoreKit 2, Google Play Billing, and custom providers remain usable without Mosaic Billing. +- no Phase 9C migration, bulk import, dual-run, or cutover feature was introduced. +- no financial reporting was introduced. +- no manual paid-access grant system was introduced. + +## Billing Customer and Identity + +- Billing Customers are Project-scoped. +- Customer aliases are typed. +- aliases are protected. +- alias history is preserved. +- customer association uses explicit trusted evidence. +- no heuristic identity merge exists. +- unresolved identity does not grant access. +- conflicting identity does not double grant. +- alias conflict is visible and auditable. +- reassociation follows an accepted protected workflow. +- historical facts are not rewritten after identity change. + +## Purchase Lineage + +- Apple purchase chains resolve deterministically. +- Google purchase chains resolve deterministically. +- one-time purchase lineages are supported. +- Product mapping history is respected. +- cross-Project lineages are rejected. +- sandbox and production remain isolated. +- lineages are not merged by Product similarity. +- supersession is explicit. +- historical lineages remain visible. + +## Subscription Projection + +- Subscription Snapshots are immutable. +- projection versions are monotonic. +- projection-rule version is recorded. +- state axes are explicit. +- cancellation does not revoke access early. +- renewal extends the accepted period. +- expiration is deterministic. +- grace period follows the approved policy. +- billing retry follows the approved policy. +- pause follows the approved policy. +- refunds are effective at validated provider time. +- revocations remove the affected source. +- upgrades and downgrades preserve history. +- one-time non-consumables project correctly. +- duplicate facts do not duplicate state. +- out-of-order facts trigger deterministic reprojection. +- checkpoint replay matches full replay. +- projection failures do not create partial current state. +- unknown remains distinct from inactive. +- unavailable remains distinct from customer state. + +## Product Grants and Entitlements + +- Product-to-Entitlement Grant Versions are immutable. +- grant effective intervals are valid. +- prospective or retroactive policy is explicit. +- historical grant meaning is preserved. +- Entitlement Sources link to Snapshots and Products. +- multiple sources aggregate correctly. +- one source revocation does not remove unrelated sources. +- permanent sources do not show false expiry. +- Customer Entitlement Snapshots are immutable. +- Entitlement Snapshot versions are monotonic. +- current Subscription and Entitlement pointers update atomically. +- every Entitlement state is explainable. +- unknown evidence remains visible. +- access changes produce a deterministic change set. +- no handler or UI action directly mutates authoritative Entitlements. + +## Transactions and Concurrency + +- one accepted transaction commits subscription state, Entitlement state, pointers, checkpoint, and webhook events. +- external calls do not occur inside the projection transaction. +- projection jobs are idempotent. +- per-lineage or per-customer serialization is enforced. +- concurrent facts do not create conflicting current Snapshots. +- current state cannot point to incomplete data. +- no-change projection avoids duplicate logical webhooks. +- worker restart does not lose accepted projection work. + +## Replay and Rule Changes + +- replay preserves prior Snapshots. +- replay is deterministic. +- replay records actor and version. +- shadow projection does not change authoritative state. +- shadow differences are visible. +- access-affecting rule promotion requires explicit approval. +- projection-rule rollback is documented. +- Product grant changes can be impact-previewed. +- retroactive changes cannot occur silently. + +## Server APIs and Authentication + +- trusted server Entitlement API works. +- server responses include Snapshot version and `as_of`. +- Entitlement checks return explicit states, not bare booleans. +- Customer Access Tokens are short lived. +- tokens are Project-scoped. +- tokens are Environment-scoped. +- tokens are customer-scoped. +- tokens are audience restricted. +- token keys rotate. +- token values are absent from logs. +- a public SDK key alone cannot query arbitrary customers. +- anonymous mode, if supported, is explicitly approved and isolated. +- rate limits are applied. + +## SDK Synchronization + +- Flutter fetches authoritative Entitlement Snapshots. +- iOS fetches authoritative Entitlement Snapshots. +- Android fetches authoritative Entitlement Snapshots. +- SDKs support ETag or Snapshot version. +- `304` preserves cache. +- caches are atomically replaced. +- older Snapshots are rejected. +- malformed Snapshots preserve prior cache. +- wrong-customer Snapshots are rejected. +- cache freshness states are visible. +- offline policy is enforced. +- offline access is not indefinite unless explicitly approved. +- token expiry follows accepted refresh behaviour. +- logout prevents prior customer cache leakage. +- identity change prevents cache leakage. +- Entitlement listeners emit only accepted changes. +- SDK state does not grant server-side access. +- local provider success remains distinct from authoritative server validation. +- restore returns explicit multi-stage status. + +## Webhooks + +- webhook events are created only after committed state. +- webhook events have stable IDs. +- webhook payloads include Snapshot versions. +- webhook payloads contain no secrets. +- signatures are verifiable. +- signing-key rotation works. +- delivery is at least once. +- retries use bounded backoff. +- delivery attempts are append-only. +- webhook failure does not roll back state. +- exhausted delivery is visible and replayable. +- destination SSRF protections work. +- cross-tenant webhook access fails. +- old events can be ordered using Snapshot version. + +## Dashboard and Explainability + +- Billing Customer search works through approved identifiers. +- customer detail shows current projection. +- subscription Timeline is complete. +- renewal intent is visible. +- grace and billing retry are visible. +- refunds and revocations are visible. +- Product transitions are visible. +- Entitlement sources are visible. +- users can answer why access is active or inactive. +- unknown and unavailable are visible. +- identity conflicts are visible. +- restore jobs are visible. +- replay and shadow projection are visible. +- Product Grant Version history is visible. +- webhook destination and delivery status are visible. +- projection health is visible. +- no unsafe manual Entitlement override exists. +- no Phase 9C migration UI exists. + +## Security, Privacy, and Operations + +- customer aliases are protected. +- customer tokens are protected. +- webhook secrets are encrypted. +- secrets are absent from logs and diagnostics. +- tenant isolation is enforced. +- Environment isolation is enforced. +- SSRF protections are enforced. +- sensitive endpoints are rate limited. +- audit events exist for high-risk actions. +- OpenTelemetry covers projection and webhook paths. +- operational alerts are defined. +- backup and restore include new Phase 9B tables. +- retention and deletion semantics are documented. +- no known critical double-grant path remains. +- no known critical accidental-revocation path remains. +- relevant checks pass. +- unavailable device or provider checks are documented honestly. + +--- + +# Required Phase 9B Demonstration + +The complete demonstration should show: + +```text +Create Billing Customer +→ issue Customer Access Token +→ complete validated subscription purchase +→ associate purchase lineage +→ project active Subscription Snapshot +→ apply versioned Product grants +→ create authoritative Entitlement Snapshot +→ fetch through trusted server API +→ fetch through Flutter, iOS, and Android +→ deliver signed access-change webhook +→ disable auto-renew +→ confirm access remains active +→ expire subscription +→ confirm Entitlement becomes inactive +→ add lifetime purchase source +→ renew and later revoke subscription source +→ confirm lifetime source preserves access +→ enter grace period +→ apply approved access policy +→ recover payment +→ process out-of-order renewal +→ reproject deterministically +→ upgrade Product +→ apply new grants at effective time +→ run restore on another device +→ synchronize same customer state +→ disconnect network +→ use bounded SDK cache +→ expire cache according to policy +→ restore network and refresh +→ trigger identity conflict +→ prevent double grant +→ resolve through approved workflow +→ replay projection +→ run candidate rule in shadow +→ compare differences without changing state +→ fail webhook delivery +→ retry with same event ID +→ recover destination +→ confirm complete history and audit trail +``` + +The one-minute demo should focus on: + +```text +Validated purchase +→ authoritative Pro Entitlement +→ cancellation keeps access through period end +→ expiration removes subscription source +→ lifetime source keeps access active +→ all three SDKs synchronize the same Snapshot +→ signed webhook reports the change +``` + +--- + +# Phase 9B Review Document + +Create: + +```text +docs/reviews/phase-9b.md +``` + +Use the following required structure. + +## Status + +Choose one: + +- Accepted +- Accepted with tracked follow-ups +- Rejected pending fixes + +## Baseline + +Document: + +- base commit +- branch +- worktree +- GA tag +- accepted Phase 9A review +- Authoritative Entitlement Contract version +- Customer Access Token contract version +- Billing State Webhook Contract version +- projection-rule version +- Product grant policy +- offline access policy +- grace-period policy +- billing-retry policy +- pause policy +- customer-association policy +- locking strategy +- webhook-signing strategy +- official Apple and Google documentation used + +## Completed Deliverables + +Group by: + +- Billing Customers +- customer aliases +- association evidence +- purchase lineages +- subscription instances +- one-time purchases +- state machine +- ordering +- Snapshots +- Timeline +- projection checkpoints +- projection-rule versions +- Product Grant Versions +- Entitlement Sources +- Customer Entitlement Snapshots +- access APIs +- customer tokens +- restore +- replay +- shadow projection +- webhooks +- dashboard +- Flutter +- iOS +- Android +- migrations +- OpenAPI +- observability +- security +- tests + +## Product Review + +Include: + +- validated demand +- phase boundaries +- Mosaic Billing optionality +- subscription-state value +- Entitlement value +- offline policy +- Product grant policy +- deferred Phase 9C migration +- deferred manual grants +- owner decisions + +## UX Review + +Include: + +- customer search +- subscription detail +- Timeline +- Entitlement explanation +- cancellation +- grace +- billing retry +- refund +- revocation +- multiple sources +- identity conflict +- restore +- replay +- shadow projection +- webhook management +- projection health +- unknown and unavailable language +- dead ends +- task-completion findings + +## Engineering Review + +Include: + +- migrations +- fact immutability +- lineage resolution +- ordering +- state machine +- projection determinism +- concurrency +- checkpoints +- replay +- Product grants +- Entitlement aggregation +- transaction boundaries +- token service +- SDK synchronization +- offline cache +- restore +- webhooks +- worker recovery +- performance +- tests +- unavailable checks +- known defects + +## Protocol Review + +Confirm: + +- Authoritative Entitlement Contract is versioned +- Customer Access Token contract is documented +- Billing State Webhook Contract is versioned +- state semantics are provider-independent +- unknown and unavailable are distinct +- Snapshot monotonicity is explicit +- customer binding is explicit +- old or malformed Snapshots fail safely +- existing contracts remain compatible +- no provider secrets appear in fixtures + +## Security Review + +Confirm: + +- customer aliases are protected +- customer tokens are protected +- Project and Environment scopes are enforced +- public SDK key cannot query arbitrary customers +- webhook secrets are encrypted +- webhook signatures verify +- SSRF protections exist +- cross-tenant access fails +- sensitive identifiers are absent from logs +- high-risk actions are audited +- no direct Entitlement mutation endpoint exists + +## State-Machine Review + +Include: + +- supported states +- state axes +- provider mappings +- transition tables +- cancellation semantics +- expiration +- grace +- billing retry +- pause +- refund +- revocation +- upgrade +- downgrade +- one-time purchase +- out-of-order facts +- unknown facts +- unsupported provider states +- projection-rule version + +## Entitlement Review + +Include: + +- Product grant versioning +- prospective or retroactive policy +- multiple sources +- permanent source +- source end +- unknown evidence +- effective dates +- explanations +- Snapshot versions +- webhook changes +- replay consistency + +## SDK Review + +Include: + +- customer authentication +- Snapshot sync +- ETag +- cache atomicity +- cache monotonicity +- offline policy +- token refresh +- logout +- identity change +- restore +- listeners +- cross-platform conformance +- unavailable checks + +## Webhook Review + +Include: + +- event types +- stable event IDs +- signatures +- retries +- ordering +- replay +- exhausted state +- destination security +- audit history + +## Phase Boundary Review + +Confirm: + +- no RevenueCat migration exists +- no historical customer import exists +- no dual-run cutover exists +- no bulk repair migration tool exists +- no financial reporting exists +- no manual paid-access grants exist +- no Phase 9C work was introduced + +## Demo Review + +State whether: + +- initial purchase projection succeeds +- renewal succeeds +- cancellation preserves access +- expiration removes the correct source +- multiple-source Entitlement aggregation succeeds +- refund or revocation succeeds +- grace and recovery succeed +- out-of-order reprojection succeeds +- Product transition succeeds +- restore and cross-device sync succeed +- offline cache follows policy +- identity conflict prevents double grant +- webhook retry succeeds +- replay is deterministic +- shadow projection leaves state unchanged +- the one-minute demonstration succeeds + +## Decision + +Choose one: + +- Phase 9B accepted; proceed to Phase 9C +- Phase 9B accepted with tracked follow-ups; proceed to Phase 9C +- Phase 9B rejected pending fixes + +Stop after producing the Phase 9B review. + +Do not begin Phase 9C. + +Do not merge automatically. + +Do not tag automatically. diff --git a/packages/test-fixtures/README.md b/packages/test-fixtures/README.md index 02c80802..238f21c9 100644 --- a/packages/test-fixtures/README.md +++ b/packages/test-fixtures/README.md @@ -55,6 +55,89 @@ platform integer or emitted as a JSON number. The `beyond-double-precision` and `uint64-max` vectors exist to fail loudly when an implementation parses instead of carrying. +## Authoritative Entitlement vectors + +Three files for [Authoritative Entitlement Contract v1](../../docs/protocol/authoritative-entitlement-v1.md), +built by `src/build-entitlement-reference-vectors.mjs`. These are the derivations +Go, Dart, Swift, Kotlin, and the protocol validator must agree on exactly, and +that a schema can constrain the *shape* of but not the *value* of. Two SDKs can +both be schema-valid and still disagree about whether a cached snapshot should be +replaced, or whether an offline cache has expired, and the user experience of +that disagreement is losing access they paid for. + +### `src/entitlement-snapshot-digest-vectors.json` + +The canonical serialization that `contentDigest` and `checksum` are computed +over: minified JSON, object members sorted by UTF-16 code unit at every depth, +**array order preserved**, absent members omitted, `null` never emitted, +timestamps at exactly three fractional digits, integers in shortest decimal form, +minimal string escaping. + +Each vector carries `payload`, `canonicalSerialization`, `canonicalByteLength`, +and the expected `digest`. Assert the serialization first — if it does not match, +the digest comparison would fail for a misleading reason. + +The `non-ascii-safe-text` vector is the one that matters: a UTF-16 or Latin-1 +encoding agrees with UTF-8 on every ASCII input, so an ASCII-only suite passes +while the implementation is wrong. The `absent-optional` / `present-optional` +pair proves absent and present are different states, which is why `null` is +forbidden rather than merely discouraged. The `array-order-is-preserved` vector +carries a deliberately unsorted array: a serializer that sorted it would silently +repair a document the semantic validator exists to reject. + +`canonical-fixture-snapshot` is the payload of +`protocol/fixtures/authoritative-entitlement/v1/snapshots/active-subscription.json`, +and a test asserts its digest equals that fixture's `contentDigest`. + +### `src/entitlement-cache-decision-vectors.json` + +`(cached, incoming) -> accept | reject` with a reason, a cache action +(`preserve`, `clear`, `replace`), and the resulting access state. The +`evaluationOrder` is normative: binding is checked **before** version, because +snapshot versions are monotonic per Environment and a staging snapshot +legitimately starts at 1. + +No vector ever resolves to `inactive`, and a test enforces that. A binding +mismatch is the only rejection that **clears** rather than preserves the cache. + +### `src/entitlement-freshness-vectors.json` + +`(issuedAt, asOf, refreshAfter, validUntil, staleGraceSeconds, deviceNow, skew=60s)` +-> `fresh` | `refresh_recommended` | `stale_within_grace` | `expired`, including +backwards and forwards clocks. + +`backwards-clock-before-issued-at` is the security-relevant one. A naive +implementation computes a negative cache age, concludes "fresh", and hands +unlimited offline access to anyone willing to change their device time. The +expected state is `expired`. `backwards-clock-within-skew` guards the opposite +error: ordinary 30-second phone-to-server skew must not trip that path. + +## `src/webhook-signature-vectors.json` + +For [Billing State Webhook Contract v1](../../docs/protocol/billing-state-webhook-v1.md), +built by `src/build-webhook-signature-vectors.mjs`. + +```text +signature = HMAC-SHA256(secret, "v1" + "." + t + "." + eventId + "." + rawBody) +``` + +Each vector carries `secret`, `timestamp`, `eventId`, `rawBody`, the assembled +`signedPayload`, the expected `signature`, and the full `Mosaic-Signature` header +value. The `tampered-body`, `different-event-id`, and `different-timestamp` +vectors must **not** verify against the canonical signature; that is what proves +each component is genuinely covered rather than merely carried alongside. + +`canonical-event-primary-key` signs the bytes of +`protocol/fixtures/billing-state-webhook/v1/events/entitlement-activated.json` +**with the file's trailing newline removed** — the builder applies `trimEnd()` — +and a test asserts the two cannot drift. The trailing newline belongs to the file +on disk, not to a delivery. The raw body must be hashed **as received**: a +verifier that parses and re-serializes, or that trims, gets different bytes and +rejects every genuine delivery. + +`non-ascii-body` and `non-ascii-secret` prove both the payload and the key are +taken as UTF-8 bytes, the secret used verbatim rather than hex- or base64-decoded. + ## Consuming a vector The file is plain JSON with no dependencies, readable from Swift, Kotlin, Dart, @@ -63,26 +146,48 @@ package manager: the SDKs are not npm consumers. ``` packages/test-fixtures/src/billing-reference-vectors.json +packages/test-fixtures/src/entitlement-snapshot-digest-vectors.json +packages/test-fixtures/src/entitlement-cache-decision-vectors.json +packages/test-fixtures/src/entitlement-freshness-vectors.json +packages/test-fixtures/src/webhook-signature-vectors.json ``` ## Regenerating +```bash +npm --prefix packages/test-fixtures run generate +``` + +Or individually: + ```bash node packages/test-fixtures/src/build-billing-reference-vectors.mjs +node packages/test-fixtures/src/build-entitlement-reference-vectors.mjs +node packages/test-fixtures/src/build-webhook-signature-vectors.mjs ``` -Digests are computed by the generator and must never be hand-edited: a -hand-edited digest would assert five implementations against a value no -implementation produces. +Digests and signatures are computed by the generators and must never be +hand-edited: a hand-edited value would assert five implementations against a +result no implementation produces. ## Verification -`protocol/tools/billing-ingestion-validation-v1.test.mjs` recomputes every -digest, checks each vector against the contract's own patterns, and asserts that -the canonical vectors match the values actually carried by -`protocol/fixtures/billing-ingestion/v1/google-client-observation.json` and -`apple-client-observation.json`. The vectors and the canonical fixtures -therefore cannot drift apart. +Every vector family is verified by the protocol test suite, which recomputes each +derivation rather than trusting the committed value, and cross-checks the +canonical vectors against the fixtures they claim to describe. The vectors and +the canonical fixtures therefore cannot drift apart. + +| Vectors | Verified by | +| --- | --- | +| `billing-reference-vectors.json` | `protocol/tools/billing-ingestion-validation-v1.test.mjs` | +| `entitlement-snapshot-digest-vectors.json` | `protocol/tools/authoritative-entitlement-validation-v1.test.mjs` | +| `entitlement-cache-decision-vectors.json` | `protocol/tools/authoritative-entitlement-validation-v1.test.mjs` | +| `entitlement-freshness-vectors.json` | `protocol/tools/authoritative-entitlement-validation-v1.test.mjs` | +| `webhook-signature-vectors.json` | `protocol/tools/billing-state-webhook-validation-v1.test.mjs` | + +The freshness vectors are additionally re-derived from the declared window in the +test rather than compared to a stored answer, so a vector whose expected state +disagrees with its own inputs fails. ```bash npm --prefix protocol test diff --git a/packages/test-fixtures/package.json b/packages/test-fixtures/package.json index ed16bb95..dc8767f7 100644 --- a/packages/test-fixtures/package.json +++ b/packages/test-fixtures/package.json @@ -4,12 +4,16 @@ "private": true, "type": "module", "exports": { - "./billing-reference-vectors.json": "./src/billing-reference-vectors.json" + "./billing-reference-vectors.json": "./src/billing-reference-vectors.json", + "./entitlement-snapshot-digest-vectors.json": "./src/entitlement-snapshot-digest-vectors.json", + "./entitlement-cache-decision-vectors.json": "./src/entitlement-cache-decision-vectors.json", + "./entitlement-freshness-vectors.json": "./src/entitlement-freshness-vectors.json", + "./webhook-signature-vectors.json": "./src/webhook-signature-vectors.json" }, "files": [ "src" ], "scripts": { - "generate": "node src/build-billing-reference-vectors.mjs" + "generate": "node src/build-billing-reference-vectors.mjs && node src/build-entitlement-reference-vectors.mjs && node src/build-webhook-signature-vectors.mjs" } } diff --git a/packages/test-fixtures/src/build-entitlement-reference-vectors.mjs b/packages/test-fixtures/src/build-entitlement-reference-vectors.mjs new file mode 100644 index 00000000..179a0e9c --- /dev/null +++ b/packages/test-fixtures/src/build-entitlement-reference-vectors.mjs @@ -0,0 +1,414 @@ +/** + * Regenerates the three Authoritative Entitlement reference-vector files. + * + * These are the derivations that five implementations -- Go, Dart, Swift, + * Kotlin, and the protocol validator -- must agree on exactly, and that a schema + * can constrain the *shape* of but not the *value* of. Two SDKs can both be + * schema-valid and still disagree about whether a cached snapshot should be + * replaced, or whether an offline cache has expired, and the user experience of + * that disagreement is losing access they paid for. + * + * Digests are computed here, never hand-written. A hand-edited digest would + * assert every implementation against a value no implementation produces. + * + * Run: node packages/test-fixtures/src/build-entitlement-reference-vectors.mjs + * Verified by: protocol/tools/authoritative-entitlement-validation-v1.test.mjs + */ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repository = resolve(here, "../../.."); + +const CONTRACT = "Authoritative Entitlement Contract v1"; +const PROVENANCE = + "Generated cross-implementation reference vectors. Regenerate with " + + "packages/test-fixtures/src/build-entitlement-reference-vectors.mjs; never hand-edit a digest."; + +/** + * The canonical serialization pinned by + * `protocol/compatibility/authoritative-entitlement/v1.json`. + */ +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +const sha256 = (text) => + `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`; + +const write = (name, document) => { + const path = resolve(here, name); + writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`); + return path; +}; + +/* ------------------------------------------------------------ digest vectors */ + +const digestVector = (id, payload, notes) => { + const serialized = canonical(payload); + return { + id, + payload, + canonicalSerialization: serialized, + canonicalByteLength: Buffer.byteLength(serialized, "utf8"), + digest: sha256(serialized), + notes, + }; +}; + +const canonicalFixturePath = + "protocol/fixtures/authoritative-entitlement/v1/snapshots/active-subscription.json"; +const canonicalFixture = JSON.parse( + readFileSync(resolve(repository, canonicalFixturePath), "utf8"), +); +const canonicalFixturePayload = { ...canonicalFixture.payload }; +delete canonicalFixturePayload.contentDigest; + +const digestDocument = { + $comment: PROVENANCE, + contract: CONTRACT, + contractVersion: "1", + compatibilityManifest: "protocol/compatibility/authoritative-entitlement/v1.json", + canonicalSerialization: { + form: "minifiedJsonSortedKeys", + hash: "SHA-256", + inputEncoding: "UTF-8", + output: "sha256_prefixed_lowercase_hex", + rules: [ + "Serialize with no insignificant whitespace: no spaces after ':' or ',', no newlines.", + "Order object members ascending by UTF-16 code unit. Sort at every nesting depth.", + "Preserve array order exactly. Array order is normative in this contract: snapshot entries ascend by entitlementKey and sources ascend by sourceId, so a serializer must never sort or reorder an array.", + "Omit absent members. Never emit null: an absent optional and a null optional are different bytes and therefore different digests, and null is invalid everywhere in this contract.", + "Emit every timestamp with exactly three fractional digits and a literal Z, which is what the schema already requires.", + "Emit integers in shortest decimal form with no exponent, no leading zeros, and no decimal point. This contract contains no non-integer numbers.", + "Escape strings minimally, as JSON requires: only the quotation mark, the reverse solidus, and control characters. Never escape non-ASCII characters into \\u sequences.", + "Remove the excluded member -- contentDigest on a customer entitlement snapshot, checksum on a subscription snapshot -- before serializing. Nothing else is removed.", + ], + excludedMembers: { + customerEntitlementSnapshot: "contentDigest", + subscriptionSnapshot: "checksum", + }, + }, + vectors: [ + digestVector( + "canonical-fixture-snapshot", + canonicalFixturePayload, + `The payload of ${canonicalFixturePath} with contentDigest removed. An implementation ` + + "that reproduces this digest agrees with the canonical fixture, which is the only " + + "agreement that matters at run time.", + ), + digestVector( + "member-order-is-irrelevant", + { zeta: 1, alpha: 2, Mu: 3, _underscore: 4 }, + "Authoring order is discarded; sorting is by UTF-16 code unit, so uppercase sorts " + + "before lowercase and '_' sorts after both. An implementation that sorts " + + "case-insensitively or by locale produces a different digest here and agrees on " + + "every all-lowercase vector.", + ), + digestVector( + "array-order-is-preserved", + { entries: ["pro", "pro_lifetime"], sources: ["b", "a"] }, + "Arrays are never sorted by the serializer. The 'sources' array here is deliberately " + + "out of order: a serializer that sorts it would silently repair a document the " + + "semantic validator is supposed to reject.", + ), + digestVector( + "absent-optional", + { endKnown: true, entitlementKey: "pro" }, + "Paired with null-is-never-emitted and present-optional below. These three prove that " + + "absent, null, and present are three different states.", + ), + digestVector( + "present-optional", + { effectiveEnd: "2026-08-01T09:00:00.000Z", endKnown: true, entitlementKey: "pro" }, + "The same payload with the optional present. Its digest differs from absent-optional, " + + "which is why an implementation must not invent a default for an absent member.", + ), + digestVector( + "non-ascii-safe-text", + { safeSummary: "Abonnement actif jusqu'au 1 août — 続き" }, + "Proves UTF-8 encoding and minimal escaping. A UTF-16 or Latin-1 encoding, or a " + + "serializer that escapes non-ASCII into \\u sequences, produces a different digest " + + "here and agrees on every ASCII vector. This is the vector that actually catches the bug.", + ), + digestVector( + "escaped-characters", + { safeSummary: 'quote " backslash \\ tab\tend' }, + "The three escapes JSON requires. A serializer that escapes more than this disagrees.", + ), + digestVector( + "integer-form", + { previousSnapshotVersion: 0, projectionRuleVersion: 1, snapshotVersion: 999999999999 }, + "Integers are shortest decimal with no exponent. A serializer that emits 1.0, 1e0, or " + + "1E12 for the largest value disagrees. Zero is a legal previousSnapshotVersion and " + + "must not be omitted as falsy.", + ), + digestVector( + "nested-sorting", + { + projectionStatus: { state: "current", lastProjectedAt: "2026-07-28T11:59:58.000Z" }, + entries: [{ state: "active", entitlementKey: "pro", endKnown: true }], + }, + "Sorting applies at every depth, including inside array elements. An implementation " + + "that sorts only the top level agrees on flat vectors and disagrees on every real snapshot.", + ), + ], +}; + +/* ------------------------------------------------------ cache-decision vectors */ + +const cacheVector = (id, cached, incoming, decision, reason, cacheAction, resultingAccessState, notes) => ({ + id, + cached, + incoming, + decision, + reason, + cacheAction, + resultingAccessState, + notes, +}); + +const CACHED = Object.freeze({ + contractVersion: "1", + billingCustomerId: "fixture-customer-0001", + projectId: "fixture-project-mosaic", + environmentId: "fixture-environment-production", + snapshotVersion: 4, + asOf: "2026-07-28T11:59:58.000Z", + contentDigestValid: true, +}); + +const incoming = (overrides) => ({ ...CACHED, ...overrides }); + +const cacheDocument = { + $comment: PROVENANCE, + contract: CONTRACT, + contractVersion: "1", + compatibilityManifest: "protocol/compatibility/authoritative-entitlement/v1.json", + evaluationOrder: [ + "unsupportedContractVersion", + "customerBindingMismatch", + "contentDigestMismatch", + "snapshotVersionNotNewer", + "asOfRegression", + "accept", + ], + rules: [ + "Evaluate the checks in evaluationOrder. The order is normative: a snapshot that is both bound to a different customer and older must be reported as a binding mismatch, because the binding failure requires clearing the cache and the version failure does not.", + "A rejected snapshot NEVER produces accessState inactive. It produces unknown, and the previously accepted cache is preserved -- except on a binding mismatch, where the cache is cleared because continuing to serve the previous customer's access is the leak this rule exists to prevent.", + "snapshotVersion is the sole monotonicity key. The entity tag is an opaque equality token and is never compared for magnitude.", + "A snapshot whose version equals the cached version is not newer and is not accepted. Re-accepting it would be harmless today and is refused anyway, so that 'accepted' always means 'the state advanced'.", + "snapshotVersion 0 is the never-projected placeholder and is cached like any other snapshot. Issued snapshots start at 1, so the placeholder sorts below everything that can supersede it and the ordinary monotonicity check promotes it. There is no special case: an implementation that added one would be adding a branch the numbering already handles.", + ], + vectors: [ + cacheVector( + "newer-version-accepted", + CACHED, + incoming({ snapshotVersion: 5, asOf: "2026-07-28T12:30:00.000Z" }), + "accept", + "newer_snapshot_version", + "replace", + "fromSnapshot", + "The ordinary path. The cache is replaced atomically; there is no partial merge.", + ), + cacheVector( + "older-version-rejected", + CACHED, + incoming({ snapshotVersion: 3, asOf: "2026-07-28T11:00:00.000Z" }), + "reject", + "snapshot_version_not_newer", + "preserve", + "unknownIfNoCacheOtherwiseCached", + "A late or replayed response must not roll state backwards. Rejecting it preserves the newer accepted state.", + ), + cacheVector( + "equal-version-rejected", + CACHED, + incoming({ snapshotVersion: 4 }), + "reject", + "snapshot_version_not_newer", + "preserve", + "unknownIfNoCacheOtherwiseCached", + "Equal is not newer. A 200 snapshotUnchanged response is the correct way to confirm a current snapshot; it slides freshness without re-accepting anything.", + ), + cacheVector( + "version-regression-after-environment-change", + CACHED, + incoming({ + environmentId: "fixture-environment-staging", + snapshotVersion: 1, + asOf: "2026-07-28T12:30:00.000Z", + }), + "reject", + "environment_mismatch", + "clear", + "unknown", + "Snapshot versions are monotonic per customer PER ENVIRONMENT, so a staging snapshot legitimately starts at 1. Treating this as a version regression would be the wrong diagnosis and would preserve a production cache under a staging identity. The binding check runs first for exactly this case.", + ), + cacheVector( + "different-customer-clears-cache", + CACHED, + incoming({ billingCustomerId: "fixture-customer-0002", snapshotVersion: 9 }), + "reject", + "customer_mismatch", + "clear", + "unknown", + "The one rejection that clears rather than preserves. A newer version does not make a snapshot for another person acceptable, and keeping the old cache after an identity change leaks the previous user's access.", + ), + cacheVector( + "different-project-clears-cache", + CACHED, + incoming({ projectId: "fixture-project-other", snapshotVersion: 9 }), + "reject", + "project_mismatch", + "clear", + "unknown", + "Same reasoning as customer mismatch. All three binding members -- customer, Project, Environment -- are covered by the contentDigest so a mismatch is detectable even if a field were tampered with.", + ), + cacheVector( + "checksum-failure-preserves-cache", + CACHED, + incoming({ snapshotVersion: 5, contentDigestValid: false }), + "reject", + "content_digest_mismatch", + "preserve", + "unknownIfNoCacheOtherwiseCached", + "Corruption in transit or at rest. The snapshot is discarded whole -- never partially applied -- and the last good cache stands.", + ), + cacheVector( + "unsupported-contract-version", + CACHED, + incoming({ contractVersion: "2", snapshotVersion: 5 }), + "reject", + "unsupported_contract_version", + "preserve", + "unknownIfNoCacheOtherwiseCached", + "Exact-match reading. A '2' document is as unreadable to a '1' reader as a '9.9' document; numeric ordering never implies support. This check runs first because a document in an unknown version cannot be trusted to have interpretable binding fields.", + ), + cacheVector( + "as-of-regression-rejected", + CACHED, + incoming({ snapshotVersion: 5, asOf: "2026-07-28T10:00:00.000Z" }), + "reject", + "as_of_regression", + "preserve", + "unknownIfNoCacheOtherwiseCached", + "A higher version evaluated at an earlier instant means the server projected from a stale read. Accepting it would move the version forward while moving the evidence backward.", + ), + cacheVector( + "placeholder-superseded-by-first-real-snapshot", + incoming({ snapshotVersion: 0, asOf: "2026-07-28T11:00:00.000Z" }), + incoming({ snapshotVersion: 1, asOf: "2026-07-28T12:30:00.000Z" }), + "accept", + "newer_snapshot_version", + "replace", + "fromSnapshot", + "A cached never-projected placeholder (version 0, no entries) replaced by the first real snapshot. This is the vector that proves the placeholder needs no special handling: 1 > 0 under the same comparison every other vector uses, and the accepted snapshot replaces it atomically like any other. An implementation that treated version 0 as 'no cache' or as a sentinel would pass every other vector and diverge here.", + ), + cacheVector( + "no-cache-accepts-first-snapshot", + null, + incoming({ snapshotVersion: 1 }), + "accept", + "no_cached_snapshot", + "replace", + "fromSnapshot", + "With no cache there is nothing to be monotonic against. Every other check still applies.", + ), + ], +}; + +/* ---------------------------------------------------------- freshness vectors */ + +const freshnessVector = (id, snapshot, deviceNow, state, notes) => ({ + id, + snapshot, + deviceNow, + clockSkewToleranceSeconds: 60, + state, + notes, +}); + +const WINDOW = Object.freeze({ + issuedAt: "2026-07-28T12:00:00.000Z", + asOf: "2026-07-28T11:59:58.000Z", + refreshAfter: "2026-07-28T13:00:00.000Z", + validUntil: "2026-08-04T12:00:00.000Z", + staleGraceSeconds: 86400, +}); + +const strictWindow = { ...WINDOW, staleGraceSeconds: 0 }; + +const freshnessDocument = { + $comment: PROVENANCE, + contract: CONTRACT, + contractVersion: "1", + compatibilityManifest: "protocol/compatibility/authoritative-entitlement/v1.json", + policy: { + name: "boundedGrace", + clockSkewToleranceSeconds: 60, + defaultRefreshAfterSeconds: 3600, + defaultValidUntilSeconds: 604800, + defaultStaleGraceSeconds: 86400, + maxValidUntilSeconds: 2592000, + maxStaleGraceSeconds: 2592000, + maxCacheHorizonSeconds: 2592000, + }, + rules: [ + "fresh: deviceNow is before refreshAfter. Serve the cache and do not refresh.", + "refresh_recommended: deviceNow is at or after refreshAfter and before validUntil. The snapshot is still fully valid; refresh opportunistically.", + "stale_within_grace: deviceNow is at or after validUntil and before validUntil + staleGraceSeconds. Previously active Entitlements remain locally active and MUST be surfaced as stale. With staleGraceSeconds 0 this band does not exist and the state is expired.", + "expired: deviceNow is at or after validUntil + staleGraceSeconds. Report unknown. NEVER report inactive: an expired cache means Mosaic has not been heard from, not that access ended.", + "staleGraceSeconds defaults to 86400 (24 hours) because bounded grace is the shipped policy; in Phase 9B it is configured deployment-wide server-side rather than per Environment (per-Environment configuration is a tracked follow-up), and a strict policy is the same fields with a grace window of zero. An absent member means zero, so a producer that intends bounded grace states it explicitly.", + "(validUntil - issuedAt) + staleGraceSeconds may never exceed 2592000 seconds (30 days). Bounding each field alone would let a 30-day validity and a 30-day grace window compose into 60 days of unconfirmed offline access.", + "Boundaries are compared with the 60-second skew tolerance applied in the direction that favours the user: a boundary is crossed only once deviceNow exceeds it by more than the tolerance.", + "A device clock earlier than issuedAt by more than the tolerance is unreliable. An unreliable clock is not a fifth state: it forces expired-equivalent behaviour, because a cache whose age cannot be measured cannot be trusted to be young.", + ], + vectors: [ + freshnessVector("well-inside-window", WINDOW, "2026-07-28T12:30:00.000Z", "fresh", + "The ordinary case shortly after issuance."), + freshnessVector("just-before-refresh-after", WINDOW, "2026-07-28T12:59:00.000Z", "fresh", + "One minute before refreshAfter."), + freshnessVector("one-second-past-refresh-after-within-skew", WINDOW, "2026-07-28T13:00:01.000Z", "fresh", + "Inside the 60-second skew tolerance, so the boundary is not yet crossed. An implementation that compares boundaries exactly flaps between two states for every device whose clock is a few seconds fast."), + freshnessVector("past-refresh-after-beyond-skew", WINDOW, "2026-07-28T13:05:00.000Z", "refresh_recommended", + "Past the boundary by more than the tolerance. Still fully valid: this is a hint, not an expiry."), + freshnessVector("long-past-refresh-after", WINDOW, "2026-08-01T12:00:00.000Z", "refresh_recommended", + "Days offline and still inside validUntil. Access continues normally."), + freshnessVector("just-past-valid-until", WINDOW, "2026-08-04T12:05:00.000Z", "stale_within_grace", + "Inside the bounded-grace window. Previously active Entitlements stay active and must be marked stale in the UI."), + freshnessVector("end-of-grace-window", WINDOW, "2026-08-05T11:55:00.000Z", "stale_within_grace", + "Five minutes before the grace window closes."), + freshnessVector("past-grace-window", WINDOW, "2026-08-05T12:05:00.000Z", "expired", + "Past grace. The state is unknown, never inactive; a host that must not over-grant asks its own server."), + freshnessVector("strict-policy-past-valid-until", strictWindow, "2026-08-04T12:05:00.000Z", "expired", + "With staleGraceSeconds 0 there is no grace band at all, so validUntil is a hard edge. This is the strict policy expressed through the same fields rather than through a separate mode."), + freshnessVector("backwards-clock-before-issued-at", WINDOW, "2026-07-28T09:00:00.000Z", "expired", + "The device claims a time hours before the snapshot was issued. The cache's age is unmeasurable, so it is treated as expired. A naive implementation computes a negative age, concludes 'fresh', and hands an attacker unlimited offline access by moving the clock back."), + freshnessVector("backwards-clock-within-skew", WINDOW, "2026-07-28T11:59:30.000Z", "fresh", + "Thirty seconds before issuedAt is ordinary clock skew between a phone and a server, not a manipulated clock. It must not trip the unreliable-clock path."), + freshnessVector("forwards-clock-far-future", WINDOW, "2027-07-28T12:00:00.000Z", "expired", + "A wildly future clock expires the cache. This direction fails safe on its own: the user sees unknown rather than a granted state."), + ], +}; + +/* --------------------------------------------------------------------- write */ + +const digestPath = write("entitlement-snapshot-digest-vectors.json", digestDocument); +const cachePath = write("entitlement-cache-decision-vectors.json", cacheDocument); +const freshnessPath = write("entitlement-freshness-vectors.json", freshnessDocument); + +console.log( + `Wrote ${digestDocument.vectors.length} snapshot digest vectors to ${digestPath}\n` + + `Wrote ${cacheDocument.vectors.length} cache-decision vectors to ${cachePath}\n` + + `Wrote ${freshnessDocument.vectors.length} freshness vectors to ${freshnessPath}`, +); diff --git a/packages/test-fixtures/src/build-webhook-signature-vectors.mjs b/packages/test-fixtures/src/build-webhook-signature-vectors.mjs new file mode 100644 index 00000000..5b65cc39 --- /dev/null +++ b/packages/test-fixtures/src/build-webhook-signature-vectors.mjs @@ -0,0 +1,147 @@ +/** + * Regenerates `webhook-signature-vectors.json`. + * + * A webhook signature is the one place where an application backend written in + * any language has to reproduce Mosaic's bytes exactly. Every realistic mistake + * -- re-serializing the JSON before hashing, signing the body alone, joining the + * parts with the wrong separator, comparing hex case-sensitively against an + * uppercase digest -- produces a verifier that rejects every genuine delivery, + * or worse, one that accepts a replayed signature on a different event. + * + * Signatures are computed here, never hand-written. + * + * Run: node packages/test-fixtures/src/build-webhook-signature-vectors.mjs + * Verified by: protocol/tools/billing-state-webhook-validation-v1.test.mjs + */ +import { createHmac } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repository = resolve(here, "../../.."); + +const SIGNING_VERSION = "v1"; + +/** + * The exact bytes the MAC covers, pinned by + * `protocol/compatibility/billing-state-webhook/v1.json`. + */ +function signedPayload({ timestamp, eventId, rawBody }) { + return `${SIGNING_VERSION}.${timestamp}.${eventId}.${rawBody}`; +} + +function sign({ secret, timestamp, eventId, rawBody }) { + return createHmac("sha256", secret) + .update(signedPayload({ timestamp, eventId, rawBody }), "utf8") + .digest("hex"); +} + +const eventFixturePath = + "protocol/fixtures/billing-state-webhook/v1/events/entitlement-activated.json"; + +/** + * The raw body is the bytes as transmitted, not a re-serialization. Mosaic + * transmits the canonical 2-space-indented form its fixtures are written in, and + * a verifier must hash the bytes it received rather than the object it parsed. + */ +const canonicalEventBody = readFileSync( + resolve(repository, eventFixturePath), + "utf8", +).trimEnd(); + +const canonicalEvent = JSON.parse(canonicalEventBody); + +const vector = (id, input, notes) => ({ + id, + ...input, + signedPayload: signedPayload(input), + signature: sign(input), + header: `t=${input.timestamp}, ${SIGNING_VERSION}=${sign(input)}`, + notes, +}); + +const primarySecret = "whsec_fixture_primary_0000000000000000"; +const rotationSecret = "whsec_fixture_rotation_000000000000000"; +const timestamp = 1785243603; + +const primary = { + secret: primarySecret, + timestamp, + eventId: canonicalEvent.payload.eventId, + rawBody: canonicalEventBody, +}; + +const rotated = { ...primary, secret: rotationSecret }; + +const document = { + $comment: + "Generated cross-implementation reference vectors. Regenerate with " + + "packages/test-fixtures/src/build-webhook-signature-vectors.mjs; never hand-edit a signature.", + contract: "Billing State Webhook Contract v1", + contractVersion: "1", + compatibilityManifest: "protocol/compatibility/billing-state-webhook/v1.json", + eventFixture: eventFixturePath, + scheme: { + header: "Mosaic-Signature", + algorithm: "HMAC-SHA256", + signingVersion: SIGNING_VERSION, + signedPayloadTemplate: "{signingVersion}.{timestamp}.{eventId}.{rawBody}", + separator: ".", + keyEncoding: "UTF-8 bytes of the secret, used verbatim; the secret is not hex- or base64-decoded first", + output: "lowercase hexadecimal, 64 characters", + timestampFormat: "unix seconds, decimal, no fractional part", + replayWindowSeconds: 300, + }, + rules: [ + "Hash the raw request body exactly as received. Do not parse and re-serialize it: whitespace, member order, and Unicode escaping all change the bytes and therefore the signature.", + "The event ID is inside the signed payload, so a captured signature cannot be replayed onto a different event body even within the replay window.", + "Reject a delivery whose timestamp is more than 300 seconds from your own clock, before comparing signatures.", + "During key rotation the header carries one v1 parameter per active key. Accept the delivery if ANY of them verifies. A verifier that reads only the first parameter drops every delivery signed with the new key.", + "Compare signatures with a constant-time comparison. A byte-by-byte early-exit comparison leaks the expected value over enough requests.", + "Compare case-insensitively or lowercase your own output first. Several HMAC libraries emit uppercase hexadecimal by default.", + ], + vectors: [ + vector("canonical-event-primary-key", primary, + `The signature Mosaic produces for ${eventFixturePath}. A verifier that reproduces this agrees with the canonical fixture.`), + vector("canonical-event-rotation-key", rotated, + "The same event signed with a second active key. During rotation both parameters appear in one header and either one verifying is enough."), + vector("tampered-body-must-not-verify", { + ...primary, + rawBody: canonicalEventBody.replace('"pro"', '"pro_lifetime"'), + }, + "One entitlement key changed. Its signature differs from canonical-event-primary-key, which is the property that makes the signature worth computing. An implementation must NOT accept this signature for the canonical body."), + vector("different-event-id-must-not-verify", { + ...primary, + eventId: "fixture-event-9999", + }, + "Same body, same secret, same timestamp, different event ID. The differing signature proves the event ID is genuinely inside the signed payload rather than merely carried beside it."), + vector("different-timestamp-must-not-verify", { ...primary, timestamp: timestamp + 1 }, + "One second later. Proves the timestamp is covered, which is what makes the replay window enforceable."), + vector("minimal-body", { + secret: primarySecret, + timestamp, + eventId: "fixture-event-0001", + rawBody: "{}", + }, + "A trivial body, so an implementation can be bootstrapped against this vector before it can produce a real event."), + vector("non-ascii-body", { + secret: primarySecret, + timestamp, + eventId: "fixture-event-0001", + rawBody: '{"safeMessage":"Abonnement actif — 続き"}', + }, + "Proves the payload is hashed as UTF-8. A UTF-16 or platform-default encoding agrees on every ASCII vector and disagrees here, so this is the vector that catches the bug."), + vector("non-ascii-secret", { + secret: "whsec_fixture_ünïcödé_secret", + timestamp, + eventId: "fixture-event-0001", + rawBody: "{}", + }, + "Proves the key is also taken as UTF-8 bytes and is not decoded from hex or base64 first."), + ], +}; + +const path = resolve(here, "webhook-signature-vectors.json"); +writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`); +console.log(`Wrote ${document.vectors.length} webhook signature vectors to ${path}`); diff --git a/packages/test-fixtures/src/entitlement-cache-decision-vectors.json b/packages/test-fixtures/src/entitlement-cache-decision-vectors.json new file mode 100644 index 00000000..ac114327 --- /dev/null +++ b/packages/test-fixtures/src/entitlement-cache-decision-vectors.json @@ -0,0 +1,301 @@ +{ + "$comment": "Generated cross-implementation reference vectors. Regenerate with packages/test-fixtures/src/build-entitlement-reference-vectors.mjs; never hand-edit a digest.", + "contract": "Authoritative Entitlement Contract v1", + "contractVersion": "1", + "compatibilityManifest": "protocol/compatibility/authoritative-entitlement/v1.json", + "evaluationOrder": [ + "unsupportedContractVersion", + "customerBindingMismatch", + "contentDigestMismatch", + "snapshotVersionNotNewer", + "asOfRegression", + "accept" + ], + "rules": [ + "Evaluate the checks in evaluationOrder. The order is normative: a snapshot that is both bound to a different customer and older must be reported as a binding mismatch, because the binding failure requires clearing the cache and the version failure does not.", + "A rejected snapshot NEVER produces accessState inactive. It produces unknown, and the previously accepted cache is preserved -- except on a binding mismatch, where the cache is cleared because continuing to serve the previous customer's access is the leak this rule exists to prevent.", + "snapshotVersion is the sole monotonicity key. The entity tag is an opaque equality token and is never compared for magnitude.", + "A snapshot whose version equals the cached version is not newer and is not accepted. Re-accepting it would be harmless today and is refused anyway, so that 'accepted' always means 'the state advanced'.", + "snapshotVersion 0 is the never-projected placeholder and is cached like any other snapshot. Issued snapshots start at 1, so the placeholder sorts below everything that can supersede it and the ordinary monotonicity check promotes it. There is no special case: an implementation that added one would be adding a branch the numbering already handles." + ], + "vectors": [ + { + "id": "newer-version-accepted", + "cached": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "incoming": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 5, + "asOf": "2026-07-28T12:30:00.000Z", + "contentDigestValid": true + }, + "decision": "accept", + "reason": "newer_snapshot_version", + "cacheAction": "replace", + "resultingAccessState": "fromSnapshot", + "notes": "The ordinary path. The cache is replaced atomically; there is no partial merge." + }, + { + "id": "older-version-rejected", + "cached": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "incoming": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 3, + "asOf": "2026-07-28T11:00:00.000Z", + "contentDigestValid": true + }, + "decision": "reject", + "reason": "snapshot_version_not_newer", + "cacheAction": "preserve", + "resultingAccessState": "unknownIfNoCacheOtherwiseCached", + "notes": "A late or replayed response must not roll state backwards. Rejecting it preserves the newer accepted state." + }, + { + "id": "equal-version-rejected", + "cached": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "incoming": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "decision": "reject", + "reason": "snapshot_version_not_newer", + "cacheAction": "preserve", + "resultingAccessState": "unknownIfNoCacheOtherwiseCached", + "notes": "Equal is not newer. A 200 snapshotUnchanged response is the correct way to confirm a current snapshot; it slides freshness without re-accepting anything." + }, + { + "id": "version-regression-after-environment-change", + "cached": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "incoming": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-staging", + "snapshotVersion": 1, + "asOf": "2026-07-28T12:30:00.000Z", + "contentDigestValid": true + }, + "decision": "reject", + "reason": "environment_mismatch", + "cacheAction": "clear", + "resultingAccessState": "unknown", + "notes": "Snapshot versions are monotonic per customer PER ENVIRONMENT, so a staging snapshot legitimately starts at 1. Treating this as a version regression would be the wrong diagnosis and would preserve a production cache under a staging identity. The binding check runs first for exactly this case." + }, + { + "id": "different-customer-clears-cache", + "cached": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "incoming": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0002", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 9, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "decision": "reject", + "reason": "customer_mismatch", + "cacheAction": "clear", + "resultingAccessState": "unknown", + "notes": "The one rejection that clears rather than preserves. A newer version does not make a snapshot for another person acceptable, and keeping the old cache after an identity change leaks the previous user's access." + }, + { + "id": "different-project-clears-cache", + "cached": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "incoming": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-other", + "environmentId": "fixture-environment-production", + "snapshotVersion": 9, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "decision": "reject", + "reason": "project_mismatch", + "cacheAction": "clear", + "resultingAccessState": "unknown", + "notes": "Same reasoning as customer mismatch. All three binding members -- customer, Project, Environment -- are covered by the contentDigest so a mismatch is detectable even if a field were tampered with." + }, + { + "id": "checksum-failure-preserves-cache", + "cached": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "incoming": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 5, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": false + }, + "decision": "reject", + "reason": "content_digest_mismatch", + "cacheAction": "preserve", + "resultingAccessState": "unknownIfNoCacheOtherwiseCached", + "notes": "Corruption in transit or at rest. The snapshot is discarded whole -- never partially applied -- and the last good cache stands." + }, + { + "id": "unsupported-contract-version", + "cached": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "incoming": { + "contractVersion": "2", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 5, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "decision": "reject", + "reason": "unsupported_contract_version", + "cacheAction": "preserve", + "resultingAccessState": "unknownIfNoCacheOtherwiseCached", + "notes": "Exact-match reading. A '2' document is as unreadable to a '1' reader as a '9.9' document; numeric ordering never implies support. This check runs first because a document in an unknown version cannot be trusted to have interpretable binding fields." + }, + { + "id": "as-of-regression-rejected", + "cached": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "incoming": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 5, + "asOf": "2026-07-28T10:00:00.000Z", + "contentDigestValid": true + }, + "decision": "reject", + "reason": "as_of_regression", + "cacheAction": "preserve", + "resultingAccessState": "unknownIfNoCacheOtherwiseCached", + "notes": "A higher version evaluated at an earlier instant means the server projected from a stale read. Accepting it would move the version forward while moving the evidence backward." + }, + { + "id": "placeholder-superseded-by-first-real-snapshot", + "cached": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 0, + "asOf": "2026-07-28T11:00:00.000Z", + "contentDigestValid": true + }, + "incoming": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 1, + "asOf": "2026-07-28T12:30:00.000Z", + "contentDigestValid": true + }, + "decision": "accept", + "reason": "newer_snapshot_version", + "cacheAction": "replace", + "resultingAccessState": "fromSnapshot", + "notes": "A cached never-projected placeholder (version 0, no entries) replaced by the first real snapshot. This is the vector that proves the placeholder needs no special handling: 1 > 0 under the same comparison every other vector uses, and the accepted snapshot replaces it atomically like any other. An implementation that treated version 0 as 'no cache' or as a sentinel would pass every other vector and diverge here." + }, + { + "id": "no-cache-accepts-first-snapshot", + "cached": null, + "incoming": { + "contractVersion": "1", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 1, + "asOf": "2026-07-28T11:59:58.000Z", + "contentDigestValid": true + }, + "decision": "accept", + "reason": "no_cached_snapshot", + "cacheAction": "replace", + "resultingAccessState": "fromSnapshot", + "notes": "With no cache there is nothing to be monotonic against. Every other check still applies." + } + ] +} diff --git a/packages/test-fixtures/src/entitlement-freshness-vectors.json b/packages/test-fixtures/src/entitlement-freshness-vectors.json new file mode 100644 index 00000000..5d916fd4 --- /dev/null +++ b/packages/test-fixtures/src/entitlement-freshness-vectors.json @@ -0,0 +1,196 @@ +{ + "$comment": "Generated cross-implementation reference vectors. Regenerate with packages/test-fixtures/src/build-entitlement-reference-vectors.mjs; never hand-edit a digest.", + "contract": "Authoritative Entitlement Contract v1", + "contractVersion": "1", + "compatibilityManifest": "protocol/compatibility/authoritative-entitlement/v1.json", + "policy": { + "name": "boundedGrace", + "clockSkewToleranceSeconds": 60, + "defaultRefreshAfterSeconds": 3600, + "defaultValidUntilSeconds": 604800, + "defaultStaleGraceSeconds": 86400, + "maxValidUntilSeconds": 2592000, + "maxStaleGraceSeconds": 2592000, + "maxCacheHorizonSeconds": 2592000 + }, + "rules": [ + "fresh: deviceNow is before refreshAfter. Serve the cache and do not refresh.", + "refresh_recommended: deviceNow is at or after refreshAfter and before validUntil. The snapshot is still fully valid; refresh opportunistically.", + "stale_within_grace: deviceNow is at or after validUntil and before validUntil + staleGraceSeconds. Previously active Entitlements remain locally active and MUST be surfaced as stale. With staleGraceSeconds 0 this band does not exist and the state is expired.", + "expired: deviceNow is at or after validUntil + staleGraceSeconds. Report unknown. NEVER report inactive: an expired cache means Mosaic has not been heard from, not that access ended.", + "staleGraceSeconds defaults to 86400 (24 hours) because bounded grace is the shipped policy; in Phase 9B it is configured deployment-wide server-side rather than per Environment (per-Environment configuration is a tracked follow-up), and a strict policy is the same fields with a grace window of zero. An absent member means zero, so a producer that intends bounded grace states it explicitly.", + "(validUntil - issuedAt) + staleGraceSeconds may never exceed 2592000 seconds (30 days). Bounding each field alone would let a 30-day validity and a 30-day grace window compose into 60 days of unconfirmed offline access.", + "Boundaries are compared with the 60-second skew tolerance applied in the direction that favours the user: a boundary is crossed only once deviceNow exceeds it by more than the tolerance.", + "A device clock earlier than issuedAt by more than the tolerance is unreliable. An unreliable clock is not a fifth state: it forces expired-equivalent behaviour, because a cache whose age cannot be measured cannot be trusted to be young." + ], + "vectors": [ + { + "id": "well-inside-window", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2026-07-28T12:30:00.000Z", + "clockSkewToleranceSeconds": 60, + "state": "fresh", + "notes": "The ordinary case shortly after issuance." + }, + { + "id": "just-before-refresh-after", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2026-07-28T12:59:00.000Z", + "clockSkewToleranceSeconds": 60, + "state": "fresh", + "notes": "One minute before refreshAfter." + }, + { + "id": "one-second-past-refresh-after-within-skew", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2026-07-28T13:00:01.000Z", + "clockSkewToleranceSeconds": 60, + "state": "fresh", + "notes": "Inside the 60-second skew tolerance, so the boundary is not yet crossed. An implementation that compares boundaries exactly flaps between two states for every device whose clock is a few seconds fast." + }, + { + "id": "past-refresh-after-beyond-skew", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2026-07-28T13:05:00.000Z", + "clockSkewToleranceSeconds": 60, + "state": "refresh_recommended", + "notes": "Past the boundary by more than the tolerance. Still fully valid: this is a hint, not an expiry." + }, + { + "id": "long-past-refresh-after", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2026-08-01T12:00:00.000Z", + "clockSkewToleranceSeconds": 60, + "state": "refresh_recommended", + "notes": "Days offline and still inside validUntil. Access continues normally." + }, + { + "id": "just-past-valid-until", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2026-08-04T12:05:00.000Z", + "clockSkewToleranceSeconds": 60, + "state": "stale_within_grace", + "notes": "Inside the bounded-grace window. Previously active Entitlements stay active and must be marked stale in the UI." + }, + { + "id": "end-of-grace-window", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2026-08-05T11:55:00.000Z", + "clockSkewToleranceSeconds": 60, + "state": "stale_within_grace", + "notes": "Five minutes before the grace window closes." + }, + { + "id": "past-grace-window", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2026-08-05T12:05:00.000Z", + "clockSkewToleranceSeconds": 60, + "state": "expired", + "notes": "Past grace. The state is unknown, never inactive; a host that must not over-grant asks its own server." + }, + { + "id": "strict-policy-past-valid-until", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 0 + }, + "deviceNow": "2026-08-04T12:05:00.000Z", + "clockSkewToleranceSeconds": 60, + "state": "expired", + "notes": "With staleGraceSeconds 0 there is no grace band at all, so validUntil is a hard edge. This is the strict policy expressed through the same fields rather than through a separate mode." + }, + { + "id": "backwards-clock-before-issued-at", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2026-07-28T09:00:00.000Z", + "clockSkewToleranceSeconds": 60, + "state": "expired", + "notes": "The device claims a time hours before the snapshot was issued. The cache's age is unmeasurable, so it is treated as expired. A naive implementation computes a negative age, concludes 'fresh', and hands an attacker unlimited offline access by moving the clock back." + }, + { + "id": "backwards-clock-within-skew", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2026-07-28T11:59:30.000Z", + "clockSkewToleranceSeconds": 60, + "state": "fresh", + "notes": "Thirty seconds before issuedAt is ordinary clock skew between a phone and a server, not a manipulated clock. It must not trip the unreliable-clock path." + }, + { + "id": "forwards-clock-far-future", + "snapshot": { + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "staleGraceSeconds": 86400 + }, + "deviceNow": "2027-07-28T12:00:00.000Z", + "clockSkewToleranceSeconds": 60, + "state": "expired", + "notes": "A wildly future clock expires the cache. This direction fails safe on its own: the user sees unknown rather than a granted state." + } + ] +} diff --git a/packages/test-fixtures/src/entitlement-snapshot-digest-vectors.json b/packages/test-fixtures/src/entitlement-snapshot-digest-vectors.json new file mode 100644 index 00000000..9d18ad86 --- /dev/null +++ b/packages/test-fixtures/src/entitlement-snapshot-digest-vectors.json @@ -0,0 +1,197 @@ +{ + "$comment": "Generated cross-implementation reference vectors. Regenerate with packages/test-fixtures/src/build-entitlement-reference-vectors.mjs; never hand-edit a digest.", + "contract": "Authoritative Entitlement Contract v1", + "contractVersion": "1", + "compatibilityManifest": "protocol/compatibility/authoritative-entitlement/v1.json", + "canonicalSerialization": { + "form": "minifiedJsonSortedKeys", + "hash": "SHA-256", + "inputEncoding": "UTF-8", + "output": "sha256_prefixed_lowercase_hex", + "rules": [ + "Serialize with no insignificant whitespace: no spaces after ':' or ',', no newlines.", + "Order object members ascending by UTF-16 code unit. Sort at every nesting depth.", + "Preserve array order exactly. Array order is normative in this contract: snapshot entries ascend by entitlementKey and sources ascend by sourceId, so a serializer must never sort or reorder an array.", + "Omit absent members. Never emit null: an absent optional and a null optional are different bytes and therefore different digests, and null is invalid everywhere in this contract.", + "Emit every timestamp with exactly three fractional digits and a literal Z, which is what the schema already requires.", + "Emit integers in shortest decimal form with no exponent, no leading zeros, and no decimal point. This contract contains no non-integer numbers.", + "Escape strings minimally, as JSON requires: only the quotation mark, the reverse solidus, and control characters. Never escape non-ASCII characters into \\u sequences.", + "Remove the excluded member -- contentDigest on a customer entitlement snapshot, checksum on a subscription snapshot -- before serializing. Nothing else is removed." + ], + "excludedMembers": { + "customerEntitlementSnapshot": "contentDigest", + "subscriptionSnapshot": "checksum" + } + }, + "vectors": [ + { + "id": "canonical-fixture-snapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + }, + "canonicalSerialization": "{\"asOf\":\"2026-07-28T11:59:58.000Z\",\"billingCustomerId\":\"fixture-customer-0001\",\"changeReason\":\"subscription_state_changed\",\"correlationId\":\"fixture-correlation-0001\",\"entityTag\":\"cs-0001-v4\",\"entries\":[{\"effectiveEnd\":\"2026-08-01T09:00:00.000Z\",\"effectiveStart\":\"2026-07-01T09:00:00.000Z\",\"endKnown\":true,\"entitlementId\":\"fixture-entitlement-pro\",\"entitlementKey\":\"pro\",\"primaryExplanation\":{\"code\":\"active_subscription_period\",\"sourceId\":\"fixture-source-subscription-0001\"},\"sourceCount\":1,\"sourceIds\":[\"fixture-source-subscription-0001\"],\"state\":\"active\"}],\"environmentId\":\"fixture-environment-production\",\"issuedAt\":\"2026-07-28T12:00:00.000Z\",\"previousSnapshotVersion\":3,\"projectId\":\"fixture-project-mosaic\",\"projectionRuleVersion\":1,\"projectionStatus\":{\"lastProjectedAt\":\"2026-07-28T11:59:58.000Z\",\"state\":\"current\"},\"refreshAfter\":\"2026-07-28T13:00:00.000Z\",\"snapshotId\":\"fixture-customer-snapshot-0001\",\"snapshotVersion\":4,\"sources\":[{\"end\":\"2026-08-01T09:00:00.000Z\",\"explanationCode\":\"active_subscription_period\",\"grantVersionId\":\"fixture-grant-version-0001\",\"isTestSource\":false,\"mosaicProductId\":\"fixture-mosaic-product-pro-monthly\",\"sourceId\":\"fixture-source-subscription-0001\",\"sourceSnapshotId\":\"fixture-subscription-snapshot-0001\",\"sourceState\":\"granting\",\"sourceType\":\"active_subscription\",\"start\":\"2026-07-01T09:00:00.000Z\",\"storePlatform\":\"apple_app_store\",\"subscriptionInstanceId\":\"fixture-subscription-instance-0001\",\"uncertainty\":{\"reason\":\"none\"}}],\"validUntil\":\"2026-08-04T12:00:00.000Z\"}", + "canonicalByteLength": 1510, + "digest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "notes": "The payload of protocol/fixtures/authoritative-entitlement/v1/snapshots/active-subscription.json with contentDigest removed. An implementation that reproduces this digest agrees with the canonical fixture, which is the only agreement that matters at run time." + }, + { + "id": "member-order-is-irrelevant", + "payload": { + "zeta": 1, + "alpha": 2, + "Mu": 3, + "_underscore": 4 + }, + "canonicalSerialization": "{\"Mu\":3,\"_underscore\":4,\"alpha\":2,\"zeta\":1}", + "canonicalByteLength": 43, + "digest": "sha256:54bdbdf993e66e0a3888172a6c452fc90e60f89ec8371d36b8639fb073bfce07", + "notes": "Authoring order is discarded; sorting is by UTF-16 code unit, so uppercase sorts before lowercase and '_' sorts after both. An implementation that sorts case-insensitively or by locale produces a different digest here and agrees on every all-lowercase vector." + }, + { + "id": "array-order-is-preserved", + "payload": { + "entries": [ + "pro", + "pro_lifetime" + ], + "sources": [ + "b", + "a" + ] + }, + "canonicalSerialization": "{\"entries\":[\"pro\",\"pro_lifetime\"],\"sources\":[\"b\",\"a\"]}", + "canonicalByteLength": 54, + "digest": "sha256:f3125e976c1593cabc75a364ab106585302ef647160f3a0f35db3def77ca8523", + "notes": "Arrays are never sorted by the serializer. The 'sources' array here is deliberately out of order: a serializer that sorts it would silently repair a document the semantic validator is supposed to reject." + }, + { + "id": "absent-optional", + "payload": { + "endKnown": true, + "entitlementKey": "pro" + }, + "canonicalSerialization": "{\"endKnown\":true,\"entitlementKey\":\"pro\"}", + "canonicalByteLength": 40, + "digest": "sha256:9727903e1e1718aced890e11cb4683e0cbc6d574cd0e87112114eb08c9b2fe89", + "notes": "Paired with null-is-never-emitted and present-optional below. These three prove that absent, null, and present are three different states." + }, + { + "id": "present-optional", + "payload": { + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "entitlementKey": "pro" + }, + "canonicalSerialization": "{\"effectiveEnd\":\"2026-08-01T09:00:00.000Z\",\"endKnown\":true,\"entitlementKey\":\"pro\"}", + "canonicalByteLength": 82, + "digest": "sha256:51131e095121ee99aa6171c25dabe0fdb14c97f3ee5df7996e7c818fae5a28de", + "notes": "The same payload with the optional present. Its digest differs from absent-optional, which is why an implementation must not invent a default for an absent member." + }, + { + "id": "non-ascii-safe-text", + "payload": { + "safeSummary": "Abonnement actif jusqu'au 1 août — 続き" + }, + "canonicalSerialization": "{\"safeSummary\":\"Abonnement actif jusqu'au 1 août — 続き\"}", + "canonicalByteLength": 62, + "digest": "sha256:b6eb5181423cfca764278e4554f259991b13a4cc95bd2a16df05c044e583d4bb", + "notes": "Proves UTF-8 encoding and minimal escaping. A UTF-16 or Latin-1 encoding, or a serializer that escapes non-ASCII into \\u sequences, produces a different digest here and agrees on every ASCII vector. This is the vector that actually catches the bug." + }, + { + "id": "escaped-characters", + "payload": { + "safeSummary": "quote \" backslash \\ tab\tend" + }, + "canonicalSerialization": "{\"safeSummary\":\"quote \\\" backslash \\\\ tab\\tend\"}", + "canonicalByteLength": 48, + "digest": "sha256:66708c391188f431c66320fb6b98d37980de0095fadaed2b4bff7ce46ff1a13d", + "notes": "The three escapes JSON requires. A serializer that escapes more than this disagrees." + }, + { + "id": "integer-form", + "payload": { + "previousSnapshotVersion": 0, + "projectionRuleVersion": 1, + "snapshotVersion": 999999999999 + }, + "canonicalSerialization": "{\"previousSnapshotVersion\":0,\"projectionRuleVersion\":1,\"snapshotVersion\":999999999999}", + "canonicalByteLength": 86, + "digest": "sha256:ba7d4fdfd32b9325a032b140ca24c65d1bac631fa5537a0d3fa0be108223f16c", + "notes": "Integers are shortest decimal with no exponent. A serializer that emits 1.0, 1e0, or 1E12 for the largest value disagrees. Zero is a legal previousSnapshotVersion and must not be omitted as falsy." + }, + { + "id": "nested-sorting", + "payload": { + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "entries": [ + { + "state": "active", + "entitlementKey": "pro", + "endKnown": true + } + ] + }, + "canonicalSerialization": "{\"entries\":[{\"endKnown\":true,\"entitlementKey\":\"pro\",\"state\":\"active\"}],\"projectionStatus\":{\"lastProjectedAt\":\"2026-07-28T11:59:58.000Z\",\"state\":\"current\"}}", + "canonicalByteLength": 155, + "digest": "sha256:5b7b6e11c3bfc32c1b33c079215b8bdd946b30e146598350b90e34ebf2402578", + "notes": "Sorting applies at every depth, including inside array elements. An implementation that sorts only the top level agrees on flat vectors and disagrees on every real snapshot." + } + ] +} diff --git a/packages/test-fixtures/src/webhook-signature-vectors.json b/packages/test-fixtures/src/webhook-signature-vectors.json new file mode 100644 index 00000000..166fcfd9 --- /dev/null +++ b/packages/test-fixtures/src/webhook-signature-vectors.json @@ -0,0 +1,116 @@ +{ + "$comment": "Generated cross-implementation reference vectors. Regenerate with packages/test-fixtures/src/build-webhook-signature-vectors.mjs; never hand-edit a signature.", + "contract": "Billing State Webhook Contract v1", + "contractVersion": "1", + "compatibilityManifest": "protocol/compatibility/billing-state-webhook/v1.json", + "eventFixture": "protocol/fixtures/billing-state-webhook/v1/events/entitlement-activated.json", + "scheme": { + "header": "Mosaic-Signature", + "algorithm": "HMAC-SHA256", + "signingVersion": "v1", + "signedPayloadTemplate": "{signingVersion}.{timestamp}.{eventId}.{rawBody}", + "separator": ".", + "keyEncoding": "UTF-8 bytes of the secret, used verbatim; the secret is not hex- or base64-decoded first", + "output": "lowercase hexadecimal, 64 characters", + "timestampFormat": "unix seconds, decimal, no fractional part", + "replayWindowSeconds": 300 + }, + "rules": [ + "Hash the raw request body exactly as received. Do not parse and re-serialize it: whitespace, member order, and Unicode escaping all change the bytes and therefore the signature.", + "The event ID is inside the signed payload, so a captured signature cannot be replayed onto a different event body even within the replay window.", + "Reject a delivery whose timestamp is more than 300 seconds from your own clock, before comparing signatures.", + "During key rotation the header carries one v1 parameter per active key. Accept the delivery if ANY of them verifies. A verifier that reads only the first parameter drops every delivery signed with the new key.", + "Compare signatures with a constant-time comparison. A byte-by-byte early-exit comparison leaks the expected value over enough requests.", + "Compare case-insensitively or lowercase your own output first. Several HMAC libraries emit uppercase hexadecimal by default." + ], + "vectors": [ + { + "id": "canonical-event-primary-key", + "secret": "whsec_fixture_primary_0000000000000000", + "timestamp": 1785243603, + "eventId": "fixture-event-0001", + "rawBody": "{\n \"billingStateWebhookContractVersion\": \"1\",\n \"recordType\": \"billingStateEvent\",\n \"payload\": {\n \"eventType\": \"customer.entitlements.changed\",\n \"projectId\": \"fixture-project-mosaic\",\n \"environmentId\": \"fixture-environment-production\",\n \"billingCustomerId\": \"fixture-customer-0001\",\n \"projectionRuleVersion\": 1,\n \"sourceReason\": \"initial_projection\",\n \"isTestSource\": false,\n \"eventId\": \"fixture-event-0001\",\n \"subscriptionInstanceId\": \"fixture-subscription-instance-0001\",\n \"snapshotVersion\": 1,\n \"occurredAt\": \"2026-07-01T09:00:00.000Z\",\n \"createdAt\": \"2026-07-01T09:00:03.000Z\",\n \"changedEntitlements\": [\n {\n \"entitlementKey\": \"pro\",\n \"previousState\": \"absent\",\n \"currentState\": \"active\"\n }\n ],\n \"stateSummary\": {\n \"accessState\": \"active\",\n \"lifecycleState\": \"active\",\n \"renewalIntent\": \"auto_renew_enabled\",\n \"billingState\": \"current\",\n \"uncertainty\": {\n \"reason\": \"none\"\n }\n },\n \"correlationId\": \"fixture-correlation-0100\"\n }\n}", + "signedPayload": "v1.1785243603.fixture-event-0001.{\n \"billingStateWebhookContractVersion\": \"1\",\n \"recordType\": \"billingStateEvent\",\n \"payload\": {\n \"eventType\": \"customer.entitlements.changed\",\n \"projectId\": \"fixture-project-mosaic\",\n \"environmentId\": \"fixture-environment-production\",\n \"billingCustomerId\": \"fixture-customer-0001\",\n \"projectionRuleVersion\": 1,\n \"sourceReason\": \"initial_projection\",\n \"isTestSource\": false,\n \"eventId\": \"fixture-event-0001\",\n \"subscriptionInstanceId\": \"fixture-subscription-instance-0001\",\n \"snapshotVersion\": 1,\n \"occurredAt\": \"2026-07-01T09:00:00.000Z\",\n \"createdAt\": \"2026-07-01T09:00:03.000Z\",\n \"changedEntitlements\": [\n {\n \"entitlementKey\": \"pro\",\n \"previousState\": \"absent\",\n \"currentState\": \"active\"\n }\n ],\n \"stateSummary\": {\n \"accessState\": \"active\",\n \"lifecycleState\": \"active\",\n \"renewalIntent\": \"auto_renew_enabled\",\n \"billingState\": \"current\",\n \"uncertainty\": {\n \"reason\": \"none\"\n }\n },\n \"correlationId\": \"fixture-correlation-0100\"\n }\n}", + "signature": "e0af000fe574596fad9a154a3d74357a986a4896ceb188dcef6c486759257905", + "header": "t=1785243603, v1=e0af000fe574596fad9a154a3d74357a986a4896ceb188dcef6c486759257905", + "notes": "The signature Mosaic produces for protocol/fixtures/billing-state-webhook/v1/events/entitlement-activated.json. A verifier that reproduces this agrees with the canonical fixture." + }, + { + "id": "canonical-event-rotation-key", + "secret": "whsec_fixture_rotation_000000000000000", + "timestamp": 1785243603, + "eventId": "fixture-event-0001", + "rawBody": "{\n \"billingStateWebhookContractVersion\": \"1\",\n \"recordType\": \"billingStateEvent\",\n \"payload\": {\n \"eventType\": \"customer.entitlements.changed\",\n \"projectId\": \"fixture-project-mosaic\",\n \"environmentId\": \"fixture-environment-production\",\n \"billingCustomerId\": \"fixture-customer-0001\",\n \"projectionRuleVersion\": 1,\n \"sourceReason\": \"initial_projection\",\n \"isTestSource\": false,\n \"eventId\": \"fixture-event-0001\",\n \"subscriptionInstanceId\": \"fixture-subscription-instance-0001\",\n \"snapshotVersion\": 1,\n \"occurredAt\": \"2026-07-01T09:00:00.000Z\",\n \"createdAt\": \"2026-07-01T09:00:03.000Z\",\n \"changedEntitlements\": [\n {\n \"entitlementKey\": \"pro\",\n \"previousState\": \"absent\",\n \"currentState\": \"active\"\n }\n ],\n \"stateSummary\": {\n \"accessState\": \"active\",\n \"lifecycleState\": \"active\",\n \"renewalIntent\": \"auto_renew_enabled\",\n \"billingState\": \"current\",\n \"uncertainty\": {\n \"reason\": \"none\"\n }\n },\n \"correlationId\": \"fixture-correlation-0100\"\n }\n}", + "signedPayload": "v1.1785243603.fixture-event-0001.{\n \"billingStateWebhookContractVersion\": \"1\",\n \"recordType\": \"billingStateEvent\",\n \"payload\": {\n \"eventType\": \"customer.entitlements.changed\",\n \"projectId\": \"fixture-project-mosaic\",\n \"environmentId\": \"fixture-environment-production\",\n \"billingCustomerId\": \"fixture-customer-0001\",\n \"projectionRuleVersion\": 1,\n \"sourceReason\": \"initial_projection\",\n \"isTestSource\": false,\n \"eventId\": \"fixture-event-0001\",\n \"subscriptionInstanceId\": \"fixture-subscription-instance-0001\",\n \"snapshotVersion\": 1,\n \"occurredAt\": \"2026-07-01T09:00:00.000Z\",\n \"createdAt\": \"2026-07-01T09:00:03.000Z\",\n \"changedEntitlements\": [\n {\n \"entitlementKey\": \"pro\",\n \"previousState\": \"absent\",\n \"currentState\": \"active\"\n }\n ],\n \"stateSummary\": {\n \"accessState\": \"active\",\n \"lifecycleState\": \"active\",\n \"renewalIntent\": \"auto_renew_enabled\",\n \"billingState\": \"current\",\n \"uncertainty\": {\n \"reason\": \"none\"\n }\n },\n \"correlationId\": \"fixture-correlation-0100\"\n }\n}", + "signature": "3d945c3ec0cc961f7fc9109f9de27961db7e2e097ae29e098c77e8240edb51ab", + "header": "t=1785243603, v1=3d945c3ec0cc961f7fc9109f9de27961db7e2e097ae29e098c77e8240edb51ab", + "notes": "The same event signed with a second active key. During rotation both parameters appear in one header and either one verifying is enough." + }, + { + "id": "tampered-body-must-not-verify", + "secret": "whsec_fixture_primary_0000000000000000", + "timestamp": 1785243603, + "eventId": "fixture-event-0001", + "rawBody": "{\n \"billingStateWebhookContractVersion\": \"1\",\n \"recordType\": \"billingStateEvent\",\n \"payload\": {\n \"eventType\": \"customer.entitlements.changed\",\n \"projectId\": \"fixture-project-mosaic\",\n \"environmentId\": \"fixture-environment-production\",\n \"billingCustomerId\": \"fixture-customer-0001\",\n \"projectionRuleVersion\": 1,\n \"sourceReason\": \"initial_projection\",\n \"isTestSource\": false,\n \"eventId\": \"fixture-event-0001\",\n \"subscriptionInstanceId\": \"fixture-subscription-instance-0001\",\n \"snapshotVersion\": 1,\n \"occurredAt\": \"2026-07-01T09:00:00.000Z\",\n \"createdAt\": \"2026-07-01T09:00:03.000Z\",\n \"changedEntitlements\": [\n {\n \"entitlementKey\": \"pro_lifetime\",\n \"previousState\": \"absent\",\n \"currentState\": \"active\"\n }\n ],\n \"stateSummary\": {\n \"accessState\": \"active\",\n \"lifecycleState\": \"active\",\n \"renewalIntent\": \"auto_renew_enabled\",\n \"billingState\": \"current\",\n \"uncertainty\": {\n \"reason\": \"none\"\n }\n },\n \"correlationId\": \"fixture-correlation-0100\"\n }\n}", + "signedPayload": "v1.1785243603.fixture-event-0001.{\n \"billingStateWebhookContractVersion\": \"1\",\n \"recordType\": \"billingStateEvent\",\n \"payload\": {\n \"eventType\": \"customer.entitlements.changed\",\n \"projectId\": \"fixture-project-mosaic\",\n \"environmentId\": \"fixture-environment-production\",\n \"billingCustomerId\": \"fixture-customer-0001\",\n \"projectionRuleVersion\": 1,\n \"sourceReason\": \"initial_projection\",\n \"isTestSource\": false,\n \"eventId\": \"fixture-event-0001\",\n \"subscriptionInstanceId\": \"fixture-subscription-instance-0001\",\n \"snapshotVersion\": 1,\n \"occurredAt\": \"2026-07-01T09:00:00.000Z\",\n \"createdAt\": \"2026-07-01T09:00:03.000Z\",\n \"changedEntitlements\": [\n {\n \"entitlementKey\": \"pro_lifetime\",\n \"previousState\": \"absent\",\n \"currentState\": \"active\"\n }\n ],\n \"stateSummary\": {\n \"accessState\": \"active\",\n \"lifecycleState\": \"active\",\n \"renewalIntent\": \"auto_renew_enabled\",\n \"billingState\": \"current\",\n \"uncertainty\": {\n \"reason\": \"none\"\n }\n },\n \"correlationId\": \"fixture-correlation-0100\"\n }\n}", + "signature": "97407155d0b1c905d3211caa2be5fb35d1dc0727d1972fdf4471cf2c177a01d6", + "header": "t=1785243603, v1=97407155d0b1c905d3211caa2be5fb35d1dc0727d1972fdf4471cf2c177a01d6", + "notes": "One entitlement key changed. Its signature differs from canonical-event-primary-key, which is the property that makes the signature worth computing. An implementation must NOT accept this signature for the canonical body." + }, + { + "id": "different-event-id-must-not-verify", + "secret": "whsec_fixture_primary_0000000000000000", + "timestamp": 1785243603, + "eventId": "fixture-event-9999", + "rawBody": "{\n \"billingStateWebhookContractVersion\": \"1\",\n \"recordType\": \"billingStateEvent\",\n \"payload\": {\n \"eventType\": \"customer.entitlements.changed\",\n \"projectId\": \"fixture-project-mosaic\",\n \"environmentId\": \"fixture-environment-production\",\n \"billingCustomerId\": \"fixture-customer-0001\",\n \"projectionRuleVersion\": 1,\n \"sourceReason\": \"initial_projection\",\n \"isTestSource\": false,\n \"eventId\": \"fixture-event-0001\",\n \"subscriptionInstanceId\": \"fixture-subscription-instance-0001\",\n \"snapshotVersion\": 1,\n \"occurredAt\": \"2026-07-01T09:00:00.000Z\",\n \"createdAt\": \"2026-07-01T09:00:03.000Z\",\n \"changedEntitlements\": [\n {\n \"entitlementKey\": \"pro\",\n \"previousState\": \"absent\",\n \"currentState\": \"active\"\n }\n ],\n \"stateSummary\": {\n \"accessState\": \"active\",\n \"lifecycleState\": \"active\",\n \"renewalIntent\": \"auto_renew_enabled\",\n \"billingState\": \"current\",\n \"uncertainty\": {\n \"reason\": \"none\"\n }\n },\n \"correlationId\": \"fixture-correlation-0100\"\n }\n}", + "signedPayload": "v1.1785243603.fixture-event-9999.{\n \"billingStateWebhookContractVersion\": \"1\",\n \"recordType\": \"billingStateEvent\",\n \"payload\": {\n \"eventType\": \"customer.entitlements.changed\",\n \"projectId\": \"fixture-project-mosaic\",\n \"environmentId\": \"fixture-environment-production\",\n \"billingCustomerId\": \"fixture-customer-0001\",\n \"projectionRuleVersion\": 1,\n \"sourceReason\": \"initial_projection\",\n \"isTestSource\": false,\n \"eventId\": \"fixture-event-0001\",\n \"subscriptionInstanceId\": \"fixture-subscription-instance-0001\",\n \"snapshotVersion\": 1,\n \"occurredAt\": \"2026-07-01T09:00:00.000Z\",\n \"createdAt\": \"2026-07-01T09:00:03.000Z\",\n \"changedEntitlements\": [\n {\n \"entitlementKey\": \"pro\",\n \"previousState\": \"absent\",\n \"currentState\": \"active\"\n }\n ],\n \"stateSummary\": {\n \"accessState\": \"active\",\n \"lifecycleState\": \"active\",\n \"renewalIntent\": \"auto_renew_enabled\",\n \"billingState\": \"current\",\n \"uncertainty\": {\n \"reason\": \"none\"\n }\n },\n \"correlationId\": \"fixture-correlation-0100\"\n }\n}", + "signature": "e6933cf08b74e760abeb13fd9b9009fada06c9532124a8fc623efa45c10b708c", + "header": "t=1785243603, v1=e6933cf08b74e760abeb13fd9b9009fada06c9532124a8fc623efa45c10b708c", + "notes": "Same body, same secret, same timestamp, different event ID. The differing signature proves the event ID is genuinely inside the signed payload rather than merely carried beside it." + }, + { + "id": "different-timestamp-must-not-verify", + "secret": "whsec_fixture_primary_0000000000000000", + "timestamp": 1785243604, + "eventId": "fixture-event-0001", + "rawBody": "{\n \"billingStateWebhookContractVersion\": \"1\",\n \"recordType\": \"billingStateEvent\",\n \"payload\": {\n \"eventType\": \"customer.entitlements.changed\",\n \"projectId\": \"fixture-project-mosaic\",\n \"environmentId\": \"fixture-environment-production\",\n \"billingCustomerId\": \"fixture-customer-0001\",\n \"projectionRuleVersion\": 1,\n \"sourceReason\": \"initial_projection\",\n \"isTestSource\": false,\n \"eventId\": \"fixture-event-0001\",\n \"subscriptionInstanceId\": \"fixture-subscription-instance-0001\",\n \"snapshotVersion\": 1,\n \"occurredAt\": \"2026-07-01T09:00:00.000Z\",\n \"createdAt\": \"2026-07-01T09:00:03.000Z\",\n \"changedEntitlements\": [\n {\n \"entitlementKey\": \"pro\",\n \"previousState\": \"absent\",\n \"currentState\": \"active\"\n }\n ],\n \"stateSummary\": {\n \"accessState\": \"active\",\n \"lifecycleState\": \"active\",\n \"renewalIntent\": \"auto_renew_enabled\",\n \"billingState\": \"current\",\n \"uncertainty\": {\n \"reason\": \"none\"\n }\n },\n \"correlationId\": \"fixture-correlation-0100\"\n }\n}", + "signedPayload": "v1.1785243604.fixture-event-0001.{\n \"billingStateWebhookContractVersion\": \"1\",\n \"recordType\": \"billingStateEvent\",\n \"payload\": {\n \"eventType\": \"customer.entitlements.changed\",\n \"projectId\": \"fixture-project-mosaic\",\n \"environmentId\": \"fixture-environment-production\",\n \"billingCustomerId\": \"fixture-customer-0001\",\n \"projectionRuleVersion\": 1,\n \"sourceReason\": \"initial_projection\",\n \"isTestSource\": false,\n \"eventId\": \"fixture-event-0001\",\n \"subscriptionInstanceId\": \"fixture-subscription-instance-0001\",\n \"snapshotVersion\": 1,\n \"occurredAt\": \"2026-07-01T09:00:00.000Z\",\n \"createdAt\": \"2026-07-01T09:00:03.000Z\",\n \"changedEntitlements\": [\n {\n \"entitlementKey\": \"pro\",\n \"previousState\": \"absent\",\n \"currentState\": \"active\"\n }\n ],\n \"stateSummary\": {\n \"accessState\": \"active\",\n \"lifecycleState\": \"active\",\n \"renewalIntent\": \"auto_renew_enabled\",\n \"billingState\": \"current\",\n \"uncertainty\": {\n \"reason\": \"none\"\n }\n },\n \"correlationId\": \"fixture-correlation-0100\"\n }\n}", + "signature": "fd5be439b687960c00c30b60e5cd9e34db6cdce75069da35d1bf35693615e2ef", + "header": "t=1785243604, v1=fd5be439b687960c00c30b60e5cd9e34db6cdce75069da35d1bf35693615e2ef", + "notes": "One second later. Proves the timestamp is covered, which is what makes the replay window enforceable." + }, + { + "id": "minimal-body", + "secret": "whsec_fixture_primary_0000000000000000", + "timestamp": 1785243603, + "eventId": "fixture-event-0001", + "rawBody": "{}", + "signedPayload": "v1.1785243603.fixture-event-0001.{}", + "signature": "228fa05ec965d33cd5a727906d1f12a76a33ad31cd8cc0369abfcef0b96e434a", + "header": "t=1785243603, v1=228fa05ec965d33cd5a727906d1f12a76a33ad31cd8cc0369abfcef0b96e434a", + "notes": "A trivial body, so an implementation can be bootstrapped against this vector before it can produce a real event." + }, + { + "id": "non-ascii-body", + "secret": "whsec_fixture_primary_0000000000000000", + "timestamp": 1785243603, + "eventId": "fixture-event-0001", + "rawBody": "{\"safeMessage\":\"Abonnement actif — 続き\"}", + "signedPayload": "v1.1785243603.fixture-event-0001.{\"safeMessage\":\"Abonnement actif — 続き\"}", + "signature": "03f3ae588e661d58b9cc5f419764f4ef9dbd5c7ef95e19620a32568aed6dfaaa", + "header": "t=1785243603, v1=03f3ae588e661d58b9cc5f419764f4ef9dbd5c7ef95e19620a32568aed6dfaaa", + "notes": "Proves the payload is hashed as UTF-8. A UTF-16 or platform-default encoding agrees on every ASCII vector and disagrees here, so this is the vector that catches the bug." + }, + { + "id": "non-ascii-secret", + "secret": "whsec_fixture_ünïcödé_secret", + "timestamp": 1785243603, + "eventId": "fixture-event-0001", + "rawBody": "{}", + "signedPayload": "v1.1785243603.fixture-event-0001.{}", + "signature": "2058a59486dc7f45fe261b7f7760befe0cbd30b4a7e691f07480e49efd499d51", + "header": "t=1785243603, v1=2058a59486dc7f45fe261b7f7760befe0cbd30b4a7e691f07480e49efd499d51", + "notes": "Proves the key is also taken as UTF-8 bytes and is not decoded from hex or base64 first." + } + ] +} diff --git a/protocol/CHANGELOG.md b/protocol/CHANGELOG.md index 4c6c5e00..dc8b97d3 100644 --- a/protocol/CHANGELOG.md +++ b/protocol/CHANGELOG.md @@ -4,6 +4,54 @@ All notable Mosaic protocol changes are recorded here. A contract's artifacts become immutable when its `status` reaches `approved`; before that, its review gate may still change them. Every Mosaic contract is `approved` as of v1 GA. +## Phase 9B: three draft contracts for authoritative entitlements - 2026-07-28 + +Status: draft + +No change to any approved contract. Three new contracts, each born `draft` per +owner decision OD-15, each carrying no compatibility guarantee, and none of them +adding a required reference to anything in the approved v1 GA set. + +- **Authoritative Entitlement `1`** — what access a Billing Customer has and + why. Five schemas, seven record types, four state axes plus uncertainty, a + pinned canonical serialization with content digests that bind a snapshot to one + customer, Project, and Environment, bounded-grace freshness, and the normative + reader rule that **any rejection yields `accessState: unknown` and preserves + the cache, never `inactive`**. 37 canonical fixtures, 27 invalid. + [Contract changelog](authoritative-entitlement/CHANGELOG.md) · + [documentation](../docs/protocol/authoritative-entitlement-v1.md). +- **Customer Access Token `1`** — how an SDK proves it may read one customer's + entitlements. Opaque `mcat_` tokens stored as SHA-256 digests, an + owner-approved deviation from the orchestration prompt's "signed" wording + (OD-14), with the header names finalized as contract-owned: the customer token + in `Authorization: Bearer`, the public SDK key in `Mosaic-SDK-Key`. 6 canonical + fixtures, 9 invalid. [Contract changelog](customer-access-token/CHANGELOG.md) · + [documentation](../docs/protocol/customer-access-token-v1.md). +- **Billing State Webhook `1`** — the minimal slice approved as OD-1(b): ten + event types declared, one emitted, HMAC-SHA256 signing over + `signingVersion.timestamp.eventId.rawBody`, at-least-once delivery, and + operator-facing attempt history that is never transmitted. 13 canonical + fixtures, 8 invalid. + [Contract changelog](billing-state-webhook/CHANGELOG.md) · + [documentation](../docs/protocol/billing-state-webhook-v1.md). + +Supporting changes: + +- Four new cross-implementation reference-vector families in + `packages/test-fixtures/src/`: snapshot digest, cache decision, freshness, and + webhook signature. Digests and signatures are computed by build scripts and + never hand-edited; drift against the canonical fixtures and against the + manifests' pinned limits is checked by the protocol test suite. +- `docs/protocol/compatibility-policy.md` records two new normative sections: + "Unknown access is never inactive", and the owner-approved producer/consumer + tolerance asymmetry for webhooks (OD-16) as an **explicit documented + exception** to the repo-wide fail-closed doctrine. +- `tools/generate-rejection-layers.mjs` gains a reusable union-probe helper and + registers the three new `invalid/` directories. +- `tools/validate.mjs` registers the three new load/validate pairs. + `generate-browser-contract.mjs` is untouched: these contracts are server- and + SDK-facing and are deliberately not generated into the browser contract. + ## v1 General Availability: all contracts approved - 2026-07-27 Status: approved diff --git a/protocol/authoritative-entitlement/CHANGELOG.md b/protocol/authoritative-entitlement/CHANGELOG.md new file mode 100644 index 00000000..febcda82 --- /dev/null +++ b/protocol/authoritative-entitlement/CHANGELOG.md @@ -0,0 +1,172 @@ +# Authoritative Entitlement Contract changelog + +## Version 1 - 2026-07-28 + +Status: draft + +Born `draft` per Phase 9B owner decision OD-15. It carries **no compatibility +guarantee**: it may change or disappear without a version bump, and nothing in +the approved v1 GA set depends on it. It is promoted alongside Billing Ingestion +`1` once live-sandbox evidence exists. + +While the contract is `draft`, narrowing and additive corrections are permitted +without a version bump, per +[the breaking-change process](../../docs/protocol/breaking-change-process.md). + +### What version 1 introduces + +Five canonical schemas plus a compatibility manifest, seven closed record types, +and the normative reader rule that gives the contract its purpose: **any +rejection yields `accessState: unknown` and preserves the cache, never +`inactive`**. + +- **Four state axes** rather than one flat enumeration: `accessState`, + `lifecycleState`, `renewalIntent`, `billingState`, plus an `uncertainty` object + whose `reason` is closed at nine members. Every axis is closed and + over-provisioned, so the members that might be needed are declared now. +- **`unavailable` is structurally impossible in a persisted snapshot entry.** + Snapshot entries use a separate three-member `persistedEntitlementState` + vocabulary. `unavailable` describes Mosaic's ability to answer, not customer + access, so it is admissible only on read-time responses. +- **Schema-level `if`/`then` invariants**: revoked implies inactive and requires + `revocationEffectiveAt`; grace requires `gracePeriodEnd`; unknown or + unavailable requires a non-`none` uncertainty reason; paused requires + `google_play`, because pause does not exist on Apple. +- **Canonical serialization** pinned in the manifest, with `contentDigest` + (snapshots) and `checksum` (subscription snapshots) computed over it. The + digest covers the customer, Project, Environment, and version binding, so a + snapshot cannot be accepted into another customer's cache. +- **Bounded-grace freshness** per OD-5: `refreshAfter` default 1 h, `validUntil` + default 7 d, `staleGraceSeconds` default 24 h, all configured deployment-wide + server-side in Phase 9B (per-Environment configuration is a tracked follow-up, + not a shipped capability), with a 60-second clock skew tolerance evaluated in the direction + that favours the user. The 30-day hard maximum applies to the **combined** + horizon, `(validUntil - issuedAt) + staleGraceSeconds`, enforced by the + semantic validator on snapshots and on unchanged responses alike. +- **Cache acceptance order** with binding checked before version, because + snapshot versions are monotonic per Environment and a staging snapshot + legitimately starts at 1. +- **Restore results on two axes**, `outcome` and `providerOutcome`, so a + successful native restore that has not yet reached an accepted snapshot is + `validation_pending` rather than `restored`. +- **`isTestSource` on every source** (OD-17). On Google Play, license-tester + purchases arrive as ordinary production transactions and this flag is the only + thing distinguishing a test grant from a paid one. + +### Deliberate deviations, recorded + +- **Product and Subscription Instance identity live on source summaries only**, + not duplicated onto entries. This deviates from the orchestration prompt's + entry field list. When several sources grant one Entitlement, duplication + creates two places that can disagree, and the entry is the one a reader trusts. +- **`staleGraceSeconds` is an explicit member** rather than an implicit reading + of the `refreshAfter`-to-`validUntil` interval. The orchestration prompt + defines bounded grace as an interval *after* `valid_until`; OD-5 states + defaults for `refresh_after` and `valid_until`. Making the grace window its own + field expresses both without either interpretation being silently assumed, and + makes the strict policy the same fields with a zero grace window rather than a + separate mode. **Ratified by the orchestrator on 2026-07-28**, with the + documented default set to 86400 rather than 0: bounded grace is the approved + shipped policy, and a zero default would have shipped strict behaviour under a + bounded-grace decision. +- **Billing disabled uses `uncertainty.reason: "provider_unavailable"`** with + `explanationCode: "billing_disabled"`. The nine uncertainty reasons are fixed + by the Stage 1 plan and none of them names a Mosaic-side service state; the + explanation code carries the precision instead of widening a closed axis. + +### Known consumer limitations, for the Stage 5 review + +- **`restoreResult.outcome: "product_unresolved"` is currently unreachable from a + client.** Reported by the Flutter agent on 2026-07-29 and applicable to all + three SDKs: an SDK observing a restore has no signal that distinguishes "the + provider transaction validated but its Product could not be resolved" from + "validation has not finished yet", so a client-side restore reports + `validation_pending` in both cases. The outcome remains reachable and correct + on the **server** surface, where the projection knows which it is, and it stays + in the enumeration for that reason — removing it would be a breaking change and + would leave the server unable to state a condition it can actually detect. + + No action in Phase 9B. The fix is a signal, not a contract change: either the + restore poll surfaces the quarantine reason for the observed transactions, or + the sync response carries the pending-fact disposition. Raised here so the + Stage 5 review decides deliberately rather than discovering it as a gap. + +### Documentation pins, 2026-07-29 + +Orchestrator-ratified, documentation only, no schema change: + +- The **SDK-conformant sync form is `POST /v1/sdk/billing/entitlements`** with the + `entitlementSyncRequest` envelope, answered by a `200` carrying either + `customerEntitlementSnapshot` or `snapshotUnchanged`. Negotiation and the + conditional `knownSnapshotVersion` / `entityTag` live in the body, so an SDK + never relies on a bare HTTP `304` or on freshness headers — no header name for + freshness exists in the frozen schemas, and a `304` has no body to carry the + refreshed window in. `GET` is an unconditional full-snapshot read for non-SDK + callers; it ignores `If-None-Match` and always answers `200`. The Customer + Access Token wire-form example was corrected from `GET` to `POST` to match. +- **An `entitlementKey` absent from a snapshot reads as `unknown`, never + `inactive`**, whether or not `requestedEntitlementKeys` narrowed the response. + Absence is not a statement: a key can be missing because it was narrowed away, + never defined, or not evaluable, and the document gives a reader no way to tell + those apart. An `inactive` entry is the opposite — Mosaic stating it looked and + is confident — and is present in the document with an explanation and a source + count. Documented with the narrowed-request example against the existing + `sync/sync-request-requested-keys.json` fixture. +- The canonical spelling of the cross-customer cache state is + **`differentCustomer`**, alongside `fresh`, `refreshRecommended`, + `staleWithinGrace`, `expired`, `missing`, and `invalid`. + +### Stage 4 documentation corrections, 2026-07-29 + +- **There is no conditional `GET` on the sync surface.** The branch was removed + during the Stage 4 defect fixes (D-5). The `GET` read, which exists for non-SDK + callers, is unconditional and always answers `200` with the full + `customerEntitlementSnapshot`; `POST` with `entitlementSyncRequest` is the only + conditional mechanism, and `snapshotUnchanged` the only unchanged response. A + `304` could confirm a snapshot without being able to state how long the + confirmation held, since it has no body and no freshness header exists in the + frozen schemas — so it earned nothing over `snapshotUnchanged` and was a second + place for freshness semantics to drift. +- **The Customer Access Token now travels on observation submission** as the + optional `Mosaic-Customer-Token` transport header, so the server can record + submission-context association evidence. Documented in the Billing Ingestion + transport section under the established transport-is-not-contract rule: no + Billing Ingestion record schema changes, the frozen draft stays frozen, and the + credential never enters an observation body because those bodies are persisted, + replayed, and digested into fact identity. + +### Stage 5 placeholder completion, 2026-07-29 + +- A `customerEntitlementSnapshot` at `snapshotVersion: 0` is the cacheable answer + for a Billing Customer whose first projection has not committed. It must carry + no entries or sources and a pending projection status. Issued snapshots still + start at 1, so every SDK replaces the placeholder through the ordinary + monotonic comparison. Sync requests may state `knownSnapshotVersion: 0`, but + the server reissues the placeholder rather than returning `snapshotUnchanged`. + +### Cross-contract discipline + +Authoritative Entitlement `1` does **not** `$ref` Billing Ingestion `1`. Both are +drafts, and a draft that referenced another draft would inherit its lifecycle. +The safe-diagnostic shape is copied rather than referenced for the same reason. +No approved contract gains a required reference to this one. + +### Fixtures + +37 canonical fixtures and 27 invalid ones. Two invalid fixtures are recorded in +`rejection-layers.json` as **semantic** rejections rather than schema ones, which +is the honest classification: `older-snapshot-version-rejected.json` is +cross-field arithmetic no JSON Schema can express, and +`different-customer-rejected.json` is a digest computed over a different +`billingCustomerId` — the binding failure the digest exists to catch. + +### Shared reference vectors + +- `packages/test-fixtures/src/entitlement-snapshot-digest-vectors.json` +- `packages/test-fixtures/src/entitlement-cache-decision-vectors.json` +- `packages/test-fixtures/src/entitlement-freshness-vectors.json` + +Built by `packages/test-fixtures/src/build-entitlement-reference-vectors.mjs`; +digests are computed, never hand-edited. Drift against the canonical fixtures and +against the manifest's pinned limits is checked by +`protocol/tools/authoritative-entitlement-validation-v1.test.mjs`. diff --git a/protocol/billing-state-webhook/CHANGELOG.md b/protocol/billing-state-webhook/CHANGELOG.md new file mode 100644 index 00000000..dffbc993 --- /dev/null +++ b/protocol/billing-state-webhook/CHANGELOG.md @@ -0,0 +1,100 @@ +# Billing State Webhook Contract changelog + +## Version 1 - 2026-07-28 + +Status: draft + +Born `draft` per Phase 9B owner decision OD-15. No compatibility guarantee until +an explicit product-owner decision approves it. + +Scope is the **minimal slice** approved as OD-1(b): one emitted event type, HMAC +signing, at-least-once delivery, attempt history, API-only destinations, no +dashboard UI. The roadmap places webhooks in Gate 9C; the split is deliberate and +recorded in the Phase 9B plan. + +### What version 1 introduces + +Two canonical schemas plus a compatibility manifest, and two closed record types. + +- **`billingStateEvent`**: `eventId`, `eventType`, Project, Environment, Billing + Customer, optional Subscription Instance, `snapshotVersion` and + `previousSnapshotVersion`, `projectionRuleVersion`, `occurredAt`, `createdAt`, + `changedEntitlements[]`, a four-axis `stateSummary`, `sourceReason`, + `isTestSource`, `correlationId`, and safe diagnostics. +- **`webhookDeliveryAttempt`**: operator-facing attempt history, **never + transmitted to a destination**, carrying no destination URL and no signing + secret. A test asserts no property name in it matches `url`, `secret`, + `signature`, `endpoint`, or `token`. +- **Ten event types declared, one emitted.** All ten names are in the closed + enumeration now because adding a member later costs a contract version. The + manifest separates the two facts: `eventTypes` is a promise about the + vocabulary, `emittedEventTypes` is a promise about behaviour. +- **HMAC-SHA256 signing**: `Mosaic-Signature: t=, v1=` over + `signingVersion.timestamp.eventId.rawBody`, multiple `v1` parameters during key + rotation, a 300-second replay window, and a test endpoint. +- **At-least-once delivery** with `eventId` as the deduplication key, + `snapshotVersion` as the ordering key, and the rule that delivery failure never + rolls back customer state. Exactly-once is never promised. + +### Approved exception: consumer tolerance (OD-16) + +Mosaic's repo-wide doctrine is fail-closed reading: a reader that does not fully +understand a document rejects it. Webhook consumers are a **documented +exception**, approved by the owner. + +Producers stay strict — closed enumerations, `additionalProperties: false`, no +provider material. Consumers are documented as tolerant: ignore unknown fields, +unknown event types, and unknown enumeration members, and re-read the snapshot. + +The justification is that the event is not authoritative; the snapshot is. A +consumer that rejected an event carrying an unrecognized field would stop +reacting to real state changes in order to protect itself from information it was +free to ignore. Ignoring the unknown and re-reading reaches the same fail-safe +outcome by the opposite route. Signature verification is the one thing a consumer +must not be tolerant about, and it happens before the body is parsed. + +Both halves are pinned as separate manifest blocks (`producerPolicy`, +`consumerTolerance`) so the exception stays an exception, and the asymmetry is +also recorded in `docs/protocol/compatibility-policy.md`. + +### Deliberate restrictions + +- **No provider-specific event names.** A validator guard rejects any event type + matching `apple|google|storekit|play|itunes`. An application backend should + never have to branch on which store a change came from. +- **An event must report a change.** A no-change projection creates no snapshot + and emits no webhook, so an event whose entitlement states are all unchanged is + a producer defect — unless `sourceReason` is `subscription_period_changed`, + `renewal_intent_changed`, or `grant_version_changed`. +- **The forbidden-value walk is ported from Billing Ingestion, values only.** + Billing Ingestion also bans `entitlement`, `subscription`, and `customer` field + names; here that vocabulary is legitimate and banning it would ban the + contract. + +### Stage 5 narrowing, 2026-07-29 + +Draft narrowing, no version bump, permitted while the contract is `draft` per +[the breaking-change process](../../docs/protocol/breaking-change-process.md). + +- **`stateSummary.accessState` no longer admits `unavailable`.** The axis is now + `active`, `inactive`, `unknown`. `unavailable` describes Mosaic's ability to + answer a *read*, and an event is not a read: an event exists only because a + projection committed a new snapshot, so the projection did answer. The worst an + event can honestly say about an axis is `unknown`, carrying a non-`none` + `uncertainty` — which the schema already required for `unknown`, and the + `if`/`then` narrowed with the enumeration. Admitting `unavailable` would have + placed a service-delivery state on a record that is not authoritative in the + first place, in front of a consumer this contract explicitly instructs to be + tolerant. No fixture carried the value, so no fixture changed. The narrowing is + machine-guarded: `validateSummaryAccessVocabulary` fails the build if the + enumeration is ever re-widened. + +### Fixtures and vectors + +13 canonical fixtures and 8 invalid ones. +`packages/test-fixtures/src/webhook-signature-vectors.json` carries eight +signature vectors — tampered body, changed event ID, changed timestamp, rotation +key, non-ASCII body, non-ASCII secret — built by +`build-webhook-signature-vectors.mjs`. Signatures are computed, never +hand-edited, and a test asserts the canonical vector signs the bytes of +`events/entitlement-activated.json` with the file's trailing newline removed. diff --git a/protocol/compatibility/authoritative-entitlement/v1.json b/protocol/compatibility/authoritative-entitlement/v1.json new file mode 100644 index 00000000..d9c9e17d --- /dev/null +++ b/protocol/compatibility/authoritative-entitlement/v1.json @@ -0,0 +1,171 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "status": "draft", + "schemas": { + "snapshot": "../../schema/authoritative-entitlement/v1/snapshot.schema.json", + "syncRequest": "../../schema/authoritative-entitlement/v1/sync-request.schema.json", + "check": "../../schema/authoritative-entitlement/v1/check.schema.json", + "subscription": "../../schema/authoritative-entitlement/v1/subscription.schema.json", + "restore": "../../schema/authoritative-entitlement/v1/restore.schema.json" + }, + "canonicalFixtures": [ + "../../fixtures/authoritative-entitlement/v1/checks/check-request.json", + "../../fixtures/authoritative-entitlement/v1/checks/check-result-active.json", + "../../fixtures/authoritative-entitlement/v1/checks/check-result-unavailable-billing-disabled.json", + "../../fixtures/authoritative-entitlement/v1/checks/check-result-unknown.json", + "../../fixtures/authoritative-entitlement/v1/restores/identity-unresolved.json", + "../../fixtures/authoritative-entitlement/v1/restores/no-additional-purchases.json", + "../../fixtures/authoritative-entitlement/v1/restores/provider-unavailable.json", + "../../fixtures/authoritative-entitlement/v1/restores/restored.json", + "../../fixtures/authoritative-entitlement/v1/restores/validation-pending.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/active-grace-period.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/active-subscription.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/active-trial.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/billing-retry-access-withheld.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/bounded-offline-cache.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/inactive-expired-subscription.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/multiple-active-sources.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/never-projected-placeholder.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/newer-snapshot.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/one-time-purchase.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/permanent-source-no-finite-expiry.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/refund-of-one-source-other-remains-active.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/snapshot-unchanged.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/test-source-sandbox-grant.json", + "../../fixtures/authoritative-entitlement/v1/snapshots/unknown-state-identity-unresolved.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/active-subscription.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/active-trial.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/billing-retry.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/cancelled-access-still-active.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/expired.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/grace-period.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/paused-google.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/refunded.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/revoked.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/superseded-by-upgrade.json", + "../../fixtures/authoritative-entitlement/v1/subscriptions/unknown-identity-unresolved.json", + "../../fixtures/authoritative-entitlement/v1/sync/sync-request-conditional.json", + "../../fixtures/authoritative-entitlement/v1/sync/sync-request-initial.json", + "../../fixtures/authoritative-entitlement/v1/sync/sync-request-placeholder.json", + "../../fixtures/authoritative-entitlement/v1/sync/sync-request-requested-keys.json" + ], + "recordTypes": [ + "entitlementSyncRequest", + "customerEntitlementSnapshot", + "entitlementCheckRequest", + "entitlementCheckResult", + "subscriptionSnapshot", + "restoreResult", + "snapshotUnchanged" + ], + "stateAxes": { + "accessState": [ + "active", + "inactive", + "unknown", + "unavailable" + ], + "lifecycleState": [ + "trialing", + "active", + "grace_period", + "billing_retry", + "paused", + "expired", + "revoked", + "refunded", + "superseded", + "unknown" + ], + "renewalIntent": [ + "auto_renew_enabled", + "auto_renew_disabled", + "provider_managed", + "paused", + "unknown" + ], + "billingState": [ + "current", + "retrying", + "grace", + "failed", + "refunded", + "revoked", + "unknown" + ], + "uncertaintyReason": [ + "none", + "provider_unavailable", + "missing_fact", + "identity_unresolved", + "product_unresolved", + "conflicting_facts", + "projection_failed", + "stale_validation", + "unsupported_provider_state" + ], + "persistedEntitlementState": [ + "active", + "inactive", + "unknown" + ] + }, + "canonicalSerialization": { + "form": "minifiedJsonSortedKeys", + "hash": "SHA-256", + "encoding": "UTF-8", + "output": "sha256_prefixed_lowercase_hex", + "keyOrdering": "ascendingByUtf16CodeUnit", + "arrayOrdering": "documentOrderIsNormative", + "timestampPrecision": "exactlyThreeFractionalDigits", + "absentVersusNull": "absentOnlyNullForbidden", + "numberForm": "shortestIntegerNoExponent", + "excludedMembers": [ + "contentDigest", + "checksum" + ] + }, + "limits": { + "maxRecordBytes": 65536, + "maxEntriesPerSnapshot": 200, + "maxSourcesPerSnapshot": 200, + "maxSourcesPerEntry": 64, + "maxEntitlementKeysPerRequest": 64, + "maxDiagnosticsPerRecord": 10, + "clockSkewToleranceSeconds": 60, + "defaultRefreshAfterSeconds": 3600, + "defaultValidUntilSeconds": 604800, + "defaultStaleGraceSeconds": 86400, + "maxValidUntilSeconds": 2592000, + "maxStaleGraceSeconds": 2592000, + "maxCacheHorizonSeconds": 2592000, + "restorePollAttempts": 3, + "restorePollBudgetSeconds": 6 + }, + "readerPolicy": { + "unknownContractVersion": "rejectRecord", + "unknownRecordType": "rejectRecord", + "unknownField": "rejectRecord", + "unknownAccessState": "rejectRecord", + "unknownLifecycleState": "rejectRecord", + "unknownUncertaintyReason": "rejectRecord", + "unknownExplanationCode": "rejectRecord", + "unknownChangeReason": "rejectRecord", + "unknownSourceType": "rejectRecord", + "unknownEntitlementKey": "acceptAsProjectData", + "rejectedRecord": "reportUnknownPreserveCache", + "inactiveInference": "forbidden", + "olderSnapshotVersion": "rejectPreserveCache", + "customerBindingMismatch": "clearCacheReportUnknown", + "contentDigestMismatch": "rejectPreserveCache", + "expiredCache": "reportUnknownNeverInactive", + "neverProjectedCustomer": "placeholderSnapshotVersionZero", + "unchangedResponse": "preserveCacheSlideFreshness", + "entityTagOrdering": "equalityOnly", + "unavailableInSnapshotEntry": "forbidden", + "billingDisabled": "unavailableNeverInactive", + "snapshotAsCredential": "forbidden", + "providerStatusString": "forbidden", + "partialAcceptance": "forbidden" + } +} diff --git a/protocol/compatibility/billing-state-webhook/v1.json b/protocol/compatibility/billing-state-webhook/v1.json new file mode 100644 index 00000000..87bd702f --- /dev/null +++ b/protocol/compatibility/billing-state-webhook/v1.json @@ -0,0 +1,88 @@ +{ + "billingStateWebhookContractVersion": "1", + "status": "draft", + "schemas": { + "event": "../../schema/billing-state-webhook/v1/event.schema.json", + "delivery": "../../schema/billing-state-webhook/v1/delivery.schema.json" + }, + "canonicalFixtures": [ + "../../fixtures/billing-state-webhook/v1/deliveries/exhausted.json", + "../../fixtures/billing-state-webhook/v1/deliveries/failed-retry-scheduled.json", + "../../fixtures/billing-state-webhook/v1/deliveries/pending.json", + "../../fixtures/billing-state-webhook/v1/deliveries/retry-same-event-id.json", + "../../fixtures/billing-state-webhook/v1/deliveries/skipped-destination-disabled.json", + "../../fixtures/billing-state-webhook/v1/deliveries/succeeded.json", + "../../fixtures/billing-state-webhook/v1/events/entitlement-activated.json", + "../../fixtures/billing-state-webhook/v1/events/entitlement-deactivated.json", + "../../fixtures/billing-state-webhook/v1/events/expiry-extended.json", + "../../fixtures/billing-state-webhook/v1/events/refund.json", + "../../fixtures/billing-state-webhook/v1/events/revocation.json", + "../../fixtures/billing-state-webhook/v1/events/subscription-cancelled-access-active.json", + "../../fixtures/billing-state-webhook/v1/events/unknown-state-transition.json" + ], + "recordTypes": [ + "billingStateEvent", + "webhookDeliveryAttempt" + ], + "eventTypes": [ + "customer.entitlements.changed", + "subscription.state.changed", + "subscription.period.changed", + "subscription.renewal_intent.changed", + "subscription.expired", + "subscription.revoked", + "subscription.refunded", + "customer.billing_identity.conflict", + "customer.projection.failed", + "customer.projection.recovered" + ], + "emittedEventTypes": [ + "customer.entitlements.changed" + ], + "signing": { + "header": "Mosaic-Signature", + "algorithm": "HMAC-SHA256", + "signingVersion": "v1", + "signedPayloadTemplate": "{signingVersion}.{timestamp}.{eventId}.{rawBody}", + "encoding": "lowercase_hex", + "timestampParameter": "t", + "signatureParameter": "v1", + "multipleActiveSignatures": true, + "replayWindowSeconds": 300, + "testEndpointSupported": true, + "signatureVectors": "packages/test-fixtures/src/webhook-signature-vectors.json" + }, + "delivery": { + "guarantee": "atLeastOnce", + "deduplicationKey": "eventId", + "orderingKey": "snapshotVersion", + "failureIsolation": "deliveryNeverRollsBackState" + }, + "limits": { + "maxRecordBytes": 32768, + "maxChangedEntitlementsPerEvent": 200, + "maxResponseExcerptCharacters": 240, + "maxDeliveryAttempts": 32, + "maxDiagnosticsPerRecord": 10, + "replayWindowSeconds": 300 + }, + "producerPolicy": { + "unknownField": "rejectRecord", + "unknownEventType": "rejectRecord", + "providerSecret": "forbidden", + "rawPurchaseToken": "forbidden", + "rawProviderPayload": "forbidden", + "eventIdStability": "stableAcrossAttemptsAndReplays", + "exactlyOnceDelivery": "neverPromised", + "deliveryRecordTransport": "neverSentToDestination" + }, + "consumerTolerance": { + "unknownField": "ignore", + "unknownEventType": "ignore", + "unknownEnumerationMember": "ignore", + "authoritativeState": "reReadSnapshot", + "ordering": "ignoreOlderSnapshotVersion", + "duplicateEvent": "deduplicateByEventId", + "signatureVerification": "requiredBeforeParsing" + } +} diff --git a/protocol/compatibility/customer-access-token/v1.json b/protocol/compatibility/customer-access-token/v1.json new file mode 100644 index 00000000..c89f4f0d --- /dev/null +++ b/protocol/compatibility/customer-access-token/v1.json @@ -0,0 +1,70 @@ +{ + "customerAccessTokenContractVersion": "1", + "status": "draft", + "schemas": { + "token": "../../schema/customer-access-token/v1/token.schema.json" + }, + "canonicalFixtures": [ + "../../fixtures/customer-access-token/v1/tokens/issuance-request.json", + "../../fixtures/customer-access-token/v1/tokens/issuance-result.json", + "../../fixtures/customer-access-token/v1/tokens/metadata-active.json", + "../../fixtures/customer-access-token/v1/tokens/metadata-restore-scope.json", + "../../fixtures/customer-access-token/v1/tokens/metadata-revoked.json", + "../../fixtures/customer-access-token/v1/tokens/revocation-identity-changed.json" + ], + "recordTypes": [ + "customerAccessTokenIssuanceRequest", + "customerAccessTokenIssuanceResult", + "customerAccessTokenMetadata", + "customerAccessTokenRevocation" + ], + "tokenModel": { + "form": "opaqueRandom", + "entropyBits": 256, + "prefix": "mcat_", + "storage": "digestOnly", + "digestAlgorithm": "sha256", + "signed": false, + "parseable": false, + "carriesEntitlementState": false, + "revocation": "immediateServerSide", + "scopeEvaluation": "serverSideColumns" + }, + "wireForm": { + "customerTokenHeader": "Authorization", + "customerTokenScheme": "Bearer", + "publicSdkKeyHeader": "Mosaic-SDK-Key", + "publicSdkKeyAloneSufficient": false, + "clockSkewToleranceSeconds": 60, + "clockEvaluatedBy": "server" + }, + "lifetime": { + "defaultSeconds": 3600, + "maximumSeconds": 86400, + "minimumSeconds": 60, + "refreshResponsibility": "hostApplicationBackend" + }, + "sdkObligations": { + "storage": "memoryOnly", + "attachment": "everySyncRequest", + "parsing": "forbidden", + "refreshOnUnauthorized": "oncePerGeneration", + "onLogout": "discardTokenAndClearCache", + "onIdentityChange": "bumpGenerationCancelInFlightClearCache", + "onProviderFailure": "reportUnavailableNeverInactive", + "loggingToken": "forbidden" + }, + "readerPolicy": { + "unknownContractVersion": "rejectRecord", + "unknownRecordType": "rejectRecord", + "unknownField": "rejectRecord", + "unknownScope": "rejectRecord", + "unknownAudience": "rejectRecord", + "expiredToken": "refuseRequestReportUnavailable", + "revokedToken": "refuseRequestReportUnavailable", + "audienceMismatch": "refuseRequest", + "customerMismatch": "refuseRequest", + "tokenInQueryString": "forbidden", + "tokenPersistedToDisk": "forbidden" + } +} diff --git a/protocol/customer-access-token/CHANGELOG.md b/protocol/customer-access-token/CHANGELOG.md new file mode 100644 index 00000000..96e89f28 --- /dev/null +++ b/protocol/customer-access-token/CHANGELOG.md @@ -0,0 +1,73 @@ +# Customer Access Token Contract changelog + +## Version 1 - 2026-07-28 + +Status: draft + +Born `draft` per Phase 9B owner decision OD-15. No compatibility guarantee until +an explicit product-owner decision approves it. + +### What version 1 introduces + +One canonical schema plus a compatibility manifest, four closed record types, and +the wire form the three SDKs and the backend must agree on exactly. + +- **Opaque tokens**: `mcat_` plus 43 base64url characters, 256 bits of + randomness, stored server-side as a SHA-256 digest with scoping columns for + Project, Environment, Billing Customer, audience, scopes, and expiry. +- **Contract-owned header names**, finalized here: the customer token travels in + `Authorization: Bearer` and the public SDK key in `Mosaic-SDK-Key`. Both are + required; `wireForm.publicSdkKeyAloneSufficient` is `false`. +- **Lifetime bounds**: 1 hour default, 24 hour maximum, 60 second minimum, ±60 + second clock skew, evaluated by the **server**. +- **Immediate revocation** with a closed seven-member reason set, and + all-or-nothing revocation state enforced in the schema. +- **An over-provisioned audience set.** `sdk_sync` is the only audience issued in + Phase 9B; `server_check` is declared and reserved so a future server-facing + audience costs no contract version. +- **SDK obligations** pinned in the manifest: memory-only storage, never parsed, + never logged, one forced refresh per 401 generation, discard-and-clear on + logout, generation bump on identity change, and `unavailable` — never + `inactive` — when the host backend cannot mint a token. + +### Owner-approved deviation: opaque, not signed (OD-14) + +The orchestration prompt's token requirement list says "signed". These tokens are +opaque random bytes. This is recorded as an **explicit deviation**, approved by +the owner on 2026-07-28, rather than a quiet substitution. + +The opaque model satisfies every other requirement on that list at least as well +and satisfies "revocable where practical" strictly better: revocation is one +`UPDATE`, effective immediately and everywhere, whereas a signed token is not +revocable without a revocation list — a server-side lookup that reintroduces +exactly the database read a signed token exists to avoid. It also avoids a +signing-key ADR, a JWKS surface, key rotation, and device-side clock validation: +four new failure modes buying nothing Mosaic needs. ADR-0017's posture already +covers digest-stored, never-recoverable secrets. + +The deviation is machine-guarded, not merely documented. +`protocol/tools/customer-access-token-validation-v1.mjs` fails the build if the +contract ever declares a signing property (`alg`, `kid`, `jwk`, `jwks`, `jws`, +`signature`, a key identifier) or a property whose name suggests the token +carries access state, and the manifest pins `tokenModel.signed: false` and +`tokenModel.carriesEntitlementState: false`. + +### Deliberate restrictions + +- **The issuance request cannot name a Project or Environment.** Tenant scope is + derived from the authenticated `secret_server` key, matching Billing Ingestion. + A request that could name a tenant would let a careless or compromised caller + mint a token into one it does not own. A test asserts those properties stay + absent. +- **No anonymous mode** (OD-4). An installation identifier is client-generated + and guessable; letting it select a Billing Customer would let anyone read + someone else's entitlements by replay or guess. +- **`readerPolicy.tokenInQueryString` is `forbidden`.** Query strings end up in + access logs, proxy logs, and browser history. + +### Fixtures + +6 canonical fixtures and 9 invalid ones, including +`token-carries-entitlement-claims.json`, `token-shaped-as-signed-payload.json`, +`token-missing-customer-binding.json`, and the semantic +`token-lifetime-exceeds-maximum.json`. diff --git a/protocol/fixtures/authoritative-entitlement/v1/checks/check-request.json b/protocol/fixtures/authoritative-entitlement/v1/checks/check-request.json new file mode 100644 index 00000000..e12b7efa --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/checks/check-request.json @@ -0,0 +1,16 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementCheckRequest", + "payload": { + "billingCustomerId": "fixture-customer-0001", + "entitlementKeys": [ + "pro", + "pro_lifetime" + ], + "expectedSnapshotVersion": 4, + "supportedAuthoritativeEntitlementContracts": [ + "1" + ], + "correlationId": "fixture-correlation-0006" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/checks/check-result-active.json b/protocol/fixtures/authoritative-entitlement/v1/checks/check-result-active.json new file mode 100644 index 00000000..79fd0ce0 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/checks/check-result-active.json @@ -0,0 +1,45 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementCheckResult", + "payload": { + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:01:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "results": [ + { + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceCount": 1, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + }, + "isTestSource": false + }, + { + "entitlementKey": "pro_lifetime", + "state": "inactive", + "endKnown": true, + "sourceCount": 0, + "primaryExplanation": { + "code": "no_qualifying_source" + } + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "correlationId": "fixture-correlation-0006" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/checks/check-result-unavailable-billing-disabled.json b/protocol/fixtures/authoritative-entitlement/v1/checks/check-result-unavailable-billing-disabled.json new file mode 100644 index 00000000..17c691e7 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/checks/check-result-unavailable-billing-disabled.json @@ -0,0 +1,39 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementCheckResult", + "payload": { + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "issuedAt": "2026-07-28T12:03:00.000Z", + "results": [ + { + "entitlementKey": "pro", + "state": "unavailable", + "endKnown": false, + "sourceCount": 0, + "primaryExplanation": { + "code": "billing_disabled", + "safeSummary": "Mosaic Billing is disabled for this Environment; access cannot be determined." + }, + "uncertainty": { + "reason": "provider_unavailable", + "since": "2026-07-28T11:00:00.000Z", + "expectedResolution": "operator_action", + "diagnosticCode": "entitlement.service.billing_disabled" + } + } + ], + "correlationId": "fixture-correlation-0008", + "diagnostics": [ + { + "code": "entitlement.service.billing_disabled", + "safeMessage": "Billing is disabled for this Environment. Access is unavailable, not inactive.", + "severity": "warning", + "retryable": false, + "correlationId": "fixture-correlation-0008", + "recoveryAction": "none" + } + ] + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/checks/check-result-unknown.json b/protocol/fixtures/authoritative-entitlement/v1/checks/check-result-unknown.json new file mode 100644 index 00000000..a7d80568 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/checks/check-result-unknown.json @@ -0,0 +1,40 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementCheckResult", + "payload": { + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 12, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:02:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "results": [ + { + "entitlementKey": "pro", + "state": "unknown", + "endKnown": false, + "sourceCount": 1, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "primaryExplanation": { + "code": "conflicting_facts", + "sourceId": "fixture-source-subscription-0001" + }, + "uncertainty": { + "reason": "conflicting_facts", + "since": "2026-07-28T10:30:00.000Z", + "expectedResolution": "operator_action", + "diagnosticCode": "entitlement.projection.conflicting_facts" + } + } + ], + "projectionStatus": { + "state": "degraded", + "lastProjectedAt": "2026-07-28T11:59:58.000Z", + "diagnosticCode": "entitlement.projection.conflicting_facts" + }, + "correlationId": "fixture-correlation-0007" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/check-result-unknown-without-uncertainty.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/check-result-unknown-without-uncertainty.json new file mode 100644 index 00000000..cab3550f --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/check-result-unknown-without-uncertainty.json @@ -0,0 +1,34 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementCheckResult", + "payload": { + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 12, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:02:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "results": [ + { + "entitlementKey": "pro", + "state": "unknown", + "endKnown": false, + "sourceCount": 1, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "primaryExplanation": { + "code": "conflicting_facts", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "projectionStatus": { + "state": "degraded", + "lastProjectedAt": "2026-07-28T11:59:58.000Z", + "diagnosticCode": "entitlement.projection.conflicting_facts" + }, + "correlationId": "fixture-correlation-0007" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/different-customer-rejected.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/different-customer-rejected.json new file mode 100644 index 00000000..faa12c43 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/different-customer-rejected.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:a3bef45af23e4e519838ce3eb1ea9f53547580b41c02314e6170139813de2285", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/entitlement-key-invalid-pattern.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/entitlement-key-invalid-pattern.json new file mode 100644 index 00000000..d95a9f7e --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/entitlement-key-invalid-pattern.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "Pro Tier", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/entries-not-in-canonical-order.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/entries-not-in-canonical-order.json new file mode 100644 index 00000000..cd833064 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/entries-not-in-canonical-order.json @@ -0,0 +1,110 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0007", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 5, + "previousSnapshotVersion": 4, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0007-v5", + "contentDigest": "sha256:dab761bd33f93c14af8e922d8c5747ebc72bc74a6d5cd09330d9b429f5ceadc6", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro-lifetime", + "entitlementKey": "pro_lifetime", + "state": "inactive", + "endKnown": true, + "sourceIds": [], + "sourceCount": 0, + "primaryExplanation": { + "code": "no_qualifying_source" + } + }, + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-05-20T08:00:00.000Z", + "effectiveEnd": "2027-05-20T08:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-family-0001", + "fixture-source-subscription-0001", + "fixture-source-subscription-0002" + ], + "sourceCount": 3, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0002", + "safeSummary": "Three sources grant this Entitlement; the latest finite end is reported." + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-family-0001", + "sourceType": "family_shared", + "subscriptionInstanceId": "fixture-subscription-instance-0003", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0003", + "storePlatform": "apple_app_store", + "start": "2026-05-20T08:00:00.000Z", + "end": "2026-08-20T08:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "family_shared_source", + "isTestSource": false + }, + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + }, + { + "sourceId": "fixture-source-subscription-0002", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0002", + "mosaicProductId": "fixture-mosaic-product-pro-yearly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0002", + "storePlatform": "google_play", + "start": "2026-05-20T08:00:00.000Z", + "end": "2027-05-20T08:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "source_added", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-active-without-granting-source.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-active-without-granting-source.json new file mode 100644 index 00000000..c31052bc --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-active-without-granting-source.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:65eebd9caa719ef591ff458641caed699f16b1a3991f71290756c01cd1e6d7b4", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "not_granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-end-known-false-with-effective-end.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-end-known-false-with-effective-end.json new file mode 100644 index 00000000..333bf922 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-end-known-false-with-effective-end.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": false, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-references-absent-source.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-references-absent-source.json new file mode 100644 index 00000000..2570823b --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-references-absent-source.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:dc45bf8e1fa3a3c278825be235277f36eb300c924c6067682a802de8a902e860", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0404" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0404" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-source-count-disagrees.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-source-count-disagrees.json new file mode 100644 index 00000000..428e40d4 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/entry-source-count-disagrees.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:62e0e72ddefee71fa4237d8a7f2e53662070f2354c97e52677984665fd98ca2c", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 3, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/grace-period-missing-grace-end.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/grace-period-missing-grace-end.json new file mode 100644 index 00000000..81397d97 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/grace-period-missing-grace-end.json @@ -0,0 +1,32 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0013", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 6, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "active", + "lifecycleState": "grace_period", + "renewalIntent": "auto_renew_enabled", + "billingState": "grace", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:466ad2debc1837c3ad14bbf3b5b0a6325109c94f339abf34d2987a548127976a", + "changeReason": "subscription_state_changed", + "explanationCode": "active_grace_period", + "correlationId": "fixture-correlation-0010" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/older-snapshot-version-rejected.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/older-snapshot-version-rejected.json new file mode 100644 index 00000000..9ebe060c --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/older-snapshot-version-rejected.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 3, + "previousSnapshotVersion": 9, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:3d6b3b534ff58308d4dd1be532038bc7daed46fd5cd959e40df8b34eb07f8f50", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/paused-on-apple-app-store.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/paused-on-apple-app-store.json new file mode 100644 index 00000000..c6994d78 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/paused-on-apple-app-store.json @@ -0,0 +1,34 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0015", + "subscriptionInstanceId": "fixture-subscription-instance-0002", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 8, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "inactive", + "lifecycleState": "paused", + "renewalIntent": "paused", + "billingState": "current", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:64e7c050889c6e3144e8ee4073e17f5e8077c4221452dcbedc089e95011990d3", + "changeReason": "subscription_state_changed", + "explanationCode": "subscription_paused", + "correlationId": "fixture-correlation-0010", + "pauseEffectiveAt": "2026-08-01T09:00:00.000Z", + "pauseResumeAt": "2026-10-01T09:00:00.000Z" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/refresh-after-later-than-valid-until.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/refresh-after-later-than-valid-until.json new file mode 100644 index 00000000..401f0566 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/refresh-after-later-than-valid-until.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-08-05T12:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:d5eea68f36345c8e47cfadb5c139ca3a5bcbd9b624bbb1b8c7c5fbc1043a033f", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/rejection-layers.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/rejection-layers.json new file mode 100644 index 00000000..2604d1dd --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/rejection-layers.json @@ -0,0 +1,33 @@ +{ + "contract": "Authoritative Entitlement v1", + "description": "Generated by tools/generate-rejection-layers.mjs. For each invalid fixture, the layer that rejects it: \"schema\" means the canonical JSON Schema alone rejects it; \"semantic\" means the schema accepts it and a semantic validator rule rejects it. See docs/protocol/fixture-lifecycle.md.", + "layers": { + "check-result-unknown-without-uncertainty.json": "schema", + "different-customer-rejected.json": "semantic", + "entitlement-key-invalid-pattern.json": "schema", + "entries-not-in-canonical-order.json": "semantic", + "entry-active-without-granting-source.json": "semantic", + "entry-end-known-false-with-effective-end.json": "schema", + "entry-references-absent-source.json": "semantic", + "entry-source-count-disagrees.json": "semantic", + "grace-period-missing-grace-end.json": "schema", + "older-snapshot-version-rejected.json": "semantic", + "paused-on-apple-app-store.json": "schema", + "refresh-after-later-than-valid-until.json": "semantic", + "restore-restored-without-snapshot-version.json": "schema", + "revoked-subscription-missing-revocation-time.json": "schema", + "revoked-subscription-reports-active-access.json": "schema", + "snapshot-carries-orphan-source.json": "semantic", + "snapshot-carries-signed-payload-value.json": "semantic", + "snapshot-entry-unavailable-state.json": "schema", + "snapshot-entry-unknown-field.json": "schema", + "source-missing-instance-reference.json": "schema", + "source-names-both-instances.json": "schema", + "subscription-carries-provider-status-string.json": "schema", + "subscription-checksum-mismatch.json": "semantic", + "timestamp-without-millisecond-precision.json": "schema", + "unknown-access-state-without-uncertainty.json": "schema", + "unknown-contract-version.json": "schema", + "unknown-record-type.json": "schema" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/restore-restored-without-snapshot-version.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/restore-restored-without-snapshot-version.json new file mode 100644 index 00000000..4604ffac --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/restore-restored-without-snapshot-version.json @@ -0,0 +1,17 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "restoreResult", + "payload": { + "restoreId": "fixture-restore-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "storePlatform": "apple_app_store", + "outcome": "restored", + "providerOutcome": "completed", + "requestedAt": "2026-07-28T12:10:00.000Z", + "completedAt": "2026-07-28T12:10:04.000Z", + "observedTransactionCount": 2, + "correlationId": "fixture-correlation-0020" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/revoked-subscription-missing-revocation-time.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/revoked-subscription-missing-revocation-time.json new file mode 100644 index 00000000..af19c1e7 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/revoked-subscription-missing-revocation-time.json @@ -0,0 +1,32 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0017", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 10, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "inactive", + "lifecycleState": "revoked", + "renewalIntent": "auto_renew_disabled", + "billingState": "revoked", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:de731c113de8a6e51473936157a134a6ba2bbf496e71353ceed2f9bb2019ddfb", + "changeReason": "revocation_applied", + "explanationCode": "subscription_revoked", + "correlationId": "fixture-correlation-0010" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/revoked-subscription-reports-active-access.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/revoked-subscription-reports-active-access.json new file mode 100644 index 00000000..ea92efb9 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/revoked-subscription-reports-active-access.json @@ -0,0 +1,33 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0017", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 10, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "active", + "lifecycleState": "revoked", + "renewalIntent": "auto_renew_disabled", + "billingState": "revoked", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:de731c113de8a6e51473936157a134a6ba2bbf496e71353ceed2f9bb2019ddfb", + "changeReason": "revocation_applied", + "explanationCode": "subscription_revoked", + "correlationId": "fixture-correlation-0010", + "revocationEffectiveAt": "2026-07-25T18:00:00.000Z" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-carries-orphan-source.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-carries-orphan-source.json new file mode 100644 index 00000000..1e76765b --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-carries-orphan-source.json @@ -0,0 +1,78 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:68548ffc056ff7a1e18d466b8e142215343fa12841c15ee9e7fd31cfcd96f639", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + }, + { + "sourceId": "fixture-source-lifetime-0002", + "sourceType": "one_time_non_consumable", + "oneTimePurchaseInstanceId": "fixture-one-time-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-lifetime", + "grantVersionId": "fixture-grant-version-0002", + "sourceSnapshotId": "fixture-one-time-snapshot-0001", + "storePlatform": "google_play", + "start": "2025-03-14T10:15:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "permanent_one_time_purchase", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-carries-signed-payload-value.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-carries-signed-payload-value.json new file mode 100644 index 00000000..cd858de7 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-carries-signed-payload-value.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:96a5727f610dae8b9d5d61f7305e328764658f1ab7f6f61d89beb442d24e5c28", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "eyJhbGciOiJFUzI1NiJ9.eyJ0cmFuc2FjdGlvbklkIjoiMjAwMDAwMDkwMDAwMDAwMSJ9.c2lnbmF0dXJl" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-entry-unavailable-state.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-entry-unavailable-state.json new file mode 100644 index 00000000..cccdefb5 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-entry-unavailable-state.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "unavailable", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-entry-unknown-field.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-entry-unknown-field.json new file mode 100644 index 00000000..d27a85a7 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/snapshot-entry-unknown-field.json @@ -0,0 +1,63 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + }, + "mosaicProductId": "fixture-mosaic-product-pro-monthly" + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/source-missing-instance-reference.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/source-missing-instance-reference.json new file mode 100644 index 00000000..ba2f8919 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/source-missing-instance-reference.json @@ -0,0 +1,61 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/source-names-both-instances.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/source-names-both-instances.json new file mode 100644 index 00000000..afedd56a --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/source-names-both-instances.json @@ -0,0 +1,63 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false, + "oneTimePurchaseInstanceId": "fixture-one-time-instance-0001" + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/subscription-carries-provider-status-string.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/subscription-carries-provider-status-string.json new file mode 100644 index 00000000..400e2d9a --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/subscription-carries-provider-status-string.json @@ -0,0 +1,33 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0001", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 4, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:9040b171b3318075c523750cb1f6ccf97091f8b36a98bdda81aa2aca8af88dd3", + "changeReason": "subscription_state_changed", + "explanationCode": "active_subscription_period", + "correlationId": "fixture-correlation-0010", + "providerStatus": "SUBSCRIPTION_STATE_ACTIVE" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/subscription-checksum-mismatch.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/subscription-checksum-mismatch.json new file mode 100644 index 00000000..2a1e986a --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/subscription-checksum-mismatch.json @@ -0,0 +1,32 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0001", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 4, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2027-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:9040b171b3318075c523750cb1f6ccf97091f8b36a98bdda81aa2aca8af88dd3", + "changeReason": "subscription_state_changed", + "explanationCode": "active_subscription_period", + "correlationId": "fixture-correlation-0010" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/timestamp-without-millisecond-precision.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/timestamp-without-millisecond-precision.json new file mode 100644 index 00000000..42f87e3f --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/timestamp-without-millisecond-precision.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/unknown-access-state-without-uncertainty.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/unknown-access-state-without-uncertainty.json new file mode 100644 index 00000000..f1030661 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/unknown-access-state-without-uncertainty.json @@ -0,0 +1,30 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0020", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 13, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "unknown", + "lifecycleState": "unknown", + "renewalIntent": "unknown", + "billingState": "unknown", + "uncertainty": { + "reason": "none" + }, + "isTestSource": false, + "checksum": "sha256:4e4f85fb5bff3c399a16e8bb853f562e6a4932b1b4e05d9320b409b07657ed91", + "changeReason": "identity_conflict_opened", + "explanationCode": "identity_unresolved", + "correlationId": "fixture-correlation-0010" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/unknown-contract-version.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/unknown-contract-version.json new file mode 100644 index 00000000..6f46f546 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/unknown-contract-version.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "2", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/invalid/unknown-record-type.json b/protocol/fixtures/authoritative-entitlement/v1/invalid/unknown-record-type.json new file mode 100644 index 00000000..df482bd6 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/invalid/unknown-record-type.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementRevocation", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/restores/identity-unresolved.json b/protocol/fixtures/authoritative-entitlement/v1/restores/identity-unresolved.json new file mode 100644 index 00000000..c3abeab8 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/restores/identity-unresolved.json @@ -0,0 +1,21 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "restoreResult", + "payload": { + "restoreId": "fixture-restore-0004", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "storePlatform": "apple_app_store", + "outcome": "identity_unresolved", + "providerOutcome": "completed", + "requestedAt": "2026-07-28T12:13:00.000Z", + "observedTransactionCount": 1, + "uncertainty": { + "reason": "identity_unresolved", + "since": "2026-07-28T12:13:03.000Z", + "expectedResolution": "operator_action", + "diagnosticCode": "entitlement.identity.conflict_open" + }, + "correlationId": "fixture-correlation-0023" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/restores/no-additional-purchases.json b/protocol/fixtures/authoritative-entitlement/v1/restores/no-additional-purchases.json new file mode 100644 index 00000000..21e3d018 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/restores/no-additional-purchases.json @@ -0,0 +1,17 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "restoreResult", + "payload": { + "restoreId": "fixture-restore-0002", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "storePlatform": "google_play", + "outcome": "no_additional_purchases", + "providerOutcome": "no_purchases_found", + "requestedAt": "2026-07-28T12:11:00.000Z", + "completedAt": "2026-07-28T12:11:02.000Z", + "observedTransactionCount": 0, + "correlationId": "fixture-correlation-0021" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/restores/provider-unavailable.json b/protocol/fixtures/authoritative-entitlement/v1/restores/provider-unavailable.json new file mode 100644 index 00000000..7726ca7c --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/restores/provider-unavailable.json @@ -0,0 +1,32 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "restoreResult", + "payload": { + "restoreId": "fixture-restore-0005", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "storePlatform": "google_play", + "outcome": "provider_unavailable", + "providerOutcome": "failed", + "requestedAt": "2026-07-28T12:14:00.000Z", + "uncertainty": { + "reason": "provider_unavailable", + "since": "2026-07-28T12:14:05.000Z", + "expectedResolution": "automatic_retry", + "diagnosticCode": "entitlement.restore.provider_unavailable" + }, + "correlationId": "fixture-correlation-0024", + "diagnostics": [ + { + "code": "entitlement.restore.provider_unavailable", + "safeMessage": "The store could not be reached. Access is unchanged; nothing was revoked.", + "severity": "warning", + "retryable": true, + "retryAfterSeconds": 30, + "correlationId": "fixture-correlation-0024", + "recoveryAction": "retry" + } + ] + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/restores/restored.json b/protocol/fixtures/authoritative-entitlement/v1/restores/restored.json new file mode 100644 index 00000000..e6a7af42 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/restores/restored.json @@ -0,0 +1,18 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "restoreResult", + "payload": { + "restoreId": "fixture-restore-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "storePlatform": "apple_app_store", + "outcome": "restored", + "providerOutcome": "completed", + "requestedAt": "2026-07-28T12:10:00.000Z", + "completedAt": "2026-07-28T12:10:04.000Z", + "snapshotVersion": 5, + "observedTransactionCount": 2, + "correlationId": "fixture-correlation-0020" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/restores/validation-pending.json b/protocol/fixtures/authoritative-entitlement/v1/restores/validation-pending.json new file mode 100644 index 00000000..a1287c32 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/restores/validation-pending.json @@ -0,0 +1,22 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "restoreResult", + "payload": { + "restoreId": "fixture-restore-0003", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "storePlatform": "apple_app_store", + "outcome": "validation_pending", + "providerOutcome": "completed", + "requestedAt": "2026-07-28T12:12:00.000Z", + "observedTransactionCount": 1, + "pendingValidationCount": 1, + "uncertainty": { + "reason": "missing_fact", + "since": "2026-07-28T12:12:06.000Z", + "expectedResolution": "automatic_retry" + }, + "correlationId": "fixture-correlation-0022" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/active-grace-period.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/active-grace-period.json new file mode 100644 index 00000000..40727f13 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/active-grace-period.json @@ -0,0 +1,64 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0003", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 6, + "previousSnapshotVersion": 5, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0003-v6", + "contentDigest": "sha256:78a0d3a46820b7a8c9e1dc567e4cb70f128c530d9f379c34ea449aa260b8402b", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-17T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_grace_period", + "sourceId": "fixture-source-subscription-0001", + "safeSummary": "Payment is being recovered; access continues until the grace period ends." + }, + "refreshRecommendedAt": "2026-08-17T09:00:00.000Z" + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "grace_period", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-17T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_grace_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/active-subscription.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/active-subscription.json new file mode 100644 index 00000000..5c24bf2e --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/active-subscription.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "previousSnapshotVersion": 3, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0001-v4", + "contentDigest": "sha256:209a06e9c13e785198f87d451ca3f94e7649307ff00b33c2979a1228946135cd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/active-trial.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/active-trial.json new file mode 100644 index 00000000..35958e6c --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/active-trial.json @@ -0,0 +1,61 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0002", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 1, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0002-v1", + "contentDigest": "sha256:e703033e6a005a8bd95b298c2d897ac54a0112c6d7696d24e8499bf1a9399c20", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-04T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_trial_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "trial", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-04T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_trial_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "initial_projection", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/billing-retry-access-withheld.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/billing-retry-access-withheld.json new file mode 100644 index 00000000..3556438e --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/billing-retry-access-withheld.json @@ -0,0 +1,63 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0010", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 11, + "previousSnapshotVersion": 10, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0010-v11", + "contentDigest": "sha256:92a49db13225c94ad42baa40341325263d9275b3155e30acab5c5fd1ee825c9d", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "inactive", + "effectiveStart": "2026-06-01T09:00:00.000Z", + "effectiveEnd": "2026-07-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "subscription_expired", + "sourceId": "fixture-source-subscription-0001", + "safeSummary": "Billing retry does not grant access under the default access policy." + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "billing_retry", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-06-01T09:00:00.000Z", + "end": "2026-07-01T09:00:00.000Z", + "sourceState": "not_granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "subscription_expired", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/bounded-offline-cache.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/bounded-offline-cache.json new file mode 100644 index 00000000..a9f6054b --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/bounded-offline-cache.json @@ -0,0 +1,64 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0011", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 13, + "previousSnapshotVersion": 12, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0011-v13", + "contentDigest": "sha256:4a11b2a4cfe82e78d028bb5a93f4e36c8e65e0e4956743015bb9c6d22016e6dd", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + }, + "refreshRecommendedAt": "2026-07-28T13:00:00.000Z" + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "subscription_state_changed", + "correlationId": "fixture-correlation-0001", + "staleGraceSeconds": 86400 + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/inactive-expired-subscription.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/inactive-expired-subscription.json new file mode 100644 index 00000000..7c969bfb --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/inactive-expired-subscription.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0004", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 9, + "previousSnapshotVersion": 8, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0004-v9", + "contentDigest": "sha256:f3c4e19de2557f1beb25a4b06bfb71ef540faf15b8078352f5382274e0ad401d", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "inactive", + "effectiveStart": "2026-06-01T09:00:00.000Z", + "effectiveEnd": "2026-07-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "subscription_expired", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-06-01T09:00:00.000Z", + "end": "2026-07-01T09:00:00.000Z", + "sourceState": "not_granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "subscription_expired", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "source_ended", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/multiple-active-sources.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/multiple-active-sources.json new file mode 100644 index 00000000..5c6d1e11 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/multiple-active-sources.json @@ -0,0 +1,99 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0007", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 5, + "previousSnapshotVersion": 4, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0007-v5", + "contentDigest": "sha256:839d05922997c35dee59e9532862d8ecb7b7e1cff071fad39678fd65d9953f0b", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-05-20T08:00:00.000Z", + "effectiveEnd": "2027-05-20T08:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-family-0001", + "fixture-source-subscription-0001", + "fixture-source-subscription-0002" + ], + "sourceCount": 3, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0002", + "safeSummary": "Three sources grant this Entitlement; the latest finite end is reported." + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-family-0001", + "sourceType": "family_shared", + "subscriptionInstanceId": "fixture-subscription-instance-0003", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0003", + "storePlatform": "apple_app_store", + "start": "2026-05-20T08:00:00.000Z", + "end": "2026-08-20T08:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "family_shared_source", + "isTestSource": false + }, + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + }, + { + "sourceId": "fixture-source-subscription-0002", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0002", + "mosaicProductId": "fixture-mosaic-product-pro-yearly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0002", + "storePlatform": "google_play", + "start": "2026-05-20T08:00:00.000Z", + "end": "2027-05-20T08:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "source_added", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/never-projected-placeholder.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/never-projected-placeholder.json new file mode 100644 index 00000000..7ca4e295 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/never-projected-placeholder.json @@ -0,0 +1,27 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-never-projected", + "billingCustomerId": "fixture-customer-never-projected", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 0, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-29T08:00:00.000Z", + "asOf": "2026-07-29T08:00:00.000Z", + "refreshAfter": "2026-07-29T09:00:00.000Z", + "validUntil": "2026-08-05T08:00:00.000Z", + "entityTag": "cs-never-projected-v0", + "contentDigest": "sha256:20c88af435b8e0a8987aeb7c893fc5411d613b7f4897d8235a8e43d723cfa20a", + "entries": [], + "sources": [], + "projectionStatus": { + "state": "pending", + "lastProjectedAt": "2026-07-29T08:00:00.000Z", + "pendingFactCount": 0 + }, + "changeReason": "initial_projection", + "correlationId": "fixture-correlation-never-projected" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/newer-snapshot.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/newer-snapshot.json new file mode 100644 index 00000000..403e100f --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/newer-snapshot.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0012", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 14, + "previousSnapshotVersion": 13, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T14:00:00.000Z", + "asOf": "2026-07-28T13:59:58.000Z", + "refreshAfter": "2026-07-28T15:00:00.000Z", + "validUntil": "2026-08-04T14:00:00.000Z", + "entityTag": "cs-0012-v14", + "contentDigest": "sha256:ed1e4b9c119a5cbfbde0d2d7ebfde294398616adb7fab4a791450db8d1545c56", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-09-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-09-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T13:59:58.000Z" + }, + "changeReason": "subscription_period_changed", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/one-time-purchase.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/one-time-purchase.json new file mode 100644 index 00000000..35ef6e12 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/one-time-purchase.json @@ -0,0 +1,60 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0006", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 2, + "previousSnapshotVersion": 1, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0006-v2", + "contentDigest": "sha256:ea04d8c72c611ced5789002f4043689f2ce7a5c41ed5b02d9c2e857709874aa2", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro-lifetime", + "entitlementKey": "pro_lifetime", + "state": "active", + "effectiveStart": "2025-03-14T10:15:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-lifetime-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "permanent_one_time_purchase", + "sourceId": "fixture-source-lifetime-0001" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-lifetime-0001", + "sourceType": "one_time_non_consumable", + "oneTimePurchaseInstanceId": "fixture-one-time-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-lifetime", + "grantVersionId": "fixture-grant-version-0002", + "sourceSnapshotId": "fixture-one-time-snapshot-0001", + "storePlatform": "google_play", + "start": "2025-03-14T10:15:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "permanent_one_time_purchase", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "source_added", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/permanent-source-no-finite-expiry.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/permanent-source-no-finite-expiry.json new file mode 100644 index 00000000..a5c3da3a --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/permanent-source-no-finite-expiry.json @@ -0,0 +1,79 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0009", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 3, + "previousSnapshotVersion": 2, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0009-v3", + "contentDigest": "sha256:79cca65ef5c63db17847fc52ffd577712d58e0646e528d4af63ca0e8c521d0bb", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2025-03-14T10:15:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-lifetime-0001", + "fixture-source-subscription-0001" + ], + "sourceCount": 2, + "primaryExplanation": { + "code": "permanent_one_time_purchase", + "sourceId": "fixture-source-lifetime-0001", + "safeSummary": "A permanent source contributes, so no finite expiry is reported." + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-lifetime-0001", + "sourceType": "one_time_non_consumable", + "oneTimePurchaseInstanceId": "fixture-one-time-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-lifetime", + "grantVersionId": "fixture-grant-version-0002", + "sourceSnapshotId": "fixture-one-time-snapshot-0001", + "storePlatform": "google_play", + "start": "2025-03-14T10:15:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "permanent_one_time_purchase", + "isTestSource": false + }, + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "source_added", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/refund-of-one-source-other-remains-active.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/refund-of-one-source-other-remains-active.json new file mode 100644 index 00000000..b8e765ef --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/refund-of-one-source-other-remains-active.json @@ -0,0 +1,79 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0008", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 7, + "previousSnapshotVersion": 6, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0008-v7", + "contentDigest": "sha256:bc79d4c75c739309ee274e1fdc45710441df397594aa7acb42e853fc67120e2f", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2025-03-14T10:15:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-lifetime-0001", + "fixture-source-subscription-0001" + ], + "sourceCount": 2, + "primaryExplanation": { + "code": "permanent_one_time_purchase", + "sourceId": "fixture-source-lifetime-0001", + "safeSummary": "One source was refunded; an unrelated permanent source still grants access." + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-lifetime-0001", + "sourceType": "one_time_non_consumable", + "oneTimePurchaseInstanceId": "fixture-one-time-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-lifetime", + "grantVersionId": "fixture-grant-version-0002", + "sourceSnapshotId": "fixture-one-time-snapshot-0001", + "storePlatform": "google_play", + "start": "2025-03-14T10:15:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "permanent_one_time_purchase", + "isTestSource": false + }, + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "not_granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "subscription_refunded", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "refund_applied", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/snapshot-unchanged.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/snapshot-unchanged.json new file mode 100644 index 00000000..c5c242d3 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/snapshot-unchanged.json @@ -0,0 +1,20 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "snapshotUnchanged", + "payload": { + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 4, + "entityTag": "cs-0001-v4", + "issuedAt": "2026-07-28T12:45:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:45:00.000Z", + "validUntil": "2026-08-04T12:45:00.000Z", + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "correlationId": "fixture-correlation-0002" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/test-source-sandbox-grant.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/test-source-sandbox-grant.json new file mode 100644 index 00000000..b906e121 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/test-source-sandbox-grant.json @@ -0,0 +1,62 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0013", + "billingCustomerId": "fixture-customer-0002", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-staging", + "snapshotVersion": 1, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0013-v1", + "contentDigest": "sha256:f7d60b97e273f05dd6fb3899efce21cf4ef3c55c188da3aa4e9c0a58e618220b", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "active", + "effectiveStart": "2026-07-01T09:00:00.000Z", + "effectiveEnd": "2026-08-01T09:00:00.000Z", + "endKnown": true, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "active_subscription_period", + "sourceId": "fixture-source-subscription-0001", + "safeSummary": "Granted by a provider test transaction; every surface reports it as a test source." + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "granting", + "uncertainty": { + "reason": "none" + }, + "explanationCode": "active_subscription_period", + "isTestSource": true + } + ], + "projectionStatus": { + "state": "current", + "lastProjectedAt": "2026-07-28T11:59:58.000Z" + }, + "changeReason": "initial_projection", + "correlationId": "fixture-correlation-0001" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/snapshots/unknown-state-identity-unresolved.json b/protocol/fixtures/authoritative-entitlement/v1/snapshots/unknown-state-identity-unresolved.json new file mode 100644 index 00000000..9a882d6d --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/snapshots/unknown-state-identity-unresolved.json @@ -0,0 +1,80 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "customerEntitlementSnapshot", + "payload": { + "snapshotId": "fixture-customer-snapshot-0005", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "snapshotVersion": 12, + "previousSnapshotVersion": 11, + "projectionRuleVersion": 1, + "issuedAt": "2026-07-28T12:00:00.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "refreshAfter": "2026-07-28T13:00:00.000Z", + "validUntil": "2026-08-04T12:00:00.000Z", + "entityTag": "cs-0005-v12", + "contentDigest": "sha256:8a42de799f17c006ea7cb20a825a28bb21c57d591412fc23e70dcdde9cc5689b", + "entries": [ + { + "entitlementId": "fixture-entitlement-pro", + "entitlementKey": "pro", + "state": "unknown", + "endKnown": false, + "sourceIds": [ + "fixture-source-subscription-0001" + ], + "sourceCount": 1, + "primaryExplanation": { + "code": "identity_unresolved", + "sourceId": "fixture-source-subscription-0001", + "safeSummary": "Two application users claim this purchase; access is frozen until an operator resolves it." + }, + "uncertainty": { + "reason": "identity_unresolved", + "since": "2026-07-28T10:30:00.000Z", + "expectedResolution": "operator_action", + "diagnosticCode": "entitlement.identity.conflict_open" + } + } + ], + "sources": [ + { + "sourceId": "fixture-source-subscription-0001", + "sourceType": "active_subscription", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "grantVersionId": "fixture-grant-version-0001", + "sourceSnapshotId": "fixture-subscription-snapshot-0001", + "storePlatform": "apple_app_store", + "start": "2026-07-01T09:00:00.000Z", + "end": "2026-08-01T09:00:00.000Z", + "sourceState": "unknown", + "uncertainty": { + "reason": "identity_unresolved", + "since": "2026-07-28T10:30:00.000Z", + "expectedResolution": "operator_action" + }, + "explanationCode": "identity_unresolved", + "isTestSource": false + } + ], + "projectionStatus": { + "state": "degraded", + "lastProjectedAt": "2026-07-28T11:59:58.000Z", + "diagnosticCode": "entitlement.projection.identity_frozen" + }, + "changeReason": "identity_conflict_opened", + "correlationId": "fixture-correlation-0001", + "diagnostics": [ + { + "code": "entitlement.identity.conflict_open", + "safeMessage": "Customer association is disputed; projection is frozen for this lineage.", + "severity": "warning", + "retryable": false, + "correlationId": "fixture-correlation-0001", + "recoveryAction": "resolveIdentityConflict" + } + ] + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/active-subscription.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/active-subscription.json new file mode 100644 index 00000000..3a88abcc --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/active-subscription.json @@ -0,0 +1,32 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0001", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 4, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:9040b171b3318075c523750cb1f6ccf97091f8b36a98bdda81aa2aca8af88dd3", + "changeReason": "subscription_state_changed", + "explanationCode": "active_subscription_period", + "correlationId": "fixture-correlation-0010" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/active-trial.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/active-trial.json new file mode 100644 index 00000000..2d78ab58 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/active-trial.json @@ -0,0 +1,32 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0011", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 1, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "active", + "lifecycleState": "trialing", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-07-08T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:dbbecac1cfce7da35e7e1588dfd6075a81fbf6f393f9207768e66320be228cfb", + "changeReason": "initial_projection", + "explanationCode": "active_trial_period", + "correlationId": "fixture-correlation-0010" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/billing-retry.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/billing-retry.json new file mode 100644 index 00000000..7917eded --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/billing-retry.json @@ -0,0 +1,33 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0014", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 7, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "inactive", + "lifecycleState": "billing_retry", + "renewalIntent": "auto_renew_enabled", + "billingState": "retrying", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:7791496ff3c47abfdb061bc3530d24edce7387c113e97169e081235f7901fb82", + "changeReason": "subscription_state_changed", + "explanationCode": "subscription_expired", + "correlationId": "fixture-correlation-0010", + "billingRetryStart": "2026-08-01T09:00:00.000Z" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/cancelled-access-still-active.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/cancelled-access-still-active.json new file mode 100644 index 00000000..dbdf4117 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/cancelled-access-still-active.json @@ -0,0 +1,33 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0012", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 5, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_disabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:01f6451ccaa9ec18ec740cf4819e96d7264cfa82159be0b9cde13095db4cb06f", + "changeReason": "renewal_intent_changed", + "explanationCode": "subscription_cancelled_access_until_period_end", + "correlationId": "fixture-correlation-0010", + "cancellationEffectiveAt": "2026-07-20T16:42:00.000Z" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/expired.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/expired.json new file mode 100644 index 00000000..1e89e4e1 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/expired.json @@ -0,0 +1,33 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0016", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 9, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "inactive", + "lifecycleState": "expired", + "renewalIntent": "auto_renew_disabled", + "billingState": "failed", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:42131af8b04c1fd362751af82ab8b649b9d9aa7f628330c5cd9e2bd843e03067", + "changeReason": "source_ended", + "explanationCode": "subscription_expired", + "correlationId": "fixture-correlation-0010", + "expirationEffectiveAt": "2026-08-01T09:00:00.000Z" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/grace-period.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/grace-period.json new file mode 100644 index 00000000..3662cf27 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/grace-period.json @@ -0,0 +1,33 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0013", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 6, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "active", + "lifecycleState": "grace_period", + "renewalIntent": "auto_renew_enabled", + "billingState": "grace", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:466ad2debc1837c3ad14bbf3b5b0a6325109c94f339abf34d2987a548127976a", + "changeReason": "subscription_state_changed", + "explanationCode": "active_grace_period", + "correlationId": "fixture-correlation-0010", + "gracePeriodEnd": "2026-08-17T09:00:00.000Z" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/paused-google.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/paused-google.json new file mode 100644 index 00000000..6bcb462d --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/paused-google.json @@ -0,0 +1,34 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0015", + "subscriptionInstanceId": "fixture-subscription-instance-0002", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 8, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "google_play", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "inactive", + "lifecycleState": "paused", + "renewalIntent": "paused", + "billingState": "current", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:64e7c050889c6e3144e8ee4073e17f5e8077c4221452dcbedc089e95011990d3", + "changeReason": "subscription_state_changed", + "explanationCode": "subscription_paused", + "correlationId": "fixture-correlation-0010", + "pauseEffectiveAt": "2026-08-01T09:00:00.000Z", + "pauseResumeAt": "2026-10-01T09:00:00.000Z" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/refunded.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/refunded.json new file mode 100644 index 00000000..13f78451 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/refunded.json @@ -0,0 +1,33 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0018", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 11, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "inactive", + "lifecycleState": "refunded", + "renewalIntent": "auto_renew_disabled", + "billingState": "refunded", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:4e05febd967f233d2f4ba847e5cf093557e77ab2be6427bcc839a80e2953ccb6", + "changeReason": "refund_applied", + "explanationCode": "subscription_refunded", + "correlationId": "fixture-correlation-0010", + "refundEffectiveAt": "2026-07-26T08:30:00.000Z" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/revoked.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/revoked.json new file mode 100644 index 00000000..5baa20c4 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/revoked.json @@ -0,0 +1,33 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0017", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 10, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "inactive", + "lifecycleState": "revoked", + "renewalIntent": "auto_renew_disabled", + "billingState": "revoked", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:de731c113de8a6e51473936157a134a6ba2bbf496e71353ceed2f9bb2019ddfb", + "changeReason": "revocation_applied", + "explanationCode": "subscription_revoked", + "correlationId": "fixture-correlation-0010", + "revocationEffectiveAt": "2026-07-25T18:00:00.000Z" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/superseded-by-upgrade.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/superseded-by-upgrade.json new file mode 100644 index 00000000..b2253192 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/superseded-by-upgrade.json @@ -0,0 +1,34 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0019", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 12, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "google_play", + "mosaicProductId": "fixture-mosaic-product-pro-yearly", + "accessState": "inactive", + "lifecycleState": "superseded", + "renewalIntent": "provider_managed", + "billingState": "current", + "uncertainty": { + "reason": "none" + }, + "periodStart": "2026-07-01T09:00:00.000Z", + "periodEnd": "2026-08-01T09:00:00.000Z", + "isTestSource": false, + "checksum": "sha256:8e0852928c4317e7eb4dbbe0ff7c904316bc6bdd72ecd6e5ff3d27098dbd96d4", + "changeReason": "subscription_state_changed", + "explanationCode": "subscription_superseded", + "correlationId": "fixture-correlation-0010", + "priorMosaicProductId": "fixture-mosaic-product-pro-monthly", + "supersededBySubscriptionInstanceId": "fixture-subscription-instance-0002" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/subscriptions/unknown-identity-unresolved.json b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/unknown-identity-unresolved.json new file mode 100644 index 00000000..2c0f2353 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/subscriptions/unknown-identity-unresolved.json @@ -0,0 +1,33 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "subscriptionSnapshot", + "payload": { + "subscriptionSnapshotId": "fixture-subscription-snapshot-0020", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "purchaseLineageId": "fixture-lineage-0001", + "billingCustomerId": "fixture-customer-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "projectionVersion": 13, + "projectionRuleVersion": 1, + "computedAt": "2026-07-28T11:59:58.000Z", + "asOf": "2026-07-28T11:59:58.000Z", + "storePlatform": "apple_app_store", + "mosaicProductId": "fixture-mosaic-product-pro-monthly", + "accessState": "unknown", + "lifecycleState": "unknown", + "renewalIntent": "unknown", + "billingState": "unknown", + "uncertainty": { + "reason": "identity_unresolved", + "since": "2026-07-28T10:30:00.000Z", + "expectedResolution": "operator_action", + "diagnosticCode": "entitlement.identity.conflict_open" + }, + "isTestSource": false, + "checksum": "sha256:4e4f85fb5bff3c399a16e8bb853f562e6a4932b1b4e05d9320b409b07657ed91", + "changeReason": "identity_conflict_opened", + "explanationCode": "identity_unresolved", + "correlationId": "fixture-correlation-0010" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-conditional.json b/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-conditional.json new file mode 100644 index 00000000..4108eeea --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-conditional.json @@ -0,0 +1,13 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementSyncRequest", + "payload": { + "billingCustomerId": "fixture-customer-0001", + "knownSnapshotVersion": 4, + "entityTag": "cs-0001-v4", + "supportedAuthoritativeEntitlementContracts": [ + "1" + ], + "correlationId": "fixture-correlation-0004" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-initial.json b/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-initial.json new file mode 100644 index 00000000..c56a18bf --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-initial.json @@ -0,0 +1,10 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementSyncRequest", + "payload": { + "supportedAuthoritativeEntitlementContracts": [ + "1" + ], + "correlationId": "fixture-correlation-0003" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-placeholder.json b/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-placeholder.json new file mode 100644 index 00000000..6fc7e5dd --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-placeholder.json @@ -0,0 +1,13 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementSyncRequest", + "payload": { + "billingCustomerId": "fixture-customer-never-projected", + "knownSnapshotVersion": 0, + "entityTag": "cs-never-projected-v0", + "supportedAuthoritativeEntitlementContracts": [ + "1" + ], + "correlationId": "fixture-correlation-placeholder-sync" + } +} diff --git a/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-requested-keys.json b/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-requested-keys.json new file mode 100644 index 00000000..82c08610 --- /dev/null +++ b/protocol/fixtures/authoritative-entitlement/v1/sync/sync-request-requested-keys.json @@ -0,0 +1,14 @@ +{ + "authoritativeEntitlementContractVersion": "1", + "recordType": "entitlementSyncRequest", + "payload": { + "supportedAuthoritativeEntitlementContracts": [ + "1" + ], + "requestedEntitlementKeys": [ + "pro", + "pro_lifetime" + ], + "correlationId": "fixture-correlation-0005" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/deliveries/exhausted.json b/protocol/fixtures/billing-state-webhook/v1/deliveries/exhausted.json new file mode 100644 index 00000000..c7ac839d --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/deliveries/exhausted.json @@ -0,0 +1,25 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "webhookDeliveryAttempt", + "payload": { + "destinationId": "fixture-destination-0001", + "maxAttempts": 8, + "deliveryId": "fixture-delivery-0005", + "eventId": "fixture-event-0005", + "attempt": 8, + "status": "exhausted", + "requestedAt": "2026-07-26T12:31:12.000Z", + "respondedAt": "2026-07-26T12:31:22.000Z", + "responseStatusCode": 500, + "responseExcerpt": "internal error", + "diagnostics": [ + { + "code": "webhook.delivery.attempts_exhausted", + "safeMessage": "Delivery attempts are exhausted. Replay manually after fixing the destination.", + "severity": "error", + "retryable": false, + "correlationId": "fixture-correlation-0104" + } + ] + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/deliveries/failed-retry-scheduled.json b/protocol/fixtures/billing-state-webhook/v1/deliveries/failed-retry-scheduled.json new file mode 100644 index 00000000..ce8607e4 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/deliveries/failed-retry-scheduled.json @@ -0,0 +1,27 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "webhookDeliveryAttempt", + "payload": { + "destinationId": "fixture-destination-0001", + "maxAttempts": 8, + "deliveryId": "fixture-delivery-0003", + "eventId": "fixture-event-0002", + "attempt": 1, + "status": "failed", + "requestedAt": "2026-08-01T09:00:06.000Z", + "respondedAt": "2026-08-01T09:00:11.000Z", + "responseStatusCode": 503, + "responseExcerpt": "upstream temporarily unavailable", + "nextAttemptAt": "2026-08-01T09:00:41.000Z", + "diagnostics": [ + { + "code": "webhook.delivery.destination_unavailable", + "safeMessage": "The destination returned 503. Customer state is unchanged; delivery will retry.", + "severity": "warning", + "retryable": true, + "retryAfterSeconds": 30, + "correlationId": "fixture-correlation-0101" + } + ] + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/deliveries/pending.json b/protocol/fixtures/billing-state-webhook/v1/deliveries/pending.json new file mode 100644 index 00000000..1caa9cac --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/deliveries/pending.json @@ -0,0 +1,13 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "webhookDeliveryAttempt", + "payload": { + "destinationId": "fixture-destination-0001", + "maxAttempts": 8, + "deliveryId": "fixture-delivery-0001", + "eventId": "fixture-event-0001", + "attempt": 1, + "status": "pending", + "requestedAt": "2026-07-01T09:00:04.000Z" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/deliveries/retry-same-event-id.json b/protocol/fixtures/billing-state-webhook/v1/deliveries/retry-same-event-id.json new file mode 100644 index 00000000..cf4b14cf --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/deliveries/retry-same-event-id.json @@ -0,0 +1,15 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "webhookDeliveryAttempt", + "payload": { + "destinationId": "fixture-destination-0001", + "maxAttempts": 8, + "deliveryId": "fixture-delivery-0004", + "eventId": "fixture-event-0002", + "attempt": 2, + "status": "succeeded", + "requestedAt": "2026-08-01T09:00:41.000Z", + "respondedAt": "2026-08-01T09:00:42.000Z", + "responseStatusCode": 200 + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/deliveries/skipped-destination-disabled.json b/protocol/fixtures/billing-state-webhook/v1/deliveries/skipped-destination-disabled.json new file mode 100644 index 00000000..4fcc0506 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/deliveries/skipped-destination-disabled.json @@ -0,0 +1,14 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "webhookDeliveryAttempt", + "payload": { + "destinationId": "fixture-destination-0001", + "maxAttempts": 8, + "deliveryId": "fixture-delivery-0006", + "eventId": "fixture-event-0006", + "attempt": 1, + "status": "skipped", + "requestedAt": "2026-07-25T18:00:10.000Z", + "skippedReason": "destination_disabled" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/deliveries/succeeded.json b/protocol/fixtures/billing-state-webhook/v1/deliveries/succeeded.json new file mode 100644 index 00000000..11ff32bf --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/deliveries/succeeded.json @@ -0,0 +1,15 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "webhookDeliveryAttempt", + "payload": { + "destinationId": "fixture-destination-0001", + "maxAttempts": 8, + "deliveryId": "fixture-delivery-0002", + "eventId": "fixture-event-0001", + "attempt": 1, + "status": "succeeded", + "requestedAt": "2026-07-01T09:00:04.000Z", + "respondedAt": "2026-07-01T09:00:04.000Z", + "responseStatusCode": 200 + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/events/entitlement-activated.json b/protocol/fixtures/billing-state-webhook/v1/events/entitlement-activated.json new file mode 100644 index 00000000..ba1a1239 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/events/entitlement-activated.json @@ -0,0 +1,35 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "initial_projection", + "isTestSource": false, + "eventId": "fixture-event-0001", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 1, + "occurredAt": "2026-07-01T09:00:00.000Z", + "createdAt": "2026-07-01T09:00:03.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "absent", + "currentState": "active" + } + ], + "stateSummary": { + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0100" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/events/entitlement-deactivated.json b/protocol/fixtures/billing-state-webhook/v1/events/entitlement-deactivated.json new file mode 100644 index 00000000..e34f6846 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/events/entitlement-deactivated.json @@ -0,0 +1,36 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "source_ended", + "isTestSource": false, + "eventId": "fixture-event-0002", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 9, + "previousSnapshotVersion": 8, + "occurredAt": "2026-08-01T09:00:00.000Z", + "createdAt": "2026-08-01T09:00:05.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "active", + "currentState": "inactive" + } + ], + "stateSummary": { + "accessState": "inactive", + "lifecycleState": "expired", + "renewalIntent": "auto_renew_disabled", + "billingState": "failed", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0101" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/events/expiry-extended.json b/protocol/fixtures/billing-state-webhook/v1/events/expiry-extended.json new file mode 100644 index 00000000..43f573bb --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/events/expiry-extended.json @@ -0,0 +1,36 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "subscription_period_changed", + "isTestSource": false, + "eventId": "fixture-event-0003", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 14, + "previousSnapshotVersion": 13, + "occurredAt": "2026-08-01T09:00:00.000Z", + "createdAt": "2026-08-01T09:00:04.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "active", + "currentState": "active" + } + ], + "stateSummary": { + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0102" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/events/refund.json b/protocol/fixtures/billing-state-webhook/v1/events/refund.json new file mode 100644 index 00000000..55178f9d --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/events/refund.json @@ -0,0 +1,36 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "refund_applied", + "isTestSource": false, + "eventId": "fixture-event-0005", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 11, + "previousSnapshotVersion": 10, + "occurredAt": "2026-07-26T08:30:00.000Z", + "createdAt": "2026-07-26T08:31:12.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "active", + "currentState": "inactive" + } + ], + "stateSummary": { + "accessState": "inactive", + "lifecycleState": "refunded", + "renewalIntent": "auto_renew_disabled", + "billingState": "refunded", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0104" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/events/revocation.json b/protocol/fixtures/billing-state-webhook/v1/events/revocation.json new file mode 100644 index 00000000..dda4e907 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/events/revocation.json @@ -0,0 +1,36 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "revocation_applied", + "isTestSource": false, + "eventId": "fixture-event-0006", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 10, + "previousSnapshotVersion": 9, + "occurredAt": "2026-07-25T18:00:00.000Z", + "createdAt": "2026-07-25T18:00:09.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "active", + "currentState": "inactive" + } + ], + "stateSummary": { + "accessState": "inactive", + "lifecycleState": "revoked", + "renewalIntent": "auto_renew_disabled", + "billingState": "revoked", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0105" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/events/subscription-cancelled-access-active.json b/protocol/fixtures/billing-state-webhook/v1/events/subscription-cancelled-access-active.json new file mode 100644 index 00000000..7a3e38fc --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/events/subscription-cancelled-access-active.json @@ -0,0 +1,36 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "renewal_intent_changed", + "isTestSource": false, + "eventId": "fixture-event-0004", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 5, + "previousSnapshotVersion": 4, + "occurredAt": "2026-07-20T16:42:00.000Z", + "createdAt": "2026-07-20T16:42:07.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "active", + "currentState": "active" + } + ], + "stateSummary": { + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_disabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0103" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/events/unknown-state-transition.json b/protocol/fixtures/billing-state-webhook/v1/events/unknown-state-transition.json new file mode 100644 index 00000000..01f72096 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/events/unknown-state-transition.json @@ -0,0 +1,47 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "identity_conflict_opened", + "isTestSource": false, + "eventId": "fixture-event-0007", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 12, + "previousSnapshotVersion": 11, + "occurredAt": "2026-07-28T10:30:00.000Z", + "createdAt": "2026-07-28T10:30:02.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "active", + "currentState": "unknown" + } + ], + "stateSummary": { + "accessState": "unknown", + "lifecycleState": "unknown", + "renewalIntent": "unknown", + "billingState": "unknown", + "uncertainty": { + "reason": "identity_unresolved", + "since": "2026-07-28T10:30:00.000Z", + "diagnosticCode": "entitlement.identity.conflict_open" + } + }, + "correlationId": "fixture-correlation-0106", + "diagnostics": [ + { + "code": "entitlement.identity.conflict_open", + "safeMessage": "Access is unknown, not revoked. Re-read the snapshot; do not downgrade the user.", + "severity": "warning", + "retryable": false, + "correlationId": "fixture-correlation-0106" + } + ] + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/invalid/created-before-it-occurred.json b/protocol/fixtures/billing-state-webhook/v1/invalid/created-before-it-occurred.json new file mode 100644 index 00000000..28a87a78 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/invalid/created-before-it-occurred.json @@ -0,0 +1,35 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "initial_projection", + "isTestSource": false, + "eventId": "fixture-event-0001", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 1, + "occurredAt": "2026-07-01T09:00:00.000Z", + "createdAt": "2026-07-01T08:59:00.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "absent", + "currentState": "active" + } + ], + "stateSummary": { + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0100" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/invalid/exhausted-before-attempts-ran-out.json b/protocol/fixtures/billing-state-webhook/v1/invalid/exhausted-before-attempts-ran-out.json new file mode 100644 index 00000000..9c255bd5 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/invalid/exhausted-before-attempts-ran-out.json @@ -0,0 +1,25 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "webhookDeliveryAttempt", + "payload": { + "destinationId": "fixture-destination-0001", + "maxAttempts": 8, + "deliveryId": "fixture-delivery-0005", + "eventId": "fixture-event-0005", + "attempt": 3, + "status": "exhausted", + "requestedAt": "2026-07-26T12:31:12.000Z", + "respondedAt": "2026-07-26T12:31:22.000Z", + "responseStatusCode": 500, + "responseExcerpt": "internal error", + "diagnostics": [ + { + "code": "webhook.delivery.attempts_exhausted", + "safeMessage": "Delivery attempts are exhausted. Replay manually after fixing the destination.", + "severity": "error", + "retryable": false, + "correlationId": "fixture-correlation-0104" + } + ] + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/invalid/missing-snapshot-version.json b/protocol/fixtures/billing-state-webhook/v1/invalid/missing-snapshot-version.json new file mode 100644 index 00000000..0f7ae248 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/invalid/missing-snapshot-version.json @@ -0,0 +1,34 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "initial_projection", + "isTestSource": false, + "eventId": "fixture-event-0001", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "occurredAt": "2026-07-01T09:00:00.000Z", + "createdAt": "2026-07-01T09:00:03.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "absent", + "currentState": "active" + } + ], + "stateSummary": { + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0100" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/invalid/provider-payload-embedded.json b/protocol/fixtures/billing-state-webhook/v1/invalid/provider-payload-embedded.json new file mode 100644 index 00000000..e1761aa5 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/invalid/provider-payload-embedded.json @@ -0,0 +1,39 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "initial_projection", + "isTestSource": false, + "eventId": "fixture-event-0001", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 1, + "occurredAt": "2026-07-01T09:00:00.000Z", + "createdAt": "2026-07-01T09:00:03.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "absent", + "currentState": "active" + } + ], + "stateSummary": { + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0100", + "providerNotification": { + "signedPayload": "provider-notification-body", + "notificationType": "DID_RENEW" + } + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/invalid/raw-purchase-token-in-event.json b/protocol/fixtures/billing-state-webhook/v1/invalid/raw-purchase-token-in-event.json new file mode 100644 index 00000000..e5890f28 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/invalid/raw-purchase-token-in-event.json @@ -0,0 +1,35 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "initial_projection", + "isTestSource": false, + "eventId": "fixture-event-0001", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 1, + "occurredAt": "2026-07-01T09:00:00.000Z", + "createdAt": "2026-07-01T09:00:03.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "absent", + "currentState": "active" + } + ], + "stateSummary": { + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "eyJhbGciOiJFUzI1NiJ9.eyJwdXJjaGFzZVRva2VuIjoiZmFrZS10b2tlbiJ9.c2lnbmF0dXJl" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/invalid/rejection-layers.json b/protocol/fixtures/billing-state-webhook/v1/invalid/rejection-layers.json new file mode 100644 index 00000000..55089472 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/invalid/rejection-layers.json @@ -0,0 +1,14 @@ +{ + "contract": "Billing State Webhook v1", + "description": "Generated by tools/generate-rejection-layers.mjs. For each invalid fixture, the layer that rejects it: \"schema\" means the canonical JSON Schema alone rejects it; \"semantic\" means the schema accepts it and a semantic validator rule rejects it. See docs/protocol/fixture-lifecycle.md.", + "layers": { + "created-before-it-occurred.json": "semantic", + "exhausted-before-attempts-ran-out.json": "semantic", + "missing-snapshot-version.json": "schema", + "provider-payload-embedded.json": "schema", + "raw-purchase-token-in-event.json": "semantic", + "response-excerpt-too-long.json": "schema", + "snapshot-version-regresses.json": "semantic", + "unknown-event-type.json": "schema" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/invalid/response-excerpt-too-long.json b/protocol/fixtures/billing-state-webhook/v1/invalid/response-excerpt-too-long.json new file mode 100644 index 00000000..95d5a790 --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/invalid/response-excerpt-too-long.json @@ -0,0 +1,27 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "webhookDeliveryAttempt", + "payload": { + "destinationId": "fixture-destination-0001", + "maxAttempts": 8, + "deliveryId": "fixture-delivery-0003", + "eventId": "fixture-event-0002", + "attempt": 1, + "status": "failed", + "requestedAt": "2026-08-01T09:00:06.000Z", + "respondedAt": "2026-08-01T09:00:11.000Z", + "responseStatusCode": 503, + "responseExcerpt": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "nextAttemptAt": "2026-08-01T09:00:41.000Z", + "diagnostics": [ + { + "code": "webhook.delivery.destination_unavailable", + "safeMessage": "The destination returned 503. Customer state is unchanged; delivery will retry.", + "severity": "warning", + "retryable": true, + "retryAfterSeconds": 30, + "correlationId": "fixture-correlation-0101" + } + ] + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/invalid/snapshot-version-regresses.json b/protocol/fixtures/billing-state-webhook/v1/invalid/snapshot-version-regresses.json new file mode 100644 index 00000000..60d6598e --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/invalid/snapshot-version-regresses.json @@ -0,0 +1,36 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "customer.entitlements.changed", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "source_ended", + "isTestSource": false, + "eventId": "fixture-event-0002", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 9, + "previousSnapshotVersion": 11, + "occurredAt": "2026-08-01T09:00:00.000Z", + "createdAt": "2026-08-01T09:00:05.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "active", + "currentState": "inactive" + } + ], + "stateSummary": { + "accessState": "inactive", + "lifecycleState": "expired", + "renewalIntent": "auto_renew_disabled", + "billingState": "failed", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0101" + } +} diff --git a/protocol/fixtures/billing-state-webhook/v1/invalid/unknown-event-type.json b/protocol/fixtures/billing-state-webhook/v1/invalid/unknown-event-type.json new file mode 100644 index 00000000..426bd04d --- /dev/null +++ b/protocol/fixtures/billing-state-webhook/v1/invalid/unknown-event-type.json @@ -0,0 +1,35 @@ +{ + "billingStateWebhookContractVersion": "1", + "recordType": "billingStateEvent", + "payload": { + "eventType": "apple.subscription.did_renew", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "projectionRuleVersion": 1, + "sourceReason": "initial_projection", + "isTestSource": false, + "eventId": "fixture-event-0001", + "subscriptionInstanceId": "fixture-subscription-instance-0001", + "snapshotVersion": 1, + "occurredAt": "2026-07-01T09:00:00.000Z", + "createdAt": "2026-07-01T09:00:03.000Z", + "changedEntitlements": [ + { + "entitlementKey": "pro", + "previousState": "absent", + "currentState": "active" + } + ], + "stateSummary": { + "accessState": "active", + "lifecycleState": "active", + "renewalIntent": "auto_renew_enabled", + "billingState": "current", + "uncertainty": { + "reason": "none" + } + }, + "correlationId": "fixture-correlation-0100" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/invalid/active-metadata-carries-revocation.json b/protocol/fixtures/customer-access-token/v1/invalid/active-metadata-carries-revocation.json new file mode 100644 index 00000000..3d8e74b1 --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/invalid/active-metadata-carries-revocation.json @@ -0,0 +1,24 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256", + "lastUsedAt": "2026-07-28T12:04:11.000Z", + "revokedAt": "2026-07-28T12:20:00.000Z", + "revocationReason": "operator_revoked" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/invalid/issuer-carries-signed-payload-value.json b/protocol/fixtures/customer-access-token/v1/invalid/issuer-carries-signed-payload-value.json new file mode 100644 index 00000000..cdefb5ca --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/invalid/issuer-carries-signed-payload-value.json @@ -0,0 +1,22 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtb3NhaWMuZXhhbXBsZS5jb20ifQ.c2lnbmF0dXJl", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256", + "lastUsedAt": "2026-07-28T12:04:11.000Z" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/invalid/rejection-layers.json b/protocol/fixtures/customer-access-token/v1/invalid/rejection-layers.json new file mode 100644 index 00000000..00caeb56 --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/invalid/rejection-layers.json @@ -0,0 +1,15 @@ +{ + "contract": "Customer Access Token v1", + "description": "Generated by tools/generate-rejection-layers.mjs. For each invalid fixture, the layer that rejects it: \"schema\" means the canonical JSON Schema alone rejects it; \"semantic\" means the schema accepts it and a semantic validator rule rejects it. See docs/protocol/fixture-lifecycle.md.", + "layers": { + "active-metadata-carries-revocation.json": "schema", + "issuer-carries-signed-payload-value.json": "semantic", + "revoked-metadata-without-reason.json": "schema", + "token-audience-not-sdk-sync.json": "schema", + "token-carries-entitlement-claims.json": "schema", + "token-expires-before-it-is-issued.json": "semantic", + "token-lifetime-exceeds-maximum.json": "semantic", + "token-missing-customer-binding.json": "schema", + "token-shaped-as-signed-payload.json": "schema" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/invalid/revoked-metadata-without-reason.json b/protocol/fixtures/customer-access-token/v1/invalid/revoked-metadata-without-reason.json new file mode 100644 index 00000000..8f9b183d --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/invalid/revoked-metadata-without-reason.json @@ -0,0 +1,23 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "revoked", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256", + "lastUsedAt": "2026-07-28T12:04:11.000Z", + "revokedAt": "2026-07-28T12:20:00.000Z" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/invalid/token-audience-not-sdk-sync.json b/protocol/fixtures/customer-access-token/v1/invalid/token-audience-not-sdk-sync.json new file mode 100644 index 00000000..b0047a5f --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/invalid/token-audience-not-sdk-sync.json @@ -0,0 +1,22 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "dashboard_admin", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256", + "lastUsedAt": "2026-07-28T12:04:11.000Z" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/invalid/token-carries-entitlement-claims.json b/protocol/fixtures/customer-access-token/v1/invalid/token-carries-entitlement-claims.json new file mode 100644 index 00000000..ef95ea63 --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/invalid/token-carries-entitlement-claims.json @@ -0,0 +1,28 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256", + "lastUsedAt": "2026-07-28T12:04:11.000Z", + "entitlements": [ + { + "entitlementKey": "pro", + "state": "active" + } + ] + } +} diff --git a/protocol/fixtures/customer-access-token/v1/invalid/token-expires-before-it-is-issued.json b/protocol/fixtures/customer-access-token/v1/invalid/token-expires-before-it-is-issued.json new file mode 100644 index 00000000..d1343c15 --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/invalid/token-expires-before-it-is-issued.json @@ -0,0 +1,22 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T11:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256", + "lastUsedAt": "2026-07-28T12:04:11.000Z" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/invalid/token-lifetime-exceeds-maximum.json b/protocol/fixtures/customer-access-token/v1/invalid/token-lifetime-exceeds-maximum.json new file mode 100644 index 00000000..1ae20566 --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/invalid/token-lifetime-exceeds-maximum.json @@ -0,0 +1,22 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-30T12:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256", + "lastUsedAt": "2026-07-28T12:04:11.000Z" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/invalid/token-missing-customer-binding.json b/protocol/fixtures/customer-access-token/v1/invalid/token-missing-customer-binding.json new file mode 100644 index 00000000..c65d9e3e --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/invalid/token-missing-customer-binding.json @@ -0,0 +1,21 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256", + "lastUsedAt": "2026-07-28T12:04:11.000Z" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/invalid/token-shaped-as-signed-payload.json b/protocol/fixtures/customer-access-token/v1/invalid/token-shaped-as-signed-payload.json new file mode 100644 index 00000000..5ac5be88 --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/invalid/token-shaped-as-signed-payload.json @@ -0,0 +1,25 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenIssuanceResult", + "payload": { + "token": "eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlLWN1c3RvbWVyLTAwMDEifQ.c2lnbmF0dXJl", + "metadata": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256" + }, + "correlationId": "fixture-correlation-0200" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/tokens/issuance-request.json b/protocol/fixtures/customer-access-token/v1/tokens/issuance-request.json new file mode 100644 index 00000000..3c66d2dd --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/tokens/issuance-request.json @@ -0,0 +1,14 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenIssuanceRequest", + "payload": { + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "requestedTtlSeconds": 3600, + "correlationId": "fixture-correlation-0200" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/tokens/issuance-result.json b/protocol/fixtures/customer-access-token/v1/tokens/issuance-result.json new file mode 100644 index 00000000..f30418b9 --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/tokens/issuance-result.json @@ -0,0 +1,25 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenIssuanceResult", + "payload": { + "token": "mcat_m1BS3Cxs-ZgaX6Zs0f4cNR0Bt6HyEvRCXTD1GePW0Ik", + "metadata": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256" + }, + "correlationId": "fixture-correlation-0200" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/tokens/metadata-active.json b/protocol/fixtures/customer-access-token/v1/tokens/metadata-active.json new file mode 100644 index 00000000..647502fb --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/tokens/metadata-active.json @@ -0,0 +1,22 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0001", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256", + "lastUsedAt": "2026-07-28T12:04:11.000Z" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/tokens/metadata-restore-scope.json b/protocol/fixtures/customer-access-token/v1/tokens/metadata-restore-scope.json new file mode 100644 index 00000000..29110ea2 --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/tokens/metadata-restore-scope.json @@ -0,0 +1,22 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0003", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync", + "restore.request" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "active", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/tokens/metadata-revoked.json b/protocol/fixtures/customer-access-token/v1/tokens/metadata-revoked.json new file mode 100644 index 00000000..6bed2989 --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/tokens/metadata-revoked.json @@ -0,0 +1,24 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenMetadata", + "payload": { + "tokenId": "fixture-token-0002", + "projectId": "fixture-project-mosaic", + "environmentId": "fixture-environment-production", + "billingCustomerId": "fixture-customer-0001", + "audience": "sdk_sync", + "scopes": [ + "entitlements.read", + "entitlements.sync" + ], + "issuer": "mosaic.example.com", + "issuedAt": "2026-07-28T12:00:00.000Z", + "expiresAt": "2026-07-28T13:00:00.000Z", + "status": "revoked", + "tokenPrefix": "mcat_", + "digestAlgorithm": "sha256", + "revokedAt": "2026-07-28T12:20:00.000Z", + "revocationReason": "customer_signed_out", + "lastUsedAt": "2026-07-28T12:19:02.000Z" + } +} diff --git a/protocol/fixtures/customer-access-token/v1/tokens/revocation-identity-changed.json b/protocol/fixtures/customer-access-token/v1/tokens/revocation-identity-changed.json new file mode 100644 index 00000000..a4536d2f --- /dev/null +++ b/protocol/fixtures/customer-access-token/v1/tokens/revocation-identity-changed.json @@ -0,0 +1,11 @@ +{ + "customerAccessTokenContractVersion": "1", + "recordType": "customerAccessTokenRevocation", + "payload": { + "tokenId": "fixture-token-0002", + "revokedAt": "2026-07-28T12:20:00.000Z", + "revocationReason": "identity_changed", + "actorReference": "fixture-actor-system", + "correlationId": "fixture-correlation-0201" + } +} diff --git a/protocol/schema/authoritative-entitlement/v1/check.schema.json b/protocol/schema/authoritative-entitlement/v1/check.schema.json new file mode 100644 index 00000000..b965836b --- /dev/null +++ b/protocol/schema/authoritative-entitlement/v1/check.schema.json @@ -0,0 +1,264 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:check", + "title": "Mosaic Authoritative Entitlement Contract v1 entitlement check", + "description": "The focused multi-key access question an application backend asks, and its answer. The answer is never a bare boolean: every key carries a state, an explanation, and the snapshot version and as-of instant the answer was derived from.", + "type": "object", + "additionalProperties": false, + "required": [ + "authoritativeEntitlementContractVersion", + "recordType", + "payload" + ], + "properties": { + "authoritativeEntitlementContractVersion": { + "const": "1" + }, + "recordType": { + "enum": [ + "entitlementCheckRequest", + "entitlementCheckResult" + ] + }, + "payload": {} + }, + "allOf": [ + { + "if": { + "properties": { + "recordType": { + "const": "entitlementCheckRequest" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/entitlementCheckRequest" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "entitlementCheckResult" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/entitlementCheckResult" + } + } + } + } + ], + "$defs": { + "entitlementCheckRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "billingCustomerId", + "entitlementKeys", + "supportedAuthoritativeEntitlementContracts", + "correlationId" + ], + "properties": { + "billingCustomerId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "entitlementKeys": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/entitlementKey" + } + }, + "expectedSnapshotVersion": { + "description": "The version the caller believes is current, used to detect that it is reading behind its own writes. A newer server version is answered normally; it is never an error.", + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/snapshotVersion" + }, + "supportedAuthoritativeEntitlementContracts": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/supportedContractVersions" + }, + "correlationId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + } + } + }, + "entitlementCheckResultEntry": { + "description": "One answered key. This is the only place unavailable is admissible for an Entitlement: it says Mosaic could not answer, not that the customer lacks access.", + "type": "object", + "additionalProperties": false, + "required": [ + "entitlementKey", + "state", + "sourceCount", + "endKnown", + "primaryExplanation" + ], + "properties": { + "entitlementKey": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/entitlementKey" + }, + "state": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/accessState" + }, + "effectiveStart": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "effectiveEnd": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "endKnown": { + "type": "boolean" + }, + "sourceCount": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "sourceIds": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + } + }, + "primaryExplanation": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/primaryExplanation" + }, + "uncertainty": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/uncertainty" + }, + "isTestSource": { + "type": "boolean" + } + }, + "allOf": [ + { + "if": { + "properties": { + "state": { + "enum": [ + "unknown", + "unavailable" + ] + } + }, + "required": [ + "state" + ] + }, + "then": { + "required": [ + "uncertainty" + ], + "properties": { + "uncertainty": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/definiteUncertainty" + } + } + } + }, + { + "if": { + "properties": { + "endKnown": { + "const": false + } + }, + "required": [ + "endKnown" + ] + }, + "then": { + "not": { + "required": [ + "effectiveEnd" + ] + } + } + } + ] + }, + "entitlementCheckResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "billingCustomerId", + "projectId", + "environmentId", + "issuedAt", + "results", + "correlationId" + ], + "properties": { + "billingCustomerId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "projectId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "environmentId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "snapshotVersion": { + "description": "The snapshot the answer was derived from. Absent only when no snapshot could be read at all, in which case every result is unavailable.", + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/snapshotVersion" + }, + "projectionRuleVersion": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/projectionRuleVersion" + }, + "issuedAt": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "asOf": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "results": { + "description": "Ascending by entitlementKey, unique by entitlementKey, one entry for every requested key.", + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { + "$ref": "#/$defs/entitlementCheckResultEntry" + } + }, + "projectionStatus": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/projectionStatus" + }, + "correlationId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "diagnostics": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/diagnostics" + } + }, + "if": { + "required": [ + "snapshotVersion" + ] + }, + "then": { + "required": [ + "asOf", + "projectionRuleVersion", + "projectionStatus" + ] + } + } + } +} diff --git a/protocol/schema/authoritative-entitlement/v1/compatibility-manifest.schema.json b/protocol/schema/authoritative-entitlement/v1/compatibility-manifest.schema.json new file mode 100644 index 00000000..b7cbd21e --- /dev/null +++ b/protocol/schema/authoritative-entitlement/v1/compatibility-manifest.schema.json @@ -0,0 +1,430 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:compatibility-manifest", + "title": "Mosaic Authoritative Entitlement Contract v1 compatibility manifest", + "description": "Machine-checked compatibility surface for Authoritative Entitlement Contract 1. Every reader obligation of this contract is pinned here as a const, so the fail-closed-to-unknown rule is enforced rather than merely documented.", + "type": "object", + "additionalProperties": false, + "required": [ + "authoritativeEntitlementContractVersion", + "status", + "schemas", + "canonicalFixtures", + "recordTypes", + "stateAxes", + "canonicalSerialization", + "limits", + "readerPolicy" + ], + "properties": { + "authoritativeEntitlementContractVersion": { + "const": "1" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "releaseCandidate", + "approved", + "deprecated", + "retired" + ] + }, + "deprecation": { + "type": "object", + "description": "Lifecycle metadata present only once this contract version enters the deprecated or retired state. See docs/protocol/deprecation-policy.md.", + "additionalProperties": false, + "required": [ + "deprecatedAt", + "retiresAt" + ], + "properties": { + "deprecatedAt": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$" + }, + "retiresAt": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$" + }, + "supersededBy": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "migrationGuide": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "schemas": { + "type": "object", + "additionalProperties": false, + "required": [ + "snapshot", + "syncRequest", + "check", + "subscription", + "restore" + ], + "properties": { + "snapshot": { + "$ref": "#/$defs/relativeJsonPath" + }, + "syncRequest": { + "$ref": "#/$defs/relativeJsonPath" + }, + "check": { + "$ref": "#/$defs/relativeJsonPath" + }, + "subscription": { + "$ref": "#/$defs/relativeJsonPath" + }, + "restore": { + "$ref": "#/$defs/relativeJsonPath" + } + } + }, + "canonicalFixtures": { + "type": "array", + "minItems": 24, + "maxItems": 80, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/relativeJsonPath" + } + }, + "recordTypes": { + "description": "The record-type set is closed by the manifest as well as by the schemas. Adding a member is a breaking change requiring Authoritative Entitlement 2.", + "type": "array", + "minItems": 7, + "maxItems": 7, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/recordType" + } + }, + "stateAxes": { + "description": "The four state axes plus the uncertainty vocabulary, pinned so a reader's own enumerations can be reconciled against the contract mechanically. Every axis is closed and deliberately over-provisioned.", + "type": "object", + "additionalProperties": false, + "required": [ + "accessState", + "lifecycleState", + "renewalIntent", + "billingState", + "uncertaintyReason", + "persistedEntitlementState" + ], + "properties": { + "accessState": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/accessState" + } + }, + "lifecycleState": { + "type": "array", + "minItems": 10, + "maxItems": 10, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/lifecycleState" + } + }, + "renewalIntent": { + "type": "array", + "minItems": 5, + "maxItems": 5, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/renewalIntent" + } + }, + "billingState": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/billingState" + } + }, + "uncertaintyReason": { + "type": "array", + "minItems": 9, + "maxItems": 9, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/uncertaintyReason" + } + }, + "persistedEntitlementState": { + "description": "The states an immutable snapshot entry may carry. unavailable is absent by construction: it is a read-time service state.", + "type": "array", + "minItems": 3, + "maxItems": 3, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/persistedEntitlementState" + } + } + } + }, + "canonicalSerialization": { + "description": "The exact serialization the contentDigest and checksum members are computed over. It is pinned because five independent implementations must produce byte-identical input or the digests silently stop meaning anything.", + "type": "object", + "additionalProperties": false, + "required": [ + "form", + "hash", + "encoding", + "output", + "keyOrdering", + "arrayOrdering", + "timestampPrecision", + "absentVersusNull", + "numberForm", + "excludedMembers" + ], + "properties": { + "form": { + "const": "minifiedJsonSortedKeys" + }, + "hash": { + "const": "SHA-256" + }, + "encoding": { + "const": "UTF-8" + }, + "output": { + "const": "sha256_prefixed_lowercase_hex" + }, + "keyOrdering": { + "const": "ascendingByUtf16CodeUnit" + }, + "arrayOrdering": { + "description": "Array order is significant and is part of the contract: entries ascend by entitlementKey and sources ascend by sourceId, so two producers of identical state produce identical bytes.", + "const": "documentOrderIsNormative" + }, + "timestampPrecision": { + "const": "exactlyThreeFractionalDigits" + }, + "absentVersusNull": { + "description": "An absent member and a null member are not the same thing, and null is never emitted anywhere in this contract. A serializer that writes null for an absent optional produces a different digest.", + "const": "absentOnlyNullForbidden" + }, + "numberForm": { + "const": "shortestIntegerNoExponent" + }, + "excludedMembers": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "enum": [ + "contentDigest", + "checksum" + ] + } + } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": [ + "maxRecordBytes", + "maxEntriesPerSnapshot", + "maxSourcesPerSnapshot", + "maxSourcesPerEntry", + "maxEntitlementKeysPerRequest", + "maxDiagnosticsPerRecord", + "clockSkewToleranceSeconds", + "defaultRefreshAfterSeconds", + "defaultValidUntilSeconds", + "defaultStaleGraceSeconds", + "maxValidUntilSeconds", + "maxStaleGraceSeconds", + "maxCacheHorizonSeconds", + "restorePollAttempts", + "restorePollBudgetSeconds" + ], + "properties": { + "maxRecordBytes": { + "const": 65536 + }, + "maxEntriesPerSnapshot": { + "const": 200 + }, + "maxSourcesPerSnapshot": { + "const": 200 + }, + "maxSourcesPerEntry": { + "const": 64 + }, + "maxEntitlementKeysPerRequest": { + "const": 64 + }, + "maxDiagnosticsPerRecord": { + "const": 10 + }, + "clockSkewToleranceSeconds": { + "const": 60 + }, + "defaultRefreshAfterSeconds": { + "const": 3600 + }, + "defaultValidUntilSeconds": { + "const": 604800 + }, + "defaultStaleGraceSeconds": { + "description": "Bounded grace is the shipped offline policy (OD-5), so the default grace window is 24 hours rather than zero. A zero default would ship the strict policy under a bounded-grace decision. In Phase 9B this and the other two freshness settings are configured deployment-wide server-side, not per Environment; per-Environment configuration is a tracked follow-up. Strict remains expressible as a grace window of zero.", + "const": 86400 + }, + "maxValidUntilSeconds": { + "const": 2592000 + }, + "maxStaleGraceSeconds": { + "const": 2592000 + }, + "maxCacheHorizonSeconds": { + "description": "The hard bound is on the COMBINED horizon: (validUntil - issuedAt) + staleGraceSeconds may never exceed 30 days. The two individual maxima above bound each field, but only this bound stops a 30-day validity and a 30-day grace window from composing into 60 days of offline access. Enforced by the semantic validator.", + "const": 2592000 + }, + "restorePollAttempts": { + "const": 3 + }, + "restorePollBudgetSeconds": { + "const": 6 + } + } + }, + "readerPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "unknownContractVersion", + "unknownRecordType", + "unknownField", + "unknownAccessState", + "unknownLifecycleState", + "unknownUncertaintyReason", + "unknownExplanationCode", + "unknownChangeReason", + "unknownSourceType", + "unknownEntitlementKey", + "rejectedRecord", + "inactiveInference", + "olderSnapshotVersion", + "customerBindingMismatch", + "contentDigestMismatch", + "expiredCache", + "neverProjectedCustomer", + "unchangedResponse", + "entityTagOrdering", + "unavailableInSnapshotEntry", + "billingDisabled", + "snapshotAsCredential", + "providerStatusString", + "partialAcceptance" + ], + "properties": { + "unknownContractVersion": { + "const": "rejectRecord" + }, + "unknownRecordType": { + "const": "rejectRecord" + }, + "unknownField": { + "const": "rejectRecord" + }, + "unknownAccessState": { + "const": "rejectRecord" + }, + "unknownLifecycleState": { + "const": "rejectRecord" + }, + "unknownUncertaintyReason": { + "const": "rejectRecord" + }, + "unknownExplanationCode": { + "const": "rejectRecord" + }, + "unknownChangeReason": { + "const": "rejectRecord" + }, + "unknownSourceType": { + "const": "rejectRecord" + }, + "unknownEntitlementKey": { + "description": "Entitlement keys are Project data, not contract vocabulary. A key a reader has never seen is carried, not rejected: rejecting it would make defining a new Entitlement a breaking change for every already-shipped SDK.", + "const": "acceptAsProjectData" + }, + "rejectedRecord": { + "description": "The normative rule of this contract. However a record is rejected -- unknown version, unknown field, unknown enumeration member, digest mismatch, regression -- the reader reports accessState unknown and preserves whatever cache it already had. It never derives inactive from a rejection.", + "const": "reportUnknownPreserveCache" + }, + "inactiveInference": { + "description": "inactive is only ever the result of a snapshot Mosaic issued and the reader fully accepted. It is never inferred from a failure, a timeout, an expiry, a rejection, or an absence.", + "const": "forbidden" + }, + "olderSnapshotVersion": { + "const": "rejectPreserveCache" + }, + "customerBindingMismatch": { + "description": "A snapshot for another Billing Customer, Project, or Environment is not merely rejected: the reader clears its cache, because continuing to serve the previous customer's access after an identity change is the leak this rule exists to prevent.", + "const": "clearCacheReportUnknown" + }, + "contentDigestMismatch": { + "const": "rejectPreserveCache" + }, + "expiredCache": { + "const": "reportUnknownNeverInactive" + }, + "neverProjectedCustomer": { + "description": "A Billing Customer no projection has ever run for is answered with a customerEntitlementSnapshot at snapshotVersion 0, not with an error and not with an empty version 1. The placeholder is cacheable like any other snapshot, and because issued versions start at 1 it sorts below everything that can supersede it, so the ordinary monotonic gate replaces it and no reader needs a special case. Its entries are empty, which the contract already reads as unknown per key rather than inactive.", + "const": "placeholderSnapshotVersionZero" + }, + "unchangedResponse": { + "const": "preserveCacheSlideFreshness" + }, + "entityTagOrdering": { + "description": "The entity tag is an opaque equality token. Monotonicity is decided by snapshotVersion alone; comparing entity tags for magnitude is meaningless.", + "const": "equalityOnly" + }, + "unavailableInSnapshotEntry": { + "const": "forbidden" + }, + "billingDisabled": { + "const": "unavailableNeverInactive" + }, + "snapshotAsCredential": { + "description": "An Access Decision Snapshot is a read model. It authorizes nothing, and a backend must never accept one presented by a client as proof of access.", + "const": "forbidden" + }, + "providerStatusString": { + "const": "forbidden" + }, + "partialAcceptance": { + "description": "A snapshot is accepted whole or rejected whole. A reader never keeps the entries it understood from a document it rejected.", + "const": "forbidden" + } + } + } + }, + "$defs": { + "relativeJsonPath": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^\\.\\./\\.\\./.*\\.json$" + } + } +} diff --git a/protocol/schema/authoritative-entitlement/v1/restore.schema.json b/protocol/schema/authoritative-entitlement/v1/restore.schema.json new file mode 100644 index 00000000..c2a1dd32 --- /dev/null +++ b/protocol/schema/authoritative-entitlement/v1/restore.schema.json @@ -0,0 +1,203 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:restore", + "title": "Mosaic Authoritative Entitlement Contract v1 restore result", + "description": "The outcome of a restore, reported on two independent axes: what the native provider did, and what Mosaic's authoritative state now says. They are separate members because a successful native restore that has not yet reached an accepted snapshot is not restored access, and reporting it as one is how a restore flow starts lying.", + "type": "object", + "additionalProperties": false, + "required": [ + "authoritativeEntitlementContractVersion", + "recordType", + "payload" + ], + "properties": { + "authoritativeEntitlementContractVersion": { + "const": "1" + }, + "recordType": { + "const": "restoreResult" + }, + "payload": { + "$ref": "#/$defs/restoreResult" + } + }, + "$defs": { + "restoreOutcome": { + "description": "Mosaic's authoritative answer. restored is admissible only once an accepted snapshot reflects the restored source.", + "enum": [ + "restored", + "no_additional_purchases", + "validation_pending", + "identity_unresolved", + "product_unresolved", + "provider_unavailable", + "failed" + ] + }, + "providerOutcome": { + "description": "What the native provider restore itself did, reported separately and never merged into the authoritative outcome.", + "enum": [ + "completed", + "no_purchases_found", + "cancelled", + "failed", + "unsupported", + "not_attempted" + ] + }, + "restoreResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "restoreId", + "projectId", + "environmentId", + "storePlatform", + "outcome", + "providerOutcome", + "requestedAt", + "correlationId" + ], + "properties": { + "restoreId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "billingCustomerId": { + "description": "Absent when identity could not be resolved, which is exactly the identity_unresolved outcome.", + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "projectId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "environmentId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "storePlatform": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/storePlatform" + }, + "outcome": { + "$ref": "#/$defs/restoreOutcome" + }, + "providerOutcome": { + "$ref": "#/$defs/providerOutcome" + }, + "requestedAt": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "completedAt": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "snapshotVersion": { + "description": "The accepted snapshot that reflects the restore. Required for restored; it is the evidence that makes the outcome authoritative rather than hopeful.", + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/snapshotVersion" + }, + "observedTransactionCount": { + "description": "How many provider transaction references the restore submitted for validation. It is never evidence of access.", + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "pendingValidationCount": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "uncertainty": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/uncertainty" + }, + "correlationId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "diagnostics": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/diagnostics" + } + }, + "allOf": [ + { + "if": { + "properties": { + "outcome": { + "const": "restored" + } + }, + "required": [ + "outcome" + ] + }, + "then": { + "required": [ + "billingCustomerId", + "snapshotVersion", + "completedAt" + ] + } + }, + { + "if": { + "properties": { + "outcome": { + "const": "validation_pending" + } + }, + "required": [ + "outcome" + ] + }, + "then": { + "required": [ + "pendingValidationCount" + ] + } + }, + { + "$comment": "Every outcome that is not a definite answer carries the reason it is not, on the same uncertainty vocabulary every other surface uses.", + "if": { + "properties": { + "outcome": { + "enum": [ + "validation_pending", + "identity_unresolved", + "product_unresolved", + "provider_unavailable", + "failed" + ] + } + }, + "required": [ + "outcome" + ] + }, + "then": { + "required": [ + "uncertainty" + ], + "properties": { + "uncertainty": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/definiteUncertainty" + } + } + } + }, + { + "if": { + "properties": { + "outcome": { + "const": "identity_unresolved" + } + }, + "required": [ + "outcome" + ] + }, + "then": { + "not": { + "required": [ + "billingCustomerId" + ] + } + } + } + ] + } + } +} diff --git a/protocol/schema/authoritative-entitlement/v1/snapshot.schema.json b/protocol/schema/authoritative-entitlement/v1/snapshot.schema.json new file mode 100644 index 00000000..13ff3303 --- /dev/null +++ b/protocol/schema/authoritative-entitlement/v1/snapshot.schema.json @@ -0,0 +1,963 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot", + "title": "Mosaic Authoritative Entitlement Contract v1 customer entitlement snapshot", + "description": "The immutable authoritative view of every Entitlement a Billing Customer holds at one snapshot version, and the unchanged-response record that confirms a cached snapshot is still current. This schema also declares the shared primitives of Authoritative Entitlement Contract 1; the sync-request, check, subscription, and restore schemas reference them. No Flutter, SwiftUI, Jetpack Compose, StoreKit, or Play Billing type name appears anywhere in this contract, and no provider status string is admissible: only the closed Mosaic vocabularies below.", + "type": "object", + "additionalProperties": false, + "required": [ + "authoritativeEntitlementContractVersion", + "recordType", + "payload" + ], + "properties": { + "authoritativeEntitlementContractVersion": { + "const": "1" + }, + "recordType": { + "enum": [ + "customerEntitlementSnapshot", + "snapshotUnchanged" + ] + }, + "payload": {} + }, + "allOf": [ + { + "if": { + "properties": { + "recordType": { + "const": "customerEntitlementSnapshot" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/customerEntitlementSnapshot" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "snapshotUnchanged" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/snapshotUnchanged" + } + } + } + } + ], + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "safeText": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]*$" + }, + "utcTimestamp": { + "description": "RFC 3339 UTC instant with exactly three fractional digits and a literal Z. The fractional precision is fixed rather than optional because this contract's contentDigest is computed over the canonical serialization of the payload: a producer that emitted the same instant with a different precision would produce a different digest for identical state.", + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$" + }, + "digest": { + "description": "Lowercase hexadecimal SHA-256 digest with an explicit algorithm prefix.", + "type": "string", + "minLength": 71, + "maxLength": 71, + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "entityTag": { + "description": "Opaque HTTP validator. It is an opaque equality token only and carries no ordering: a reader compares it for equality and never for magnitude. Snapshot monotonicity is decided by snapshotVersion alone. On the wire it appears as a strong ETag, that is, this value enclosed in double quotes.", + "type": "string", + "minLength": 8, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "entitlementKey": { + "description": "Project-defined Entitlement key, reusing the Commerce Configuration key pattern unchanged so one vocabulary spans catalogue and access. Key values are project data rather than contract vocabulary: an unrecognized key is accepted and carried, never rejected.", + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_.-]*$" + }, + "snapshotVersion": { + "description": "Monotonic integer, per Billing Customer per Environment. It is the sole cache-monotonicity key. A projection that changes nothing does not advance it. Issued snapshots start at 1; 0 is reserved for the never-projected placeholder and is admissible only where snapshotVersionOrPlaceholder is referenced.", + "type": "integer", + "minimum": 1, + "maximum": 999999999999 + }, + "snapshotVersionOrPlaceholder": { + "description": "A snapshot version as carried on an issued customerEntitlementSnapshot, which additionally admits 0. Zero is the never-projected placeholder: the answer to a read for a Billing Customer no projection has ever run for. Because real versions start at 1, the placeholder sorts below every version that can ever supersede it, so the ordinary monotonic gate promotes it with no special casing anywhere. It is admissible only alongside a pending projection and empty entries and sources: it states that there is nothing to say yet, never that there is nothing.", + "type": "integer", + "minimum": 0, + "maximum": 999999999999 + }, + "priorSnapshotVersion": { + "type": "integer", + "minimum": 0, + "maximum": 999999999999 + }, + "projectionRuleVersion": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "recordType": { + "description": "The complete, closed record-type set of Authoritative Entitlement Contract 1. Adding a member is a breaking change requiring contract version 2.", + "enum": [ + "entitlementSyncRequest", + "customerEntitlementSnapshot", + "entitlementCheckRequest", + "entitlementCheckResult", + "subscriptionSnapshot", + "restoreResult", + "snapshotUnchanged" + ] + }, + "storePlatform": { + "description": "Opaque store platform vocabulary, identical to the Billing Ingestion vocabulary but redefined locally so this contract does not inherit another contract's lifecycle. Provider status strings are never admissible anywhere in this contract.", + "enum": [ + "apple_app_store", + "google_play" + ] + }, + "supportedContractVersions": { + "description": "Exact contract versions the caller can read. Negotiation lives in the request body of this contract; the Configuration Delivery capability request is untouched.", + "type": "array", + "minItems": 1, + "maxItems": 8, + "uniqueItems": true, + "items": { + "enum": [ + "1" + ] + } + }, + "accessState": { + "description": "Whether access is granted under accepted policy. unavailable is a service-delivery state, never a customer access state, and is therefore admissible only on a read-time response.", + "enum": [ + "active", + "inactive", + "unknown", + "unavailable" + ] + }, + "persistedEntitlementState": { + "description": "Entitlement state admissible inside an immutable snapshot. unavailable is deliberately absent: it describes Mosaic's ability to answer, not the customer's access, so it can never be persisted as projected state.", + "enum": [ + "active", + "inactive", + "unknown" + ] + }, + "lifecycleState": { + "description": "The provider lifecycle axis, over-provisioned and closed. Adding a member is a breaking change requiring contract version 2.", + "enum": [ + "trialing", + "active", + "grace_period", + "billing_retry", + "paused", + "expired", + "revoked", + "refunded", + "superseded", + "unknown" + ] + }, + "renewalIntent": { + "description": "Intent to renew. Cancellation changes this axis, not the access axis: access ends when a validated fact proves the effective period ended.", + "enum": [ + "auto_renew_enabled", + "auto_renew_disabled", + "provider_managed", + "paused", + "unknown" + ] + }, + "billingState": { + "enum": [ + "current", + "retrying", + "grace", + "failed", + "refunded", + "revoked", + "unknown" + ] + }, + "uncertaintyReason": { + "enum": [ + "none", + "provider_unavailable", + "missing_fact", + "identity_unresolved", + "product_unresolved", + "conflicting_facts", + "projection_failed", + "stale_validation", + "unsupported_provider_state" + ] + }, + "expectedResolution": { + "description": "How the uncertainty is expected to clear. It is guidance for a reader deciding whether to retry, not a promise.", + "enum": [ + "automatic_retry", + "next_provider_notification", + "next_projection_run", + "operator_action", + "customer_action", + "none_expected" + ] + }, + "uncertainty": { + "description": "Why a state is not definitive. reason none means the state is definitive, and a definitive state carries no since instant.", + "type": "object", + "additionalProperties": false, + "required": [ + "reason" + ], + "properties": { + "reason": { + "$ref": "#/$defs/uncertaintyReason" + }, + "since": { + "$ref": "#/$defs/utcTimestamp" + }, + "expectedResolution": { + "$ref": "#/$defs/expectedResolution" + }, + "diagnosticCode": { + "$ref": "#/$defs/diagnosticCode" + } + }, + "if": { + "properties": { + "reason": { + "const": "none" + } + }, + "required": [ + "reason" + ] + }, + "then": { + "not": { + "required": [ + "since" + ] + } + }, + "else": { + "required": [ + "since" + ] + } + }, + "definiteUncertainty": { + "description": "An uncertainty whose reason is not none. Used wherever a non-definitive state must remain explainable.", + "allOf": [ + { + "$ref": "#/$defs/uncertainty" + }, + { + "properties": { + "reason": { + "not": { + "const": "none" + } + } + } + } + ] + }, + "explanationCode": { + "description": "Closed, over-provisioned explanation vocabulary. Every state a reader can observe has a code; a reader may render its own copy for a code but must never invent one.", + "enum": [ + "active_subscription_period", + "active_trial_period", + "active_grace_period", + "active_billing_retry_allowance", + "permanent_one_time_purchase", + "family_shared_source", + "scheduled_pause_not_yet_effective", + "subscription_cancelled_access_until_period_end", + "subscription_expired", + "subscription_paused", + "subscription_revoked", + "subscription_refunded", + "subscription_superseded", + "grant_version_ended", + "no_qualifying_source", + "identity_unresolved", + "product_unresolved", + "conflicting_facts", + "projection_failed", + "provider_evidence_stale", + "provider_unavailable", + "billing_disabled", + "unsupported_provider_state" + ] + }, + "primaryExplanation": { + "description": "The one reason a reader should show first. safeSummary is an operator-facing convenience; it is never parsed and never carries a provider identifier, token, or raw provider error text.", + "type": "object", + "additionalProperties": false, + "required": [ + "code" + ], + "properties": { + "code": { + "$ref": "#/$defs/explanationCode" + }, + "sourceId": { + "$ref": "#/$defs/identifier" + }, + "safeSummary": { + "$ref": "#/$defs/safeText" + } + } + }, + "diagnosticCode": { + "type": "string", + "minLength": 3, + "maxLength": 96, + "pattern": "^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$" + }, + "recoveryAction": { + "enum": [ + "retry", + "refreshCustomerAccessToken", + "requestAuthoritativeSync", + "resolveIdentityConflict", + "fixProductMapping", + "contactProvider", + "none" + ] + }, + "diagnostic": { + "description": "Structurally the accepted Mosaic diagnostic shape, copied rather than referenced so Authoritative Entitlement 1 does not inherit another contract's lifecycle. Codes are namespaced entitlement... Raw provider error text is forbidden.", + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "safeMessage", + "severity", + "retryable", + "correlationId" + ], + "properties": { + "code": { + "$ref": "#/$defs/diagnosticCode" + }, + "safeMessage": { + "$ref": "#/$defs/safeText" + }, + "severity": { + "enum": [ + "info", + "warning", + "error" + ] + }, + "retryable": { + "type": "boolean" + }, + "retryAfterSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400 + }, + "correlationId": { + "$ref": "#/$defs/identifier" + }, + "recoveryAction": { + "$ref": "#/$defs/recoveryAction" + } + } + }, + "diagnostics": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/$defs/diagnostic" + } + }, + "projectionStatus": { + "description": "The health of the projection that produced this record. A degraded or failed projection does not make a snapshot unreadable: it makes the entries it could not determine unknown.", + "type": "object", + "additionalProperties": false, + "required": [ + "state", + "lastProjectedAt" + ], + "properties": { + "state": { + "enum": [ + "current", + "pending", + "stale", + "degraded", + "failed" + ] + }, + "lastProjectedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "pendingFactCount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "diagnosticCode": { + "$ref": "#/$defs/diagnosticCode" + } + }, + "allOf": [ + { + "if": { + "properties": { + "state": { + "const": "pending" + } + }, + "required": [ + "state" + ] + }, + "then": { + "required": [ + "pendingFactCount" + ] + } + }, + { + "if": { + "properties": { + "state": { + "enum": [ + "degraded", + "failed" + ] + } + }, + "required": [ + "state" + ] + }, + "then": { + "required": [ + "diagnosticCode" + ] + } + } + ] + }, + "changeReason": { + "description": "Why this snapshot exists. Closed and over-provisioned: a reader may branch on it, so a new member is a breaking change.", + "enum": [ + "initial_projection", + "subscription_state_changed", + "subscription_period_changed", + "renewal_intent_changed", + "source_added", + "source_ended", + "refund_applied", + "revocation_applied", + "grant_version_changed", + "identity_changed", + "identity_conflict_opened", + "identity_conflict_resolved", + "projection_replayed", + "projection_rule_upgraded", + "projection_recovered", + "projection_failed", + "manual_reprojection" + ] + }, + "sourceType": { + "description": "Why a source may grant access. Closed and over-provisioned.", + "enum": [ + "active_subscription", + "trial", + "grace_period", + "billing_retry", + "one_time_non_consumable", + "family_shared" + ] + }, + "sourceState": { + "enum": [ + "granting", + "not_granting", + "unknown" + ] + }, + "sourceSummary": { + "description": "One reason a Billing Customer holds, or may hold, access. Mosaic Product and Subscription Instance identity live here and nowhere else: duplicating them onto the Entitlement entry would create two places that can disagree when several sources grant one Entitlement.", + "type": "object", + "additionalProperties": false, + "required": [ + "sourceId", + "sourceType", + "mosaicProductId", + "grantVersionId", + "sourceSnapshotId", + "start", + "sourceState", + "uncertainty", + "explanationCode", + "isTestSource" + ], + "properties": { + "sourceId": { + "description": "Stable identity of the source, derived from (purchase lineage, Product, grant version) and never from a transaction fact identifier, so a second fact for one purchase cannot double-grant.", + "$ref": "#/$defs/identifier" + }, + "sourceType": { + "$ref": "#/$defs/sourceType" + }, + "subscriptionInstanceId": { + "$ref": "#/$defs/identifier" + }, + "oneTimePurchaseInstanceId": { + "$ref": "#/$defs/identifier" + }, + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "grantVersionId": { + "$ref": "#/$defs/identifier" + }, + "sourceSnapshotId": { + "$ref": "#/$defs/identifier" + }, + "storePlatform": { + "$ref": "#/$defs/storePlatform" + }, + "start": { + "$ref": "#/$defs/utcTimestamp" + }, + "end": { + "description": "Absent means this source has no finite end that Mosaic can state. For a permanent source that is a fact about the source; for an uncertain source the uncertainty explains why.", + "$ref": "#/$defs/utcTimestamp" + }, + "sourceState": { + "$ref": "#/$defs/sourceState" + }, + "uncertainty": { + "$ref": "#/$defs/uncertainty" + }, + "explanationCode": { + "$ref": "#/$defs/explanationCode" + }, + "isTestSource": { + "description": "True when this source derives from a provider test transaction: an Apple sandbox transaction, or a Google Play license-tester purchase, which arrives as a production transaction and is distinguishable only by this flag. Every surface that reports access reports this flag, so a test-derived grant is never mistaken for a paid one.", + "type": "boolean" + } + }, + "allOf": [ + { + "if": { + "properties": { + "sourceType": { + "const": "one_time_non_consumable" + } + }, + "required": [ + "sourceType" + ] + }, + "then": { + "required": [ + "oneTimePurchaseInstanceId" + ], + "not": { + "required": [ + "subscriptionInstanceId" + ] + } + }, + "else": { + "required": [ + "subscriptionInstanceId" + ], + "not": { + "required": [ + "oneTimePurchaseInstanceId" + ] + } + } + }, + { + "if": { + "properties": { + "sourceState": { + "const": "unknown" + } + }, + "required": [ + "sourceState" + ] + }, + "then": { + "properties": { + "uncertainty": { + "$ref": "#/$defs/definiteUncertainty" + } + } + } + } + ] + }, + "entitlementEntry": { + "description": "The authoritative state of one Entitlement for one Billing Customer. Product and Subscription Instance identity are deliberately absent: they live on the contributing source summaries, which sourceIds resolves against.", + "type": "object", + "additionalProperties": false, + "required": [ + "entitlementId", + "entitlementKey", + "state", + "endKnown", + "sourceIds", + "sourceCount", + "primaryExplanation" + ], + "properties": { + "entitlementId": { + "$ref": "#/$defs/identifier" + }, + "entitlementKey": { + "$ref": "#/$defs/entitlementKey" + }, + "state": { + "$ref": "#/$defs/persistedEntitlementState" + }, + "effectiveStart": { + "$ref": "#/$defs/utcTimestamp" + }, + "effectiveEnd": { + "description": "The latest known end among contributing active finite sources. It is present only when endKnown is true; when endKnown is true and this member is absent, the Entitlement has no finite expiry because a permanent source contributes to it.", + "$ref": "#/$defs/utcTimestamp" + }, + "endKnown": { + "description": "Whether Mosaic can state the effective end at all. False means an active source has an uncertain end, and a reader must not display or enforce any expiry.", + "type": "boolean" + }, + "refreshRecommendedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "sourceIds": { + "description": "Every contributing source, never a single selected winner. Ascending, unique, and resolvable against the snapshot's sources array.", + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "sourceCount": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "primaryExplanation": { + "$ref": "#/$defs/primaryExplanation" + }, + "uncertainty": { + "$ref": "#/$defs/uncertainty" + } + }, + "allOf": [ + { + "if": { + "properties": { + "state": { + "const": "active" + } + }, + "required": [ + "state" + ] + }, + "then": { + "required": [ + "effectiveStart" + ] + } + }, + { + "if": { + "properties": { + "state": { + "const": "unknown" + } + }, + "required": [ + "state" + ] + }, + "then": { + "required": [ + "uncertainty" + ], + "properties": { + "uncertainty": { + "$ref": "#/$defs/definiteUncertainty" + } + } + } + }, + { + "if": { + "properties": { + "endKnown": { + "const": false + } + }, + "required": [ + "endKnown" + ] + }, + "then": { + "not": { + "required": [ + "effectiveEnd" + ] + } + } + } + ] + }, + "customerEntitlementSnapshot": { + "description": "Immutable authoritative state for one Billing Customer in one Environment at one snapshot version. It is a read model, never a bearer credential: possessing it authorizes nothing.", + "type": "object", + "additionalProperties": false, + "required": [ + "snapshotId", + "billingCustomerId", + "projectId", + "environmentId", + "snapshotVersion", + "projectionRuleVersion", + "issuedAt", + "asOf", + "refreshAfter", + "validUntil", + "entityTag", + "contentDigest", + "entries", + "sources", + "projectionStatus", + "changeReason", + "correlationId" + ], + "properties": { + "snapshotId": { + "$ref": "#/$defs/identifier" + }, + "billingCustomerId": { + "$ref": "#/$defs/identifier" + }, + "projectId": { + "$ref": "#/$defs/identifier" + }, + "environmentId": { + "$ref": "#/$defs/identifier" + }, + "snapshotVersion": { + "$ref": "#/$defs/snapshotVersionOrPlaceholder" + }, + "previousSnapshotVersion": { + "description": "The version this snapshot succeeded. Absent on the first snapshot for a customer in an Environment, and absent on the never-projected placeholder, which succeeds nothing.", + "$ref": "#/$defs/priorSnapshotVersion" + }, + "projectionRuleVersion": { + "$ref": "#/$defs/projectionRuleVersion" + }, + "issuedAt": { + "description": "When this representation was issued to the caller.", + "$ref": "#/$defs/utcTimestamp" + }, + "asOf": { + "description": "The instant the projection evaluated state at. Every derived interval is half-open and evaluated at this instant.", + "$ref": "#/$defs/utcTimestamp" + }, + "refreshAfter": { + "description": "After this instant a reader should refresh. The snapshot remains fully valid until validUntil.", + "$ref": "#/$defs/utcTimestamp" + }, + "validUntil": { + "description": "The hard end of authoritative validity. Past it, a reader may serve the cache only under a bounded-grace policy and only while marking it stale; past the grace window the reader reports unknown, never inactive.", + "$ref": "#/$defs/utcTimestamp" + }, + "staleGraceSeconds": { + "description": "Bounded-grace window past validUntil during which a reader may keep serving previously active Entitlements while clearly marking them stale. The shipped default is 86400 (24 hours), configured deployment-wide server-side in Phase 9B; per-Environment configuration is deferred. A strict policy is expressed as zero. Absent means zero, so a producer that intends bounded grace must state it. The combined horizon (validUntil - issuedAt) + staleGraceSeconds may never exceed 30 days.", + "type": "integer", + "minimum": 0, + "maximum": 2592000 + }, + "entityTag": { + "$ref": "#/$defs/entityTag" + }, + "contentDigest": { + "description": "SHA-256 over the canonical serialization of this payload with the contentDigest member removed. It is corruption and binding detection, not authentication: it covers billingCustomerId, projectId, environmentId, and snapshotVersion, so a snapshot cannot be accepted into another customer's, Project's, or Environment's cache.", + "$ref": "#/$defs/digest" + }, + "entries": { + "description": "Ascending by entitlementKey, unique by entitlementKey. The ordering is normative so two producers of identical state produce identical bytes and therefore an identical contentDigest.", + "type": "array", + "maxItems": 200, + "items": { + "$ref": "#/$defs/entitlementEntry" + } + }, + "sources": { + "description": "Ascending by sourceId, unique by sourceId. Every source contributing to any entry appears here; a source that grants nothing may also appear, with sourceState not_granting, so a reader can explain absence.", + "type": "array", + "maxItems": 200, + "items": { + "$ref": "#/$defs/sourceSummary" + } + }, + "projectionStatus": { + "$ref": "#/$defs/projectionStatus" + }, + "changeReason": { + "$ref": "#/$defs/changeReason" + }, + "correlationId": { + "$ref": "#/$defs/identifier" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + }, + "allOf": [ + { + "$comment": "The never-projected placeholder. Version 0 is not a snapshot of nothing; it is the statement that no projection has run for this Billing Customer in this Environment. Expressing that as a version rather than as an error is what lets a reader cache the answer and lets the ordinary monotonic gate replace it with version 1: no reader needs a special case, and no reader has to treat an unanswerable read as inactive. The content constraints are here rather than in prose because a producer that emitted version 0 alongside entries would hand a reader projected state under a version that claims none exists.", + "if": { + "properties": { + "snapshotVersion": { + "const": 0 + } + }, + "required": [ + "snapshotVersion" + ] + }, + "then": { + "properties": { + "projectionStatus": { + "properties": { + "state": { + "const": "pending" + } + } + }, + "entries": { + "maxItems": 0 + }, + "sources": { + "maxItems": 0 + }, + "changeReason": { + "const": "initial_projection" + } + }, + "not": { + "required": [ + "previousSnapshotVersion" + ] + } + } + } + ] + }, + "snapshotUnchanged": { + "description": "The response to a conditional sync whose cached snapshot is still current. It carries no entries: it confirms the cached snapshot and slides its freshness window, so a confirmed-current snapshot never expires merely because it was confirmed instead of resent.", + "type": "object", + "additionalProperties": false, + "required": [ + "billingCustomerId", + "projectId", + "environmentId", + "snapshotVersion", + "entityTag", + "issuedAt", + "asOf", + "refreshAfter", + "validUntil", + "projectionStatus", + "correlationId" + ], + "properties": { + "billingCustomerId": { + "$ref": "#/$defs/identifier" + }, + "projectId": { + "$ref": "#/$defs/identifier" + }, + "environmentId": { + "$ref": "#/$defs/identifier" + }, + "snapshotVersion": { + "$comment": "Deliberately snapshotVersion rather than snapshotVersionOrPlaceholder: the never-projected placeholder is never confirmed, only re-issued. There is nothing to slide -- a placeholder carries no projected state to keep alive -- and re-issuing it costs an empty record, while admitting version 0 here would create a second surface where the placeholder's rules would have to be restated and could drift.", + "$ref": "#/$defs/snapshotVersion" + }, + "entityTag": { + "$ref": "#/$defs/entityTag" + }, + "issuedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "asOf": { + "$ref": "#/$defs/utcTimestamp" + }, + "refreshAfter": { + "$ref": "#/$defs/utcTimestamp" + }, + "validUntil": { + "$ref": "#/$defs/utcTimestamp" + }, + "staleGraceSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 2592000 + }, + "projectionStatus": { + "$ref": "#/$defs/projectionStatus" + }, + "correlationId": { + "$ref": "#/$defs/identifier" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + } + } +} diff --git a/protocol/schema/authoritative-entitlement/v1/subscription.schema.json b/protocol/schema/authoritative-entitlement/v1/subscription.schema.json new file mode 100644 index 00000000..a21d4301 --- /dev/null +++ b/protocol/schema/authoritative-entitlement/v1/subscription.schema.json @@ -0,0 +1,348 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:subscription", + "title": "Mosaic Authoritative Entitlement Contract v1 subscription snapshot", + "description": "The immutable projected state of one Subscription Instance at one projection version, on four explicit axes. Provider status strings are not admissible: a provider concept enters this contract only as a member of a closed Mosaic vocabulary, so no reader has to know Apple or Google state names to read it.", + "type": "object", + "additionalProperties": false, + "required": [ + "authoritativeEntitlementContractVersion", + "recordType", + "payload" + ], + "properties": { + "authoritativeEntitlementContractVersion": { + "const": "1" + }, + "recordType": { + "const": "subscriptionSnapshot" + }, + "payload": { + "$ref": "#/$defs/subscriptionSnapshot" + } + }, + "$defs": { + "subscriptionSnapshot": { + "type": "object", + "additionalProperties": false, + "required": [ + "subscriptionSnapshotId", + "subscriptionInstanceId", + "billingCustomerId", + "projectId", + "environmentId", + "projectionVersion", + "projectionRuleVersion", + "computedAt", + "asOf", + "storePlatform", + "mosaicProductId", + "accessState", + "lifecycleState", + "renewalIntent", + "billingState", + "uncertainty", + "isTestSource", + "checksum", + "changeReason", + "correlationId" + ], + "properties": { + "subscriptionSnapshotId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "subscriptionInstanceId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "purchaseLineageId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "billingCustomerId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "projectId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "environmentId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "projectionVersion": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/snapshotVersion" + }, + "projectionRuleVersion": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/projectionRuleVersion" + }, + "computedAt": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "asOf": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "storePlatform": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/storePlatform" + }, + "mosaicProductId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "priorMosaicProductId": { + "description": "Present when this snapshot records a Product transition, so an upgrade or downgrade is explainable without reading the previous snapshot.", + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "accessState": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/accessState" + }, + "lifecycleState": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/lifecycleState" + }, + "renewalIntent": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/renewalIntent" + }, + "billingState": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/billingState" + }, + "uncertainty": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/uncertainty" + }, + "periodStart": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "periodEnd": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "gracePeriodEnd": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "billingRetryStart": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "pauseEffectiveAt": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "pauseResumeAt": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "cancellationEffectiveAt": { + "description": "When the customer's cancellation was recorded by the provider. It changes renewalIntent; it does not end access, which ends when a validated fact proves the period ended.", + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "expirationEffectiveAt": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "revocationEffectiveAt": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "refundEffectiveAt": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/utcTimestamp" + }, + "supersededBySubscriptionInstanceId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "isTestSource": { + "type": "boolean" + }, + "sourceFactCount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "checksum": { + "description": "SHA-256 over the canonical serialization of this payload with the checksum member removed. Two projections of the same facts under the same rule version produce the same checksum; a no-change replay is detected by checksum equality and creates no new snapshot.", + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/digest" + }, + "changeReason": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/changeReason" + }, + "explanationCode": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/explanationCode" + }, + "correlationId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "diagnostics": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/diagnostics" + } + }, + "allOf": [ + { + "$comment": "Revocation is the one transition that is never policy-dependent: a revoked source grants nothing, and the instant it became effective is what makes that auditable.", + "if": { + "properties": { + "lifecycleState": { + "const": "revoked" + } + }, + "required": [ + "lifecycleState" + ] + }, + "then": { + "required": [ + "revocationEffectiveAt" + ], + "properties": { + "accessState": { + "const": "inactive" + } + } + } + }, + { + "$comment": "Grace grants access on both providers, so the instant it ends is the instant access ends. A grace state without it would be an unbounded grant.", + "if": { + "properties": { + "lifecycleState": { + "const": "grace_period" + } + }, + "required": [ + "lifecycleState" + ] + }, + "then": { + "required": [ + "gracePeriodEnd" + ] + } + }, + { + "if": { + "properties": { + "lifecycleState": { + "const": "billing_retry" + } + }, + "required": [ + "lifecycleState" + ] + }, + "then": { + "required": [ + "billingRetryStart" + ] + } + }, + { + "$comment": "Pause exists only on Google Play. An Apple snapshot claiming paused would be a normalization defect, not a provider state.", + "if": { + "properties": { + "lifecycleState": { + "const": "paused" + } + }, + "required": [ + "lifecycleState" + ] + }, + "then": { + "required": [ + "pauseEffectiveAt" + ], + "properties": { + "storePlatform": { + "const": "google_play" + } + } + } + }, + { + "if": { + "properties": { + "lifecycleState": { + "const": "expired" + } + }, + "required": [ + "lifecycleState" + ] + }, + "then": { + "required": [ + "expirationEffectiveAt" + ] + } + }, + { + "if": { + "properties": { + "lifecycleState": { + "const": "refunded" + } + }, + "required": [ + "lifecycleState" + ] + }, + "then": { + "required": [ + "refundEffectiveAt" + ] + } + }, + { + "$comment": "Supersession is explicit and never deletion: the superseded instance keeps its history and names its successor.", + "if": { + "properties": { + "lifecycleState": { + "const": "superseded" + } + }, + "required": [ + "lifecycleState" + ] + }, + "then": { + "required": [ + "supersededBySubscriptionInstanceId" + ] + } + }, + { + "$comment": "Unknown and unavailable must remain explainable. This is the invariant that keeps a failure from being reported as a definite loss of access.", + "if": { + "properties": { + "accessState": { + "enum": [ + "unknown", + "unavailable" + ] + } + }, + "required": [ + "accessState" + ] + }, + "then": { + "properties": { + "uncertainty": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/definiteUncertainty" + } + } + } + }, + { + "$comment": "An unknown lifecycle cannot support a definite access answer in either direction.", + "if": { + "properties": { + "lifecycleState": { + "const": "unknown" + } + }, + "required": [ + "lifecycleState" + ] + }, + "then": { + "properties": { + "accessState": { + "enum": [ + "unknown", + "unavailable" + ] + } + } + } + } + ] + } + } +} diff --git a/protocol/schema/authoritative-entitlement/v1/sync-request.schema.json b/protocol/schema/authoritative-entitlement/v1/sync-request.schema.json new file mode 100644 index 00000000..7a74bc78 --- /dev/null +++ b/protocol/schema/authoritative-entitlement/v1/sync-request.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:sync-request", + "title": "Mosaic Authoritative Entitlement Contract v1 sync request", + "description": "The request an SDK sends to synchronize its authoritative Entitlement snapshot. Contract negotiation lives in this body; no Configuration Delivery capability request changes.", + "type": "object", + "additionalProperties": false, + "required": [ + "authoritativeEntitlementContractVersion", + "recordType", + "payload" + ], + "properties": { + "authoritativeEntitlementContractVersion": { + "const": "1" + }, + "recordType": { + "const": "entitlementSyncRequest" + }, + "payload": { + "$ref": "#/$defs/entitlementSyncRequest" + } + }, + "$defs": { + "entitlementSyncRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "supportedAuthoritativeEntitlementContracts", + "correlationId" + ], + "properties": { + "billingCustomerId": { + "description": "A hint only. The server derives the Billing Customer from the Customer Access Token and verifies this value against it; a mismatch is refused. A caller can never select a customer by asserting an identifier, so this member can never widen access.", + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + }, + "knownSnapshotVersion": { + "description": "The version the caller already holds. Zero is the cached never-projected placeholder; issued snapshots start at 1 and replace it through the ordinary monotonic comparison. The placeholder is always re-issued rather than answered as snapshotUnchanged. For issued versions, the server answers snapshotUnchanged when its current version equals this value and the entity tag also matches.", + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/snapshotVersionOrPlaceholder" + }, + "entityTag": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/entityTag" + }, + "supportedAuthoritativeEntitlementContracts": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/supportedContractVersions" + }, + "requestedEntitlementKeys": { + "description": "Narrows the response to these keys. Unrecognized keys are project data and are accepted; a key the Project does not define simply contributes no entry.", + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/entitlementKey" + } + }, + "correlationId": { + "$ref": "urn:mosaic:protocol:schema:authoritative-entitlement:v1:snapshot#/$defs/identifier" + } + } + } + } +} diff --git a/protocol/schema/billing-state-webhook/v1/compatibility-manifest.schema.json b/protocol/schema/billing-state-webhook/v1/compatibility-manifest.schema.json new file mode 100644 index 00000000..59645f34 --- /dev/null +++ b/protocol/schema/billing-state-webhook/v1/compatibility-manifest.schema.json @@ -0,0 +1,321 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:billing-state-webhook:v1:compatibility-manifest", + "title": "Mosaic Billing State Webhook Contract v1 compatibility manifest", + "description": "Machine-checked compatibility surface for Billing State Webhook Contract 1, including the deliberate producer/consumer asymmetry: producers are strict, consumers are tolerant. That asymmetry is an owner-approved exception to Mosaic's repo-wide fail-closed doctrine and is pinned here so it stays an exception rather than becoming a habit.", + "type": "object", + "additionalProperties": false, + "required": [ + "billingStateWebhookContractVersion", + "status", + "schemas", + "canonicalFixtures", + "recordTypes", + "eventTypes", + "emittedEventTypes", + "signing", + "delivery", + "limits", + "producerPolicy", + "consumerTolerance" + ], + "properties": { + "billingStateWebhookContractVersion": { + "const": "1" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "releaseCandidate", + "approved", + "deprecated", + "retired" + ] + }, + "deprecation": { + "type": "object", + "additionalProperties": false, + "required": [ + "deprecatedAt", + "retiresAt" + ], + "properties": { + "deprecatedAt": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$" + }, + "retiresAt": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$" + }, + "supersededBy": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "migrationGuide": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "schemas": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "delivery" + ], + "properties": { + "event": { + "$ref": "#/$defs/relativeJsonPath" + }, + "delivery": { + "$ref": "#/$defs/relativeJsonPath" + } + } + }, + "canonicalFixtures": { + "type": "array", + "minItems": 10, + "maxItems": 48, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/relativeJsonPath" + } + }, + "recordTypes": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/recordType" + } + }, + "eventTypes": { + "description": "The complete closed event-type set, over-provisioned so the nine reserved names cost no contract version when they begin to be emitted.", + "type": "array", + "minItems": 10, + "maxItems": 10, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/eventType" + } + }, + "emittedEventTypes": { + "description": "The subset Mosaic actually emits at this contract's current status. Declaring a name in eventTypes is a promise about the vocabulary; listing it here is a promise about behaviour, and the two are deliberately different sizes.", + "type": "array", + "minItems": 1, + "maxItems": 10, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/eventType" + } + }, + "signing": { + "description": "The signature a consumer verifies before parsing anything. There is exactly one Mosaic webhook-signing scheme; a second one is never invented for a second event family.", + "type": "object", + "additionalProperties": false, + "required": [ + "header", + "algorithm", + "signingVersion", + "signedPayloadTemplate", + "encoding", + "timestampParameter", + "signatureParameter", + "multipleActiveSignatures", + "replayWindowSeconds", + "testEndpointSupported", + "signatureVectors" + ], + "properties": { + "header": { + "const": "Mosaic-Signature" + }, + "algorithm": { + "const": "HMAC-SHA256" + }, + "signingVersion": { + "const": "v1" + }, + "signedPayloadTemplate": { + "description": "The exact bytes the MAC covers: the signing version, the timestamp, the event ID, and the raw request body, joined by full stops. The event ID is inside the signed payload so a valid signature cannot be replayed onto a different event, and the raw body is used verbatim because re-serializing the JSON changes the bytes.", + "const": "{signingVersion}.{timestamp}.{eventId}.{rawBody}" + }, + "encoding": { + "const": "lowercase_hex" + }, + "timestampParameter": { + "const": "t" + }, + "signatureParameter": { + "const": "v1" + }, + "multipleActiveSignatures": { + "description": "During key rotation the header carries one v1 parameter per active key. A consumer accepts the request if any one of them verifies, so rotation never drops a delivery.", + "const": true + }, + "replayWindowSeconds": { + "const": 300 + }, + "testEndpointSupported": { + "const": true + }, + "signatureVectors": { + "description": "Repository-relative path to the cross-implementation signature vectors, so a verifier in any language can be checked against the same bytes.", + "const": "packages/test-fixtures/src/webhook-signature-vectors.json" + } + } + }, + "delivery": { + "type": "object", + "additionalProperties": false, + "required": [ + "guarantee", + "deduplicationKey", + "orderingKey", + "failureIsolation" + ], + "properties": { + "guarantee": { + "const": "atLeastOnce" + }, + "deduplicationKey": { + "const": "eventId" + }, + "orderingKey": { + "description": "Delivery order is not guaranteed. A consumer that has applied a higher snapshotVersion ignores a lower one; it never orders by createdAt or by arrival.", + "const": "snapshotVersion" + }, + "failureIsolation": { + "description": "A webhook that cannot be delivered never rolls back the customer state that produced it. Delivery is a notification path, not a commit path.", + "const": "deliveryNeverRollsBackState" + } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": [ + "maxRecordBytes", + "maxChangedEntitlementsPerEvent", + "maxResponseExcerptCharacters", + "maxDeliveryAttempts", + "maxDiagnosticsPerRecord", + "replayWindowSeconds" + ], + "properties": { + "maxRecordBytes": { + "const": 32768 + }, + "maxChangedEntitlementsPerEvent": { + "const": 200 + }, + "maxResponseExcerptCharacters": { + "const": 240 + }, + "maxDeliveryAttempts": { + "const": 32 + }, + "maxDiagnosticsPerRecord": { + "const": 10 + }, + "replayWindowSeconds": { + "const": 300 + } + } + }, + "producerPolicy": { + "description": "What Mosaic guarantees about what it emits. Producer schemas are strict in the ordinary Mosaic sense: closed enumerations, additionalProperties false, no provider material.", + "type": "object", + "additionalProperties": false, + "required": [ + "unknownField", + "unknownEventType", + "providerSecret", + "rawPurchaseToken", + "rawProviderPayload", + "eventIdStability", + "exactlyOnceDelivery", + "deliveryRecordTransport" + ], + "properties": { + "unknownField": { + "const": "rejectRecord" + }, + "unknownEventType": { + "const": "rejectRecord" + }, + "providerSecret": { + "const": "forbidden" + }, + "rawPurchaseToken": { + "const": "forbidden" + }, + "rawProviderPayload": { + "const": "forbidden" + }, + "eventIdStability": { + "const": "stableAcrossAttemptsAndReplays" + }, + "exactlyOnceDelivery": { + "const": "neverPromised" + }, + "deliveryRecordTransport": { + "description": "The delivery-attempt record is operator-facing. It is never transmitted to a destination.", + "const": "neverSentToDestination" + } + } + }, + "consumerTolerance": { + "description": "An owner-approved documented exception to the repo-wide fail-closed doctrine. Everywhere else in Mosaic a reader that does not fully understand a document rejects it, because the alternative is rendering or granting something it misread. A webhook consumer is different: the event is not authoritative, the snapshot is. A consumer that rejected an event carrying a field it had not seen would stop reacting to real state changes in order to protect itself from information it was free to ignore. So consumers ignore what they do not recognize and re-read the snapshot, which is the same fail-safe outcome reached by the opposite route.", + "type": "object", + "additionalProperties": false, + "required": [ + "unknownField", + "unknownEventType", + "unknownEnumerationMember", + "authoritativeState", + "ordering", + "duplicateEvent", + "signatureVerification" + ], + "properties": { + "unknownField": { + "const": "ignore" + }, + "unknownEventType": { + "const": "ignore" + }, + "unknownEnumerationMember": { + "const": "ignore" + }, + "authoritativeState": { + "const": "reReadSnapshot" + }, + "ordering": { + "const": "ignoreOlderSnapshotVersion" + }, + "duplicateEvent": { + "const": "deduplicateByEventId" + }, + "signatureVerification": { + "description": "The one thing a consumer must not be tolerant about. Verify the signature and the replay window before parsing the body at all.", + "const": "requiredBeforeParsing" + } + } + } + }, + "$defs": { + "relativeJsonPath": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^\\.\\./\\.\\./.*\\.json$" + } + } +} diff --git a/protocol/schema/billing-state-webhook/v1/delivery.schema.json b/protocol/schema/billing-state-webhook/v1/delivery.schema.json new file mode 100644 index 00000000..35daf36e --- /dev/null +++ b/protocol/schema/billing-state-webhook/v1/delivery.schema.json @@ -0,0 +1,209 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:billing-state-webhook:v1:delivery", + "title": "Mosaic Billing State Webhook Contract v1 delivery attempt", + "description": "One recorded attempt to deliver one event to one destination. This record is dashboard- and API-facing only: it is never sent to a destination, and a destination never sees another tenant's attempts. It carries no destination URL and no signing secret, because attempt history is read by operators far more often than it is read by the person who configured the destination.", + "type": "object", + "additionalProperties": false, + "required": [ + "billingStateWebhookContractVersion", + "recordType", + "payload" + ], + "properties": { + "billingStateWebhookContractVersion": { + "const": "1" + }, + "recordType": { + "const": "webhookDeliveryAttempt" + }, + "payload": { + "$ref": "#/$defs/webhookDeliveryAttempt" + } + }, + "$defs": { + "webhookDeliveryAttempt": { + "type": "object", + "additionalProperties": false, + "required": [ + "deliveryId", + "eventId", + "destinationId", + "attempt", + "maxAttempts", + "status", + "requestedAt" + ], + "properties": { + "deliveryId": { + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/identifier" + }, + "eventId": { + "description": "Stable across attempts. A retry of the same event carries the same eventId with a higher attempt number, which is what makes consumer-side deduplication possible.", + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/identifier" + }, + "destinationId": { + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/identifier" + }, + "attempt": { + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "maxAttempts": { + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "status": { + "enum": [ + "pending", + "succeeded", + "failed", + "exhausted", + "skipped" + ] + }, + "requestedAt": { + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/utcTimestamp" + }, + "respondedAt": { + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/utcTimestamp" + }, + "responseStatusCode": { + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "responseExcerpt": { + "description": "A bounded, control-character-free excerpt of the destination's response body, kept only so an integrator can see why their endpoint refused. It is never parsed and never influences Mosaic state.", + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/safeText" + }, + "nextAttemptAt": { + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/utcTimestamp" + }, + "skippedReason": { + "enum": [ + "destination_disabled", + "event_type_not_enabled", + "destination_deleted", + "tenant_suspended" + ] + }, + "diagnostics": { + "$ref": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event#/$defs/diagnostics" + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "succeeded" + } + }, + "required": [ + "status" + ] + }, + "then": { + "required": [ + "respondedAt", + "responseStatusCode" + ], + "not": { + "required": [ + "nextAttemptAt" + ] + } + } + }, + { + "$comment": "A pending attempt has not been answered, so it can carry no answer.", + "if": { + "properties": { + "status": { + "const": "pending" + } + }, + "required": [ + "status" + ] + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "respondedAt" + ] + }, + { + "required": [ + "responseStatusCode" + ] + }, + { + "required": [ + "responseExcerpt" + ] + } + ] + } + } + }, + { + "$comment": "Exhaustion is terminal: there is no next attempt to schedule.", + "if": { + "properties": { + "status": { + "const": "exhausted" + } + }, + "required": [ + "status" + ] + }, + "then": { + "not": { + "required": [ + "nextAttemptAt" + ] + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "skipped" + } + }, + "required": [ + "status" + ] + }, + "then": { + "required": [ + "skippedReason" + ], + "not": { + "anyOf": [ + { + "required": [ + "responseStatusCode" + ] + }, + { + "required": [ + "nextAttemptAt" + ] + } + ] + } + } + } + ] + } + } +} diff --git a/protocol/schema/billing-state-webhook/v1/event.schema.json b/protocol/schema/billing-state-webhook/v1/event.schema.json new file mode 100644 index 00000000..92522e53 --- /dev/null +++ b/protocol/schema/billing-state-webhook/v1/event.schema.json @@ -0,0 +1,437 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:billing-state-webhook:v1:event", + "title": "Mosaic Billing State Webhook Contract v1 event", + "description": "The committed authoritative change Mosaic delivers to an application backend. An event is a notification that state changed, never the authority on what the state now is: a consumer re-reads the Customer Entitlement Snapshot. This schema also declares the shared primitives of Billing State Webhook Contract 1; the delivery schema references them. Provider-specific event names are deliberately absent, and no provider secret, purchase token, or raw provider payload can be carried.", + "type": "object", + "additionalProperties": false, + "required": [ + "billingStateWebhookContractVersion", + "recordType", + "payload" + ], + "properties": { + "billingStateWebhookContractVersion": { + "const": "1" + }, + "recordType": { + "const": "billingStateEvent" + }, + "payload": { + "$ref": "#/$defs/billingStateEvent" + } + }, + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "safeText": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]*$" + }, + "utcTimestamp": { + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$" + }, + "entitlementKey": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_.-]*$" + }, + "snapshotVersion": { + "type": "integer", + "minimum": 1, + "maximum": 999999999999 + }, + "priorSnapshotVersion": { + "type": "integer", + "minimum": 0, + "maximum": 999999999999 + }, + "recordType": { + "description": "The complete, closed record-type set of Billing State Webhook Contract 1.", + "enum": [ + "billingStateEvent", + "webhookDeliveryAttempt" + ] + }, + "eventType": { + "description": "The complete, closed event-type set. All ten members are declared now because adding one later costs a contract version; Phase 9B emits only customer.entitlements.changed. The other nine are reserved names with defined meanings, and a producer that emits one before it is specified is a defect. No member names a provider: a consumer never has to know whether the change came from Apple or Google to handle it.", + "enum": [ + "customer.entitlements.changed", + "subscription.state.changed", + "subscription.period.changed", + "subscription.renewal_intent.changed", + "subscription.expired", + "subscription.revoked", + "subscription.refunded", + "customer.billing_identity.conflict", + "customer.projection.failed", + "customer.projection.recovered" + ] + }, + "accessState": { + "description": "Access under accepted policy, as summarized on an event. unavailable is deliberately ABSENT, unlike the read-time accessState of Authoritative Entitlement 1: unavailable means Mosaic could not answer a read, and an event is not a read. An event exists only because a projection committed a new snapshot, so the projection did answer; the worst it can say about an axis is unknown, with the uncertainty that explains why. Emitting unavailable here would let a consumer read a service-delivery state as a customer state on a record that is not even authoritative.", + "enum": [ + "active", + "inactive", + "unknown" + ] + }, + "entitlementState": { + "enum": [ + "active", + "inactive", + "unknown" + ] + }, + "priorEntitlementState": { + "description": "absent means the Entitlement contributed no entry to the previous snapshot, which is how a first grant is reported without pretending the customer was previously inactive.", + "enum": [ + "active", + "inactive", + "unknown", + "absent" + ] + }, + "lifecycleState": { + "enum": [ + "trialing", + "active", + "grace_period", + "billing_retry", + "paused", + "expired", + "revoked", + "refunded", + "superseded", + "unknown" + ] + }, + "renewalIntent": { + "enum": [ + "auto_renew_enabled", + "auto_renew_disabled", + "provider_managed", + "paused", + "unknown" + ] + }, + "billingState": { + "enum": [ + "current", + "retrying", + "grace", + "failed", + "refunded", + "revoked", + "unknown" + ] + }, + "uncertaintyReason": { + "enum": [ + "none", + "provider_unavailable", + "missing_fact", + "identity_unresolved", + "product_unresolved", + "conflicting_facts", + "projection_failed", + "stale_validation", + "unsupported_provider_state" + ] + }, + "uncertainty": { + "description": "Structurally the Authoritative Entitlement uncertainty shape, copied rather than referenced so this contract does not inherit another draft contract's lifecycle.", + "type": "object", + "additionalProperties": false, + "required": [ + "reason" + ], + "properties": { + "reason": { + "$ref": "#/$defs/uncertaintyReason" + }, + "since": { + "$ref": "#/$defs/utcTimestamp" + }, + "diagnosticCode": { + "$ref": "#/$defs/diagnosticCode" + } + }, + "if": { + "properties": { + "reason": { + "const": "none" + } + }, + "required": [ + "reason" + ] + }, + "then": { + "not": { + "required": [ + "since" + ] + } + }, + "else": { + "required": [ + "since" + ] + } + }, + "sourceReason": { + "description": "Why the committed state changed. Identical vocabulary to the Authoritative Entitlement changeReason, copied locally.", + "enum": [ + "initial_projection", + "subscription_state_changed", + "subscription_period_changed", + "renewal_intent_changed", + "source_added", + "source_ended", + "refund_applied", + "revocation_applied", + "grant_version_changed", + "identity_changed", + "identity_conflict_opened", + "identity_conflict_resolved", + "projection_replayed", + "projection_rule_upgraded", + "projection_recovered", + "projection_failed", + "manual_reprojection" + ] + }, + "diagnosticCode": { + "type": "string", + "minLength": 3, + "maxLength": 96, + "pattern": "^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$" + }, + "diagnostic": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "safeMessage", + "severity", + "retryable", + "correlationId" + ], + "properties": { + "code": { + "$ref": "#/$defs/diagnosticCode" + }, + "safeMessage": { + "$ref": "#/$defs/safeText" + }, + "severity": { + "enum": [ + "info", + "warning", + "error" + ] + }, + "retryable": { + "type": "boolean" + }, + "retryAfterSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400 + }, + "correlationId": { + "$ref": "#/$defs/identifier" + } + } + }, + "diagnostics": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/$defs/diagnostic" + } + }, + "changedEntitlement": { + "type": "object", + "additionalProperties": false, + "required": [ + "entitlementKey", + "previousState", + "currentState" + ], + "properties": { + "entitlementKey": { + "$ref": "#/$defs/entitlementKey" + }, + "previousState": { + "$ref": "#/$defs/priorEntitlementState" + }, + "currentState": { + "$ref": "#/$defs/entitlementState" + } + } + }, + "stateSummary": { + "description": "The four state axes of the subscription the change came from, as a safe summary. It is a convenience for routing and logging: the authoritative answer is always the snapshot the consumer re-reads.", + "type": "object", + "additionalProperties": false, + "required": [ + "accessState", + "lifecycleState", + "renewalIntent", + "billingState", + "uncertainty" + ], + "properties": { + "accessState": { + "$ref": "#/$defs/accessState" + }, + "lifecycleState": { + "$ref": "#/$defs/lifecycleState" + }, + "renewalIntent": { + "$ref": "#/$defs/renewalIntent" + }, + "billingState": { + "$ref": "#/$defs/billingState" + }, + "uncertainty": { + "$ref": "#/$defs/uncertainty" + } + }, + "if": { + "properties": { + "accessState": { + "const": "unknown" + } + }, + "required": [ + "accessState" + ] + }, + "then": { + "properties": { + "uncertainty": { + "properties": { + "reason": { + "not": { + "const": "none" + } + } + } + } + } + } + }, + "billingStateEvent": { + "type": "object", + "additionalProperties": false, + "required": [ + "eventId", + "eventType", + "projectId", + "environmentId", + "billingCustomerId", + "snapshotVersion", + "projectionRuleVersion", + "occurredAt", + "createdAt", + "changedEntitlements", + "stateSummary", + "sourceReason", + "correlationId" + ], + "properties": { + "eventId": { + "description": "Stable across every delivery attempt and every manual replay. It is the consumer's deduplication key; delivery is at least once and is never promised to be exactly once.", + "$ref": "#/$defs/identifier" + }, + "eventType": { + "$ref": "#/$defs/eventType" + }, + "projectId": { + "$ref": "#/$defs/identifier" + }, + "environmentId": { + "$ref": "#/$defs/identifier" + }, + "billingCustomerId": { + "$ref": "#/$defs/identifier" + }, + "subscriptionInstanceId": { + "$ref": "#/$defs/identifier" + }, + "snapshotVersion": { + "$ref": "#/$defs/snapshotVersion" + }, + "previousSnapshotVersion": { + "$ref": "#/$defs/priorSnapshotVersion" + }, + "projectionRuleVersion": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "occurredAt": { + "description": "When the change became effective, which is provider-derived and may be well before the event was created.", + "$ref": "#/$defs/utcTimestamp" + }, + "createdAt": { + "description": "When Mosaic committed the event. Ordering by createdAt is not safe; consumers order by snapshotVersion.", + "$ref": "#/$defs/utcTimestamp" + }, + "changedEntitlements": { + "description": "Ascending and unique by entitlementKey.", + "type": "array", + "maxItems": 200, + "items": { + "$ref": "#/$defs/changedEntitlement" + } + }, + "stateSummary": { + "$ref": "#/$defs/stateSummary" + }, + "sourceReason": { + "$ref": "#/$defs/sourceReason" + }, + "isTestSource": { + "type": "boolean" + }, + "correlationId": { + "$ref": "#/$defs/identifier" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + }, + "if": { + "properties": { + "eventType": { + "const": "customer.entitlements.changed" + } + }, + "required": [ + "eventType" + ] + }, + "then": { + "properties": { + "changedEntitlements": { + "minItems": 1 + } + } + } + } + } +} diff --git a/protocol/schema/customer-access-token/v1/compatibility-manifest.schema.json b/protocol/schema/customer-access-token/v1/compatibility-manifest.schema.json new file mode 100644 index 00000000..55899177 --- /dev/null +++ b/protocol/schema/customer-access-token/v1/compatibility-manifest.schema.json @@ -0,0 +1,309 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:customer-access-token:v1:compatibility-manifest", + "title": "Mosaic Customer Access Token Contract v1 compatibility manifest", + "description": "Machine-checked compatibility surface for Customer Access Token Contract 1, including the wire form the SDKs and the backend must agree on and the obligations an SDK accepts by holding a token at all.", + "type": "object", + "additionalProperties": false, + "required": [ + "customerAccessTokenContractVersion", + "status", + "schemas", + "canonicalFixtures", + "recordTypes", + "tokenModel", + "wireForm", + "lifetime", + "sdkObligations", + "readerPolicy" + ], + "properties": { + "customerAccessTokenContractVersion": { + "const": "1" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "releaseCandidate", + "approved", + "deprecated", + "retired" + ] + }, + "deprecation": { + "type": "object", + "additionalProperties": false, + "required": [ + "deprecatedAt", + "retiresAt" + ], + "properties": { + "deprecatedAt": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$" + }, + "retiresAt": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$" + }, + "supersededBy": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "migrationGuide": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "schemas": { + "type": "object", + "additionalProperties": false, + "required": [ + "token" + ], + "properties": { + "token": { + "$ref": "#/$defs/relativeJsonPath" + } + } + }, + "canonicalFixtures": { + "type": "array", + "minItems": 4, + "maxItems": 24, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/relativeJsonPath" + } + }, + "recordTypes": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "uniqueItems": true, + "items": { + "$ref": "urn:mosaic:protocol:schema:customer-access-token:v1:token#/$defs/recordType" + } + }, + "tokenModel": { + "description": "The decision this whole contract rests on: the token is opaque, not signed. It is pinned here so a later change to a signed model is a visible contract change with a version bump, not a quiet substitution.", + "type": "object", + "additionalProperties": false, + "required": [ + "form", + "entropyBits", + "prefix", + "storage", + "digestAlgorithm", + "signed", + "parseable", + "carriesEntitlementState", + "revocation", + "scopeEvaluation" + ], + "properties": { + "form": { + "const": "opaqueRandom" + }, + "entropyBits": { + "const": 256 + }, + "prefix": { + "const": "mcat_" + }, + "storage": { + "const": "digestOnly" + }, + "digestAlgorithm": { + "const": "sha256" + }, + "signed": { + "description": "False, deliberately. There is no JWS, no JWKS, no key identifier, and no signing algorithm to agree on, therefore none to get wrong.", + "const": false + }, + "parseable": { + "const": false + }, + "carriesEntitlementState": { + "description": "A token never carries access state. Access is read from the authoritative snapshot at read time, so a token cannot preserve a grant its holder has since lost.", + "const": false + }, + "revocation": { + "const": "immediateServerSide" + }, + "scopeEvaluation": { + "description": "Scope is evaluated against stored columns, not against claims presented by the caller.", + "const": "serverSideColumns" + } + } + }, + "wireForm": { + "description": "Header names are contract-owned: the SDKs, the backend, and any host-backend proxy must agree on them exactly.", + "type": "object", + "additionalProperties": false, + "required": [ + "customerTokenHeader", + "customerTokenScheme", + "publicSdkKeyHeader", + "publicSdkKeyAloneSufficient", + "clockSkewToleranceSeconds", + "clockEvaluatedBy" + ], + "properties": { + "customerTokenHeader": { + "const": "Authorization" + }, + "customerTokenScheme": { + "const": "Bearer" + }, + "publicSdkKeyHeader": { + "const": "Mosaic-SDK-Key" + }, + "publicSdkKeyAloneSufficient": { + "description": "False. A public SDK key identifies the application; it can never select a Billing Customer. Both headers are required on the sync surface.", + "const": false + }, + "clockSkewToleranceSeconds": { + "const": 60 + }, + "clockEvaluatedBy": { + "description": "The server. A device clock never decides whether a token is still valid, because a device clock is attacker-controlled.", + "const": "server" + } + } + }, + "lifetime": { + "type": "object", + "additionalProperties": false, + "required": [ + "defaultSeconds", + "maximumSeconds", + "minimumSeconds", + "refreshResponsibility" + ], + "properties": { + "defaultSeconds": { + "const": 3600 + }, + "maximumSeconds": { + "const": 86400 + }, + "minimumSeconds": { + "const": 60 + }, + "refreshResponsibility": { + "description": "The host application backend mints every token. Mosaic never refreshes one on the SDK's behalf, because Mosaic cannot authenticate the host's user.", + "const": "hostApplicationBackend" + } + } + }, + "sdkObligations": { + "description": "What an SDK promises by accepting a token. These are conformance obligations, checked by inspection and by the SDK cache tests, not by any schema.", + "type": "object", + "additionalProperties": false, + "required": [ + "storage", + "attachment", + "parsing", + "refreshOnUnauthorized", + "onLogout", + "onIdentityChange", + "onProviderFailure", + "loggingToken" + ], + "properties": { + "storage": { + "const": "memoryOnly" + }, + "attachment": { + "const": "everySyncRequest" + }, + "parsing": { + "const": "forbidden" + }, + "refreshOnUnauthorized": { + "description": "Exactly one forced refresh per token generation. A second 401 on a freshly minted token is a real failure, and retrying it forever is how an SDK turns an outage into a request storm.", + "const": "oncePerGeneration" + }, + "onLogout": { + "const": "discardTokenAndClearCache" + }, + "onIdentityChange": { + "const": "bumpGenerationCancelInFlightClearCache" + }, + "onProviderFailure": { + "description": "A host backend that cannot mint a token yields unavailable, never inactive.", + "const": "reportUnavailableNeverInactive" + }, + "loggingToken": { + "const": "forbidden" + } + } + }, + "readerPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "unknownContractVersion", + "unknownRecordType", + "unknownField", + "unknownScope", + "unknownAudience", + "expiredToken", + "revokedToken", + "audienceMismatch", + "customerMismatch", + "tokenInQueryString", + "tokenPersistedToDisk" + ], + "properties": { + "unknownContractVersion": { + "const": "rejectRecord" + }, + "unknownRecordType": { + "const": "rejectRecord" + }, + "unknownField": { + "const": "rejectRecord" + }, + "unknownScope": { + "const": "rejectRecord" + }, + "unknownAudience": { + "const": "rejectRecord" + }, + "expiredToken": { + "const": "refuseRequestReportUnavailable" + }, + "revokedToken": { + "const": "refuseRequestReportUnavailable" + }, + "audienceMismatch": { + "const": "refuseRequest" + }, + "customerMismatch": { + "description": "A token names exactly one Billing Customer. A request that asserts a different one is refused; the assertion never selects the customer.", + "const": "refuseRequest" + }, + "tokenInQueryString": { + "const": "forbidden" + }, + "tokenPersistedToDisk": { + "const": "forbidden" + } + } + } + }, + "$defs": { + "relativeJsonPath": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^\\.\\./\\.\\./.*\\.json$" + } + } +} diff --git a/protocol/schema/customer-access-token/v1/token.schema.json b/protocol/schema/customer-access-token/v1/token.schema.json new file mode 100644 index 00000000..e19d7caf --- /dev/null +++ b/protocol/schema/customer-access-token/v1/token.schema.json @@ -0,0 +1,374 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:customer-access-token:v1:token", + "title": "Mosaic Customer Access Token Contract v1", + "description": "A Customer Access Token is an OPAQUE random credential. It has no claims, no header, no signature, and no parseable structure: Mosaic stores only its SHA-256 digest alongside the columns that scope it, and every property named here is server-side metadata ABOUT a token rather than content INSIDE one. This contract therefore describes issuance, scope, lifetime, and revocation; an SDK never parses, decodes, inspects, or persists the token itself. No JWS, no JWKS, no key identifier, and no signing algorithm appears anywhere, by design.", + "type": "object", + "additionalProperties": false, + "required": [ + "customerAccessTokenContractVersion", + "recordType", + "payload" + ], + "properties": { + "customerAccessTokenContractVersion": { + "const": "1" + }, + "recordType": { + "enum": [ + "customerAccessTokenIssuanceRequest", + "customerAccessTokenIssuanceResult", + "customerAccessTokenMetadata", + "customerAccessTokenRevocation" + ] + }, + "payload": {} + }, + "allOf": [ + { + "if": { + "properties": { + "recordType": { + "const": "customerAccessTokenIssuanceRequest" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/customerAccessTokenIssuanceRequest" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "customerAccessTokenIssuanceResult" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/customerAccessTokenIssuanceResult" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "customerAccessTokenMetadata" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/customerAccessTokenMetadata" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "customerAccessTokenRevocation" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/customerAccessTokenRevocation" + } + } + } + } + ], + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "safeText": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]*$" + }, + "utcTimestamp": { + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$" + }, + "recordType": { + "description": "The complete, closed record-type set of Customer Access Token Contract 1.", + "enum": [ + "customerAccessTokenIssuanceRequest", + "customerAccessTokenIssuanceResult", + "customerAccessTokenMetadata", + "customerAccessTokenRevocation" + ] + }, + "tokenValue": { + "description": "The opaque credential: the prefix mcat_ followed by 43 base64url characters, which is 256 bits of randomness. It has no internal structure and nothing may be inferred from it. It is returned exactly once, at issuance; Mosaic stores only its SHA-256 digest and can never reproduce it.", + "$comment": "The mcat_ value in fixtures/customer-access-token/v1/tokens/issuance-result.json is FABRICATED: it was typed to satisfy this pattern and was never issued by any Mosaic deployment, so it grants nothing anywhere. It exists because the issuance result is the one record that carries a token value at all, and an SDK author needs to see its shape. No fixture, example, or document may ever carry a token a deployment actually minted -- a fixture is copied, committed, and published, and a real credential in one is a leaked credential.", + "type": "string", + "minLength": 48, + "maxLength": 48, + "pattern": "^mcat_[A-Za-z0-9_-]{43}$" + }, + "audience": { + "description": "What the token may be presented to. A token minted for one audience is refused by every other surface. The set is closed, so it is over-provisioned: server_check is reserved for a future server-facing audience and is NOT issued in Phase 9B, but declaring it now means adding that audience later costs no contract version.", + "enum": [ + "sdk_sync", + "server_check" + ] + }, + "scope": { + "description": "Closed, over-provisioned scope vocabulary. A token carries the least it can: entitlements.read alone is enough to synchronize, and restore.request is granted only to a client that may trigger a restore.", + "enum": [ + "entitlements.read", + "entitlements.sync", + "restore.request" + ] + }, + "scopes": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/scope" + } + }, + "tokenStatus": { + "enum": [ + "active", + "expired", + "revoked" + ] + }, + "revocationReason": { + "description": "Closed and over-provisioned. Revocation is one row update, which is the practical advantage of an opaque token over a signed one: there is nothing to wait out.", + "enum": [ + "customer_signed_out", + "identity_changed", + "operator_revoked", + "customer_deleted", + "key_rotated", + "suspected_compromise", + "superseded_by_new_token" + ] + }, + "customerAccessTokenIssuanceRequest": { + "description": "The host application backend asks Mosaic for a token for a user it has already authenticated. The request carries no Project or Environment: tenant scope is derived from the authenticated secret server key, so a compromised or careless caller cannot mint a token into a tenant it does not own.", + "type": "object", + "additionalProperties": false, + "required": [ + "billingCustomerId", + "audience", + "scopes", + "correlationId" + ], + "properties": { + "billingCustomerId": { + "$ref": "#/$defs/identifier" + }, + "audience": { + "$ref": "#/$defs/audience" + }, + "scopes": { + "$ref": "#/$defs/scopes" + }, + "requestedTtlSeconds": { + "description": "A request, not an instruction. Mosaic clamps it to the contract maximum; a caller can shorten a token's life but never lengthen it past the maximum.", + "type": "integer", + "minimum": 60, + "maximum": 86400 + }, + "correlationId": { + "$ref": "#/$defs/identifier" + } + } + }, + "customerAccessTokenIssuanceResult": { + "description": "The only record that ever carries the token value. It is returned to the host backend over its authenticated server-to-server channel and never logged, never stored by Mosaic, and never returned again.", + "type": "object", + "additionalProperties": false, + "required": [ + "token", + "metadata", + "correlationId" + ], + "properties": { + "token": { + "$ref": "#/$defs/tokenValue" + }, + "metadata": { + "$ref": "#/$defs/customerAccessTokenMetadata" + }, + "correlationId": { + "$ref": "#/$defs/identifier" + } + } + }, + "customerAccessTokenMetadata": { + "description": "Everything Mosaic knows about a token, which is deliberately everything the token itself does not carry. The scoping columns here are what authorization actually evaluates: there is no claims validation step, because there are no claims.", + "type": "object", + "additionalProperties": false, + "required": [ + "tokenId", + "projectId", + "environmentId", + "billingCustomerId", + "audience", + "scopes", + "issuer", + "issuedAt", + "expiresAt", + "status", + "tokenPrefix", + "digestAlgorithm" + ], + "properties": { + "tokenId": { + "description": "A stable public handle for the token, safe to log and to name in an audit event. It is not the token and cannot be presented as one.", + "$ref": "#/$defs/identifier" + }, + "projectId": { + "$ref": "#/$defs/identifier" + }, + "environmentId": { + "$ref": "#/$defs/identifier" + }, + "billingCustomerId": { + "$ref": "#/$defs/identifier" + }, + "audience": { + "$ref": "#/$defs/audience" + }, + "scopes": { + "$ref": "#/$defs/scopes" + }, + "issuer": { + "description": "The Mosaic installation that issued the token, for operator diagnostics in self-hosted deployments. It is never used to decide anything.", + "$ref": "#/$defs/safeText" + }, + "issuedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "notBefore": { + "$ref": "#/$defs/utcTimestamp" + }, + "expiresAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "status": { + "$ref": "#/$defs/tokenStatus" + }, + "revokedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "revocationReason": { + "$ref": "#/$defs/revocationReason" + }, + "lastUsedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "tokenPrefix": { + "const": "mcat_" + }, + "digestAlgorithm": { + "description": "How Mosaic stores the token. SHA-256 over the UTF-8 token bytes; the stored digest is what a presented token is compared against.", + "const": "sha256" + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "revoked" + } + }, + "required": [ + "status" + ] + }, + "then": { + "required": [ + "revokedAt", + "revocationReason" + ] + }, + "else": { + "not": { + "anyOf": [ + { + "required": [ + "revokedAt" + ] + }, + { + "required": [ + "revocationReason" + ] + } + ] + } + } + } + ] + }, + "customerAccessTokenRevocation": { + "description": "A revocation takes effect immediately and everywhere: the digest row is marked, so the next presentation of the token fails regardless of how long it had left to live.", + "type": "object", + "additionalProperties": false, + "required": [ + "tokenId", + "revokedAt", + "revocationReason", + "correlationId" + ], + "properties": { + "tokenId": { + "$ref": "#/$defs/identifier" + }, + "revokedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "revocationReason": { + "$ref": "#/$defs/revocationReason" + }, + "actorReference": { + "description": "An opaque handle for the operator or system that revoked the token. Never a name, an email address, or any other personal identifier.", + "$ref": "#/$defs/identifier" + }, + "correlationId": { + "$ref": "#/$defs/identifier" + } + } + } + } +} diff --git a/protocol/tools/authoritative-entitlement-validation-v1.mjs b/protocol/tools/authoritative-entitlement-validation-v1.mjs new file mode 100644 index 00000000..8ef6df9a --- /dev/null +++ b/protocol/tools/authoritative-entitlement-validation-v1.mjs @@ -0,0 +1,732 @@ +/** + * Authoritative Entitlement Contract v1 validation. + * + * The contract is five canonical schemas plus a compatibility manifest. Each + * schema is a self-describing envelope: `authoritativeEntitlementContractVersion`, + * `recordType`, and a `payload` dispatched by record type. A fixture's directory + * therefore determines which schema it is a document of. + * + * Most of this contract's rules are expressible in JSON Schema and are expressed + * there -- revoked implies inactive, grace implies a grace end, unknown implies a + * reason, `unavailable` is structurally impossible inside a persisted snapshot + * entry. What remains here is what JSON Schema cannot state: + * + * - the canonical serialization and its digests (`contentDigest`, `checksum`), + * which are what make a snapshot bindable to one customer and a projection + * comparable across replays; + * - snapshot-version monotonicity and freshness-window ordering; + * - the entry-to-source graph: counts, resolution, canonical ordering, and the + * rule that an active entry must actually have a granting source; and + * - two guards that watch the contract itself rather than a document: a + * persisted entitlement state may never include `unavailable`, and no reader + * policy may ever resolve a rejection to `inactive`. + * + * That last guard is the contract's whole point. Every other rule protects a + * field; that one protects the user, who must never lose access because Mosaic + * failed to answer. + */ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import Ajv2020 from "ajv/dist/2020.js"; + +import { REJECTION_LAYERS_FILENAME } from "./generate-rejection-layers.mjs"; + +const toolsDirectory = dirname(fileURLToPath(import.meta.url)); +export const authoritativeEntitlementV1Root = resolve(toolsDirectory, ".."); + +const schemaPath = (name) => + resolve( + authoritativeEntitlementV1Root, + `schema/authoritative-entitlement/v1/${name}.schema.json`, + ); + +export const authoritativeEntitlementV1Paths = Object.freeze({ + snapshotSchema: schemaPath("snapshot"), + syncRequestSchema: schemaPath("sync-request"), + checkSchema: schemaPath("check"), + subscriptionSchema: schemaPath("subscription"), + restoreSchema: schemaPath("restore"), + compatibilityManifestSchema: schemaPath("compatibility-manifest"), + compatibilityManifest: resolve( + authoritativeEntitlementV1Root, + "compatibility/authoritative-entitlement/v1.json", + ), + fixtureDirectory: resolve( + authoritativeEntitlementV1Root, + "fixtures/authoritative-entitlement/v1", + ), +}); + +/** Fixture directory to the schema its documents belong to. */ +const FIXTURE_FAMILIES = Object.freeze({ + snapshots: "snapshot", + sync: "syncRequest", + checks: "check", + subscriptions: "subscription", + restores: "restore", +}); + +/** Record types each schema accepts, used to catch misfiled fixtures. */ +const FAMILY_RECORD_TYPES = Object.freeze({ + snapshot: ["customerEntitlementSnapshot", "snapshotUnchanged"], + syncRequest: ["entitlementSyncRequest"], + check: ["entitlementCheckRequest", "entitlementCheckResult"], + subscription: ["subscriptionSnapshot"], + restore: ["restoreResult"], +}); + +export function readAuthoritativeEntitlementV1Json(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function jsonPaths(directory) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) return jsonPaths(path); + if (entry.name === REJECTION_LAYERS_FILENAME) return []; + return entry.name.endsWith(".json") ? [path] : []; + }) + .sort(); +} + +function familyOf(path) { + const directory = dirname( + relative(authoritativeEntitlementV1Paths.fixtureDirectory, path), + ); + return FIXTURE_FAMILIES[directory]; +} + +export function loadAuthoritativeEntitlementV1Artifacts() { + const fixturePaths = jsonPaths( + authoritativeEntitlementV1Paths.fixtureDirectory, + ); + const validFixturePaths = fixturePaths.filter( + (path) => !path.includes("/invalid/"), + ); + const invalidFixturePaths = fixturePaths.filter((path) => + path.includes("/invalid/"), + ); + return { + snapshotSchema: readAuthoritativeEntitlementV1Json( + authoritativeEntitlementV1Paths.snapshotSchema, + ), + syncRequestSchema: readAuthoritativeEntitlementV1Json( + authoritativeEntitlementV1Paths.syncRequestSchema, + ), + checkSchema: readAuthoritativeEntitlementV1Json( + authoritativeEntitlementV1Paths.checkSchema, + ), + subscriptionSchema: readAuthoritativeEntitlementV1Json( + authoritativeEntitlementV1Paths.subscriptionSchema, + ), + restoreSchema: readAuthoritativeEntitlementV1Json( + authoritativeEntitlementV1Paths.restoreSchema, + ), + compatibilityManifestSchema: readAuthoritativeEntitlementV1Json( + authoritativeEntitlementV1Paths.compatibilityManifestSchema, + ), + compatibilityManifest: readAuthoritativeEntitlementV1Json( + authoritativeEntitlementV1Paths.compatibilityManifest, + ), + fixturePaths, + validFixturePaths, + invalidFixturePaths, + validFixtures: validFixturePaths.map(readAuthoritativeEntitlementV1Json), + invalidFixtures: invalidFixturePaths.map(readAuthoritativeEntitlementV1Json), + }; +} + +function validators(artifacts) { + const ajv = new Ajv2020({ + allErrors: true, + strict: true, + strictRequired: false, + strictTypes: false, + }); + for (const schema of [ + artifacts.snapshotSchema, + artifacts.syncRequestSchema, + artifacts.checkSchema, + artifacts.subscriptionSchema, + artifacts.restoreSchema, + artifacts.compatibilityManifestSchema, + ]) { + ajv.addSchema(schema); + } + return { + snapshot: ajv.getSchema(artifacts.snapshotSchema.$id), + syncRequest: ajv.getSchema(artifacts.syncRequestSchema.$id), + check: ajv.getSchema(artifacts.checkSchema.$id), + subscription: ajv.getSchema(artifacts.subscriptionSchema.$id), + restore: ajv.getSchema(artifacts.restoreSchema.$id), + manifest: ajv.getSchema(artifacts.compatibilityManifestSchema.$id), + }; +} + +function describeSchemaError(label, error) { + const at = `${label}${error.instancePath || "/"}`; + const offending = + error.params?.unevaluatedProperty ?? error.params?.additionalProperty; + if (offending !== undefined) { + return `${at}.${offending} is not allowed`; + } + return `${at} ${error.message ?? "is invalid"}`; +} + +function schemaErrors(label, errors = []) { + const specific = errors.filter((error) => error.keyword !== "oneOf"); + const reported = specific.length > 0 ? specific : errors; + return [ + ...new Set(reported.map((error) => describeSchemaError(label, error))), + ]; +} + +/** + * The canonical serialization the digests are computed over, pinned in the + * manifest's `canonicalSerialization` block: minified JSON, object keys ascending + * by UTF-16 code unit, array order preserved because array order is itself + * normative, and no `null` anywhere. + */ +export function canonicalSerialization(value) { + if (Array.isArray(value)) { + return `[${value.map(canonicalSerialization).join(",")}]`; + } + if (value !== null && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalSerialization(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function canonicalDigest(payload, excludedMember) { + const copy = { ...payload }; + delete copy[excludedMember]; + return `sha256:${createHash("sha256") + .update(canonicalSerialization(copy), "utf8") + .digest("hex")}`; +} + +function encodedBytes(value) { + return Buffer.byteLength(JSON.stringify(value), "utf8"); +} + +function notAfter(earlier, later) { + return Date.parse(earlier) <= Date.parse(later); +} + +function ascending(values) { + return values.every( + (value, index) => index === 0 || values[index - 1] < value, + ); +} + +/** + * A JWS/receipt/purchase-token shape. Nothing in this contract may carry one, + * and no fixture may contain one even as a placeholder: the fixtures are the + * examples SDK authors copy. Unlike Billing Ingestion this validator does not + * police field *names* -- entitlement, subscription, and customer vocabulary is + * exactly what this contract is about -- only values. + */ +const JWS_SHAPE = /^[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}$/; + +function walkValues(value, path, visit) { + if (typeof value === "string") { + visit(value, path); + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => walkValues(item, `${path}/${index}`, visit)); + return; + } + if (value !== null && typeof value === "object") { + for (const [key, child] of Object.entries(value)) { + walkValues(child, `${path}/${key}`, visit); + } + } +} + +const MAX_CACHE_HORIZON_SECONDS = 2_592_000; + +/** + * The freshness window, shared by a snapshot and by the unchanged response that + * slides it. + * + * The combined-horizon bound is the one that actually matters. `validUntil` and + * `staleGraceSeconds` each have their own 30-day maximum, but only a bound on + * their sum stops a 30-day validity and a 30-day grace window from composing + * into 60 days during which a device serves access Mosaic has not confirmed. + */ +function freshnessWindowSemantics(label, payload) { + const errors = []; + if (!notAfter(payload.asOf, payload.issuedAt)) { + errors.push(`${label} was issued before the instant it evaluated state at`); + } + if (!notAfter(payload.issuedAt, payload.refreshAfter)) { + errors.push(`${label} recommends a refresh before it was issued`); + } + if (!notAfter(payload.refreshAfter, payload.validUntil)) { + errors.push( + `${label} refreshAfter is later than validUntil; the freshness window is ordered issuedAt <= refreshAfter <= validUntil`, + ); + } + const validitySeconds = + (Date.parse(payload.validUntil) - Date.parse(payload.issuedAt)) / 1000; + const horizon = validitySeconds + (payload.staleGraceSeconds ?? 0); + if (horizon > MAX_CACHE_HORIZON_SECONDS) { + errors.push( + `${label} offers a combined offline horizon of ${horizon} seconds; ` + + `validity plus stale grace may never exceed ${MAX_CACHE_HORIZON_SECONDS} seconds`, + ); + } + return errors; +} + +function snapshotSemantics(label, payload) { + const errors = [...freshnessWindowSemantics(label, payload)]; + + const expected = canonicalDigest(payload, "contentDigest"); + if (payload.contentDigest !== expected) { + errors.push( + `${label} contentDigest does not cover its own canonical serialization; ` + + "the digest binds the snapshot to its Billing Customer, Project, Environment, and version", + ); + } + + // The never-projected placeholder. The schema constrains its content; what it + // cannot constrain is the one instant the record carries. `lastProjectedAt` is + // required on every projection status, and on a placeholder there is no + // projection to date it from, so it is pinned to the record's own evaluation + // instant. Leaving it free would let a producer date a placeholder from an + // unrelated run and let a reader mistake it for evidence a projection happened. + if ( + payload.snapshotVersion === 0 && + payload.projectionStatus.lastProjectedAt !== payload.asOf + ) { + errors.push( + `${label} is the never-projected placeholder but dates its projection at ` + + `${payload.projectionStatus.lastProjectedAt} rather than its own asOf ${payload.asOf}; ` + + "no projection has run, so the only instant it can honestly report is the instant it was evaluated", + ); + } + + if ( + payload.previousSnapshotVersion !== undefined && + payload.snapshotVersion <= payload.previousSnapshotVersion + ) { + errors.push( + `${label} snapshotVersion ${payload.snapshotVersion} does not advance past ` + + `previousSnapshotVersion ${payload.previousSnapshotVersion}; snapshot versions are monotonic per customer per Environment`, + ); + } + + const keys = payload.entries.map((item) => item.entitlementKey); + if (!ascending(keys)) { + errors.push( + `${label} entries are not ascending and unique by entitlementKey; the ordering is normative because the contentDigest is computed over it`, + ); + } + const sourceIds = payload.sources.map((item) => item.sourceId); + if (!ascending(sourceIds)) { + errors.push(`${label} sources are not ascending and unique by sourceId`); + } + + const byId = new Map(payload.sources.map((item) => [item.sourceId, item])); + const referenced = new Set(); + + for (const entry of payload.entries) { + const at = `${label} entry ${entry.entitlementKey}`; + if (entry.sourceCount !== entry.sourceIds.length) { + errors.push( + `${at} declares sourceCount ${entry.sourceCount} but lists ${entry.sourceIds.length} sources`, + ); + } + if (!ascending(entry.sourceIds)) { + errors.push(`${at} sourceIds are not ascending and unique`); + } + const contributing = []; + for (const sourceId of entry.sourceIds) { + referenced.add(sourceId); + const source = byId.get(sourceId); + if (source === undefined) { + errors.push(`${at} references source ${sourceId}, which the snapshot does not carry`); + continue; + } + contributing.push(source); + } + const explanationSourceId = entry.primaryExplanation.sourceId; + if (explanationSourceId !== undefined && !byId.has(explanationSourceId)) { + errors.push( + `${at} explains itself with source ${explanationSourceId}, which the snapshot does not carry`, + ); + } + const granting = contributing.filter( + (source) => source.sourceState === "granting", + ); + const uncertain = contributing.filter( + (source) => source.sourceState === "unknown", + ); + if (entry.state === "active" && granting.length === 0) { + errors.push( + `${at} is active but no contributing source is granting; an active Entitlement always has a reason`, + ); + } + if (entry.state === "inactive" && granting.length > 0) { + errors.push(`${at} is inactive while a contributing source is still granting`); + } + if (entry.state === "inactive" && uncertain.length > 0) { + errors.push( + `${at} is inactive while a contributing source is uncertain; unresolved evidence yields unknown, never inactive`, + ); + } + if ( + entry.effectiveStart !== undefined && + entry.effectiveEnd !== undefined && + !notAfter(entry.effectiveStart, entry.effectiveEnd) + ) { + errors.push(`${at} ends before it starts`); + } + } + + for (const source of payload.sources) { + if (!referenced.has(source.sourceId)) { + errors.push( + `${label} carries source ${source.sourceId}, which no entry accounts for`, + ); + } + if (source.end !== undefined && !notAfter(source.start, source.end)) { + errors.push(`${label} source ${source.sourceId} ends before it starts`); + } + } + + return errors; +} + +function subscriptionSemantics(label, payload) { + const errors = []; + const expected = canonicalDigest(payload, "checksum"); + if (payload.checksum !== expected) { + errors.push( + `${label} checksum does not cover its own canonical serialization; ` + + "no-change replay detection and shadow comparison both depend on it", + ); + } + if ( + payload.periodStart !== undefined && + payload.periodEnd !== undefined && + !notAfter(payload.periodStart, payload.periodEnd) + ) { + errors.push(`${label} ends its service period before it starts`); + } + if ( + payload.pauseEffectiveAt !== undefined && + payload.pauseResumeAt !== undefined && + !notAfter(payload.pauseEffectiveAt, payload.pauseResumeAt) + ) { + errors.push(`${label} resumes from pause before the pause takes effect`); + } + return errors; +} + +function checkSemantics(label, payload) { + const errors = []; + const keys = payload.results.map((item) => item.entitlementKey); + if (!ascending(keys)) { + errors.push(`${label} results are not ascending and unique by entitlementKey`); + } + for (const result of payload.results) { + if ( + result.sourceIds !== undefined && + result.sourceCount !== result.sourceIds.length + ) { + errors.push( + `${label} result ${result.entitlementKey} declares sourceCount ${result.sourceCount} but lists ${result.sourceIds.length} sources`, + ); + } + if (payload.snapshotVersion === undefined && result.state !== "unavailable") { + errors.push( + `${label} result ${result.entitlementKey} claims ${result.state} without a snapshot to derive it from; ` + + "with no readable snapshot every result is unavailable", + ); + } + } + return errors; +} + +function restoreSemantics(label, payload) { + const errors = []; + if ( + payload.completedAt !== undefined && + !notAfter(payload.requestedAt, payload.completedAt) + ) { + errors.push(`${label} completed before it was requested`); + } + if ( + payload.pendingValidationCount !== undefined && + payload.observedTransactionCount !== undefined && + payload.pendingValidationCount > payload.observedTransactionCount + ) { + errors.push( + `${label} has more validations pending than transactions it observed`, + ); + } + return errors; +} + +function recordSemantics(label, document) { + const errors = []; + if (encodedBytes(document) > 65_536) { + errors.push(`${label} exceeds the 64 KiB record limit`); + } + walkValues(document, "", (value, path) => { + if (JWS_SHAPE.test(value)) { + errors.push(`${label}${path} carries a signed-payload-shaped value`); + } + }); + + const payload = document.payload; + switch (document.recordType) { + case "customerEntitlementSnapshot": + errors.push(...snapshotSemantics(label, payload)); + break; + case "snapshotUnchanged": + // An unchanged response slides the freshness window, so it is bound by + // the same horizon a snapshot is. Otherwise the bound could be evaded by + // confirming a snapshot rather than reissuing it. + errors.push(...freshnessWindowSemantics(label, payload)); + break; + case "subscriptionSnapshot": + errors.push(...subscriptionSemantics(label, payload)); + break; + case "entitlementCheckResult": + errors.push(...checkSemantics(label, payload)); + break; + case "restoreResult": + errors.push(...restoreSemantics(label, payload)); + break; + default: + break; + } + return errors; +} + +export function validateAuthoritativeEntitlementV1Record( + document, + artifacts, + label = "Entitlement record", +) { + const compiled = validators(artifacts); + const family = Object.entries(FAMILY_RECORD_TYPES).find(([, types]) => + types.includes(document?.recordType), + )?.[0]; + if (family === undefined) { + return [`${label} declares unknown record type ${document?.recordType}`]; + } + const validate = compiled[family]; + if (!validate(document)) return schemaErrors(label, validate.errors); + return recordSemantics(label, document); +} + +/** + * Guards the contract itself, not a document. + * + * Two invariants have to hold for the whole design to mean anything, and neither + * is checkable from any single record: a persisted Entitlement state may never + * include `unavailable` (which would let a service failure be written into + * immutable state), and no reader policy may ever resolve to `inactive` (which + * would let a failure look like a cancellation). + */ +function validateFailClosedVocabulary(artifacts) { + const errors = []; + const persisted = artifacts.snapshotSchema.$defs.persistedEntitlementState.enum; + if (persisted.includes("unavailable")) { + errors.push( + "A persisted Entitlement state may never include unavailable: it is a service-delivery state, not customer access", + ); + } + if (!persisted.includes("unknown")) { + errors.push( + "A persisted Entitlement state must include unknown, or a projection with incomplete evidence has nowhere safe to land", + ); + } + // The placeholder only works because it sorts below every version that can + // supersede it. If issued versions ever started at 0, a never-projected + // placeholder and a real first snapshot would be indistinguishable, and the + // monotonic gate would silently refuse the real one. + const issued = artifacts.snapshotSchema.$defs.snapshotVersion; + const placeholder = artifacts.snapshotSchema.$defs.snapshotVersionOrPlaceholder; + if (issued.minimum !== 1) { + errors.push( + "An issued snapshot version must start at 1, or the never-projected placeholder collides with a real first snapshot", + ); + } + if (placeholder.minimum !== 0) { + errors.push( + "The placeholder snapshot version must admit 0, which is the version a never-projected customer is answered with", + ); + } + + const policy = artifacts.compatibilityManifest.readerPolicy ?? {}; + for (const [key, value] of Object.entries(policy)) { + // `...NeverInactive` is the rule being stated, not broken. + if (typeof value === "string" && /(? !listed.includes(recordType)) + ) { + errors.push("Authoritative Entitlement record-type set is incomplete"); + } + const dispatched = Object.values(FAMILY_RECORD_TYPES).flat(); + for (const recordType of declared) { + if (!dispatched.includes(recordType)) { + errors.push( + `Authoritative Entitlement record type ${recordType} has no schema that dispatches it`, + ); + } + } + + const axes = { + accessState: artifacts.snapshotSchema.$defs.accessState.enum, + lifecycleState: artifacts.snapshotSchema.$defs.lifecycleState.enum, + renewalIntent: artifacts.snapshotSchema.$defs.renewalIntent.enum, + billingState: artifacts.snapshotSchema.$defs.billingState.enum, + uncertaintyReason: artifacts.snapshotSchema.$defs.uncertaintyReason.enum, + persistedEntitlementState: + artifacts.snapshotSchema.$defs.persistedEntitlementState.enum, + }; + for (const [axis, members] of Object.entries(axes)) { + const pinned = manifest.stateAxes[axis]; + if ( + pinned.length !== members.length || + members.some((member) => !pinned.includes(member)) + ) { + errors.push( + `Authoritative Entitlement manifest pins a ${axis} set that differs from the schema`, + ); + } + } + + const manifestDirectory = dirname( + authoritativeEntitlementV1Paths.compatibilityManifest, + ); + for (const path of [ + ...Object.values(manifest.schemas), + ...manifest.canonicalFixtures, + ]) { + if (!existsSync(resolve(manifestDirectory, path))) { + errors.push(`Authoritative Entitlement compatibility path does not exist: ${path}`); + } + } + + const canonical = new Set( + manifest.canonicalFixtures.map((path) => resolve(manifestDirectory, path)), + ); + for (const path of artifacts.validFixturePaths) { + if (!canonical.has(path)) { + errors.push( + `Authoritative Entitlement fixture ${relative(authoritativeEntitlementV1Root, path)} is not listed in the compatibility manifest`, + ); + } + } + const covered = new Set( + artifacts.validFixtures.map((document) => document.recordType), + ); + for (const recordType of declared) { + if (!covered.has(recordType)) { + errors.push( + `Authoritative Entitlement record type ${recordType} has no canonical fixture`, + ); + } + } + return errors; +} + +export function validateAuthoritativeEntitlementV1Artifacts(artifacts) { + const compiled = validators(artifacts); + const errors = [ + ...validateFailClosedVocabulary(artifacts), + ...validateCompatibility(artifacts), + ]; + + for (const [index, document] of artifacts.validFixtures.entries()) { + const path = artifacts.validFixturePaths[index]; + const label = `Entitlement fixture ${relative(authoritativeEntitlementV1Root, path)}`; + const family = familyOf(path); + if (family === undefined) { + errors.push(`${label} is in a directory with no schema family`); + continue; + } + if (!FAMILY_RECORD_TYPES[family].includes(document.recordType)) { + errors.push( + `${label} declares ${document.recordType}, which does not belong in this directory`, + ); + continue; + } + const validate = compiled[family]; + if (!validate(document)) { + errors.push(...schemaErrors(label, validate.errors)); + continue; + } + errors.push(...recordSemantics(label, document)); + } + + for (const [index, document] of artifacts.invalidFixtures.entries()) { + const path = artifacts.invalidFixturePaths[index]; + const label = `Invalid Entitlement fixture ${relative(authoritativeEntitlementV1Root, path)}`; + const accepted = Object.keys(FAMILY_RECORD_TYPES).some((family) => { + const validate = compiled[family]; + return validate(document) && recordSemantics(label, document).length === 0; + }); + if (accepted) errors.push(`${label} was accepted`); + } + + return errors; +} + +export function validateAuthoritativeEntitlementV1JsonFormatting() { + const paths = [ + authoritativeEntitlementV1Paths.snapshotSchema, + authoritativeEntitlementV1Paths.syncRequestSchema, + authoritativeEntitlementV1Paths.checkSchema, + authoritativeEntitlementV1Paths.subscriptionSchema, + authoritativeEntitlementV1Paths.restoreSchema, + authoritativeEntitlementV1Paths.compatibilityManifestSchema, + authoritativeEntitlementV1Paths.compatibilityManifest, + ...jsonPaths(authoritativeEntitlementV1Paths.fixtureDirectory), + ]; + return paths.flatMap((path) => { + const source = readFileSync(path, "utf8"); + const canonical = `${JSON.stringify(JSON.parse(source), null, 2)}\n`; + return source === canonical + ? [] + : [ + `${relative(authoritativeEntitlementV1Root, path)} is not canonical JSON`, + ]; + }); +} diff --git a/protocol/tools/authoritative-entitlement-validation-v1.test.mjs b/protocol/tools/authoritative-entitlement-validation-v1.test.mjs new file mode 100644 index 00000000..6ced649d --- /dev/null +++ b/protocol/tools/authoritative-entitlement-validation-v1.test.mjs @@ -0,0 +1,452 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { + authoritativeEntitlementV1Root, + canonicalDigest, + canonicalSerialization, + loadAuthoritativeEntitlementV1Artifacts, + readAuthoritativeEntitlementV1Json, + validateAuthoritativeEntitlementV1Artifacts, + validateAuthoritativeEntitlementV1JsonFormatting, + validateAuthoritativeEntitlementV1Record, +} from "./authoritative-entitlement-validation-v1.mjs"; + +const artifacts = loadAuthoritativeEntitlementV1Artifacts(); + +const vectors = (name) => + readAuthoritativeEntitlementV1Json( + resolve(authoritativeEntitlementV1Root, `../packages/test-fixtures/src/${name}`), + ); + +/** Reloads a fixture from disk so a mutation in one test cannot leak into another. */ +function fixture(name) { + const path = artifacts.fixturePaths.find((candidate) => + candidate.endsWith(`/${name}`), + ); + assert.ok(path, `Missing fixture ${name}`); + return readAuthoritativeEntitlementV1Json(path); +} + +test("the committed contract validates clean", () => { + assert.deepEqual(validateAuthoritativeEntitlementV1Artifacts(artifacts), []); + assert.deepEqual(validateAuthoritativeEntitlementV1JsonFormatting(), []); +}); + +test("the manifest is born a draft and pins the fail-closed-to-unknown rule", () => { + const manifest = artifacts.compatibilityManifest; + assert.equal(manifest.status, "draft"); + assert.equal(manifest.readerPolicy.rejectedRecord, "reportUnknownPreserveCache"); + assert.equal(manifest.readerPolicy.inactiveInference, "forbidden"); + assert.equal(manifest.readerPolicy.expiredCache, "reportUnknownNeverInactive"); + assert.equal(manifest.readerPolicy.billingDisabled, "unavailableNeverInactive"); + assert.equal( + manifest.readerPolicy.customerBindingMismatch, + "clearCacheReportUnknown", + ); + assert.equal(manifest.readerPolicy.partialAcceptance, "forbidden"); +}); + +test("no reader policy may ever resolve a failure to inactive", () => { + // The contract-level guard, exercised by breaking it. A policy that resolved a + // rejection to inactive would turn every Mosaic outage into a mass revocation. + const broken = structuredClone(artifacts); + broken.compatibilityManifest = structuredClone(artifacts.compatibilityManifest); + broken.compatibilityManifest.readerPolicy.expiredCache = "reportInactive"; + const errors = validateAuthoritativeEntitlementV1Artifacts(broken); + assert.ok( + errors.some((error) => error.includes("may ever resolve to inactive")), + `expected an inactive-inference error, got: ${errors.join("; ")}`, + ); +}); + +test("unavailable can never be persisted in a snapshot entry", () => { + // unavailable says Mosaic could not answer. Persisting it into an immutable + // snapshot would record a service failure as customer state. + assert.deepEqual( + artifacts.snapshotSchema.$defs.persistedEntitlementState.enum, + ["active", "inactive", "unknown"], + ); + const document = fixture("snapshots/active-subscription.json"); + document.payload.entries[0].state = "unavailable"; + assert.notDeepEqual( + validateAuthoritativeEntitlementV1Record(document, artifacts), + [], + ); + + // It remains admissible on a read-time check response, which is the whole + // reason the two vocabularies are separate. + const check = fixture("checks/check-result-unavailable-billing-disabled.json"); + assert.equal(check.payload.results[0].state, "unavailable"); + assert.deepEqual(validateAuthoritativeEntitlementV1Record(check, artifacts), []); +}); + +test("cancellation changes renewal intent without ending access", () => { + // The behavioural rule the whole contract exists to protect: a cancelled + // subscription keeps access until a validated fact proves the period ended. + const document = fixture("subscriptions/cancelled-access-still-active.json"); + assert.equal(document.payload.renewalIntent, "auto_renew_disabled"); + assert.equal(document.payload.accessState, "active"); + assert.equal(document.payload.lifecycleState, "active"); + assert.ok(document.payload.cancellationEffectiveAt); + assert.deepEqual( + validateAuthoritativeEntitlementV1Record(document, artifacts), + [], + ); +}); + +test("a revoked subscription can never report active access", () => { + const document = fixture("subscriptions/revoked.json"); + document.payload.accessState = "active"; + assert.notDeepEqual( + validateAuthoritativeEntitlementV1Record(document, artifacts), + [], + ); + + const missingInstant = fixture("subscriptions/revoked.json"); + delete missingInstant.payload.revocationEffectiveAt; + assert.notDeepEqual( + validateAuthoritativeEntitlementV1Record(missingInstant, artifacts), + [], + ); +}); + +test("a permanent source reports no finite expiry", () => { + // Reporting the subscription's end date here would tell a lifetime purchaser + // their access expires next month. + const document = fixture("snapshots/permanent-source-no-finite-expiry.json"); + const entry = document.payload.entries[0]; + assert.equal(entry.state, "active"); + assert.equal(entry.endKnown, true); + assert.equal(entry.effectiveEnd, undefined); + assert.equal(entry.sourceCount, 2); +}); + +test("refunding one source leaves an unrelated source granting", () => { + const document = fixture( + "snapshots/refund-of-one-source-other-remains-active.json", + ); + const entry = document.payload.entries[0]; + assert.equal(entry.state, "active"); + const states = Object.fromEntries( + document.payload.sources.map((source) => [source.sourceId, source.sourceState]), + ); + assert.equal(states["fixture-source-subscription-0001"], "not_granting"); + assert.equal(states["fixture-source-lifetime-0001"], "granting"); +}); + +test("every contributing source is exposed, never collapsed to a winner", () => { + const document = fixture("snapshots/multiple-active-sources.json"); + const entry = document.payload.entries[0]; + assert.equal(entry.sourceCount, 3); + assert.equal(entry.sourceIds.length, 3); + assert.equal(document.payload.sources.length, 3); +}); + +test("an active entry always has a granting source", () => { + const document = fixture("snapshots/active-subscription.json"); + document.payload.sources[0].sourceState = "not_granting"; + document.payload.contentDigest = canonicalDigest( + document.payload, + "contentDigest", + ); + assert.notDeepEqual( + validateAuthoritativeEntitlementV1Record(document, artifacts), + [], + ); +}); + +test("an entry is never inactive while its evidence is uncertain", () => { + // Unresolved evidence yields unknown. Reporting inactive here is how a + // projection outage becomes an apparent cancellation. + const document = fixture("snapshots/active-subscription.json"); + document.payload.entries[0].state = "inactive"; + document.payload.sources[0].sourceState = "unknown"; + document.payload.sources[0].uncertainty = { + reason: "provider_unavailable", + since: "2026-07-28T11:00:00.000Z", + }; + document.payload.contentDigest = canonicalDigest( + document.payload, + "contentDigest", + ); + const errors = validateAuthoritativeEntitlementV1Record(document, artifacts); + assert.ok( + errors.some((error) => error.includes("unresolved evidence yields unknown")), + `expected an uncertain-evidence error, got: ${errors.join("; ")}`, + ); +}); + +test("the content digest binds a snapshot to one customer, Project, and Environment", () => { + const document = fixture("snapshots/active-subscription.json"); + assert.deepEqual( + validateAuthoritativeEntitlementV1Record(document, artifacts), + [], + ); + + for (const member of ["billingCustomerId", "projectId", "environmentId", "snapshotVersion"]) { + const tampered = fixture("snapshots/active-subscription.json"); + tampered.payload[member] = + typeof tampered.payload[member] === "number" + ? tampered.payload[member] + 1 + : "fixture-other-0002"; + assert.notDeepEqual( + validateAuthoritativeEntitlementV1Record(tampered, artifacts), + [], + `${member} is not covered by the content digest`, + ); + } +}); + +test("snapshot versions are monotonic against their own predecessor", () => { + const document = fixture("snapshots/active-subscription.json"); + document.payload.snapshotVersion = document.payload.previousSnapshotVersion; + document.payload.contentDigest = canonicalDigest( + document.payload, + "contentDigest", + ); + assert.notDeepEqual( + validateAuthoritativeEntitlementV1Record(document, artifacts), + [], + ); +}); + +test("the Entitlement key vocabulary is the Commerce Configuration one", () => { + // One key vocabulary spans catalogue and access. If these drifted, a Product + // could grant a key no Entitlement could ever be named. + const commerce = readAuthoritativeEntitlementV1Json( + resolve( + authoritativeEntitlementV1Root, + "schema/commerce-configuration/v2/configuration.schema.json", + ), + ); + assert.equal( + artifacts.snapshotSchema.$defs.entitlementKey.pattern, + commerce.$defs.entitlementKey.pattern, + ); + assert.equal( + artifacts.snapshotSchema.$defs.entitlementKey.maxLength, + commerce.$defs.entitlementKey.maxLength, + ); +}); + +test("an unknown Entitlement key is project data and is accepted", () => { + // Defining a new Entitlement must never be a breaking change for an SDK that + // shipped before it existed. + assert.equal( + artifacts.compatibilityManifest.readerPolicy.unknownEntitlementKey, + "acceptAsProjectData", + ); + const document = fixture("snapshots/active-subscription.json"); + document.payload.entries[0].entitlementKey = "a_key_no_reader_has_ever_seen"; + document.payload.contentDigest = canonicalDigest( + document.payload, + "contentDigest", + ); + assert.deepEqual( + validateAuthoritativeEntitlementV1Record(document, artifacts), + [], + ); +}); + +test("no fixture carries a signed-payload-shaped value", () => { + const jws = /^[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}$/; + const strings = (value) => + typeof value === "string" + ? [value] + : value !== null && typeof value === "object" + ? Object.values(value).flatMap(strings) + : []; + for (const document of artifacts.validFixtures) { + for (const value of strings(document)) assert.doesNotMatch(value, jws); + } +}); + +test("every snapshot digest vector is the SHA-256 of its canonical serialization", () => { + const document = vectors("entitlement-snapshot-digest-vectors.json"); + assert.equal(document.canonicalSerialization.form, "minifiedJsonSortedKeys"); + + const digests = new Set(); + for (const vector of document.vectors) { + const serialized = canonicalSerialization(vector.payload); + assert.equal( + serialized, + vector.canonicalSerialization, + `${vector.id} canonical serialization drifted`, + ); + assert.equal( + Buffer.byteLength(serialized, "utf8"), + vector.canonicalByteLength, + `${vector.id} declares the wrong UTF-8 byte length`, + ); + assert.equal( + `sha256:${createHash("sha256").update(serialized, "utf8").digest("hex")}`, + vector.digest, + `${vector.id} digest is not SHA-256 over its canonical serialization`, + ); + digests.add(vector.digest); + } + assert.equal(digests.size, document.vectors.length, "distinct payloads must hash distinctly"); + + // Absent and present are different states, which is why null is forbidden. + const absent = document.vectors.find((vector) => vector.id === "absent-optional"); + const present = document.vectors.find((vector) => vector.id === "present-optional"); + assert.notEqual(absent.digest, present.digest); + + // The vector that catches a UTF-16 or Latin-1 implementation must contain + // non-ASCII, or it catches nothing. + const nonAscii = document.vectors.find((vector) => vector.id === "non-ascii-safe-text"); + assert.match(nonAscii.payload.safeSummary, /[^\x00-\x7F]/); +}); + +test("the canonical digest vector agrees with the canonical fixture", () => { + const document = vectors("entitlement-snapshot-digest-vectors.json"); + const vector = document.vectors.find( + (candidate) => candidate.id === "canonical-fixture-snapshot", + ); + const snapshot = fixture("snapshots/active-subscription.json"); + assert.equal( + vector.digest, + snapshot.payload.contentDigest, + "the canonical vector and the canonical fixture have drifted apart", + ); +}); + +test("cache-decision vectors never resolve a rejection to inactive", () => { + const document = vectors("entitlement-cache-decision-vectors.json"); + assert.ok(document.vectors.length >= 8); + const seen = new Set(); + for (const vector of document.vectors) { + assert.ok(["accept", "reject"].includes(vector.decision), vector.id); + assert.ok(["preserve", "clear", "replace"].includes(vector.cacheAction), vector.id); + assert.doesNotMatch( + vector.resultingAccessState, + /^inactive$/, + `${vector.id} resolves a cache decision to inactive`, + ); + if (vector.decision === "reject") { + assert.notEqual(vector.cacheAction, "replace", vector.id); + } + seen.add(vector.reason); + } + // The reasons the SDKs branch on must all be represented. + for (const reason of [ + "snapshot_version_not_newer", + "customer_mismatch", + "environment_mismatch", + "content_digest_mismatch", + "unsupported_contract_version", + "as_of_regression", + ]) { + assert.ok(seen.has(reason), `no cache-decision vector covers ${reason}`); + } + + // A binding mismatch is the one rejection that clears rather than preserves. + for (const id of ["different-customer-clears-cache", "version-regression-after-environment-change"]) { + const vector = document.vectors.find((candidate) => candidate.id === id); + assert.equal(vector.cacheAction, "clear", id); + assert.equal(vector.resultingAccessState, "unknown", id); + } +}); + +test("freshness vectors agree with the window they declare", () => { + const document = vectors("entitlement-freshness-vectors.json"); + const skew = document.policy.clockSkewToleranceSeconds * 1000; + assert.equal(skew, 60_000); + + for (const vector of document.vectors) { + const { issuedAt, refreshAfter, validUntil, staleGraceSeconds } = vector.snapshot; + const now = Date.parse(vector.deviceNow); + const graceEnd = Date.parse(validUntil) + staleGraceSeconds * 1000; + + let expected; + if (now < Date.parse(issuedAt) - skew) { + expected = "expired"; // unreliable clock forces expired-equivalent behaviour + } else if (now <= Date.parse(refreshAfter) + skew) { + expected = "fresh"; + } else if (now <= Date.parse(validUntil) + skew) { + expected = "refresh_recommended"; + } else if (now <= graceEnd + skew) { + expected = "stale_within_grace"; + } else { + expected = "expired"; + } + assert.equal(vector.state, expected, `${vector.id} disagrees with the declared window`); + } + + const states = new Set(document.vectors.map((vector) => vector.state)); + for (const state of ["fresh", "refresh_recommended", "stale_within_grace", "expired"]) { + assert.ok(states.has(state), `no freshness vector produces ${state}`); + } + + // A backwards clock must never read as fresh: that is unlimited offline access + // for anyone willing to change their device time. + const backwards = document.vectors.find( + (vector) => vector.id === "backwards-clock-before-issued-at", + ); + assert.equal(backwards.state, "expired"); +}); + +test("the freshness policy matches the limits the manifest pins", () => { + const document = vectors("entitlement-freshness-vectors.json"); + const limits = artifacts.compatibilityManifest.limits; + assert.equal(document.policy.clockSkewToleranceSeconds, limits.clockSkewToleranceSeconds); + assert.equal(document.policy.defaultRefreshAfterSeconds, limits.defaultRefreshAfterSeconds); + assert.equal(document.policy.defaultValidUntilSeconds, limits.defaultValidUntilSeconds); + assert.equal(document.policy.defaultStaleGraceSeconds, limits.defaultStaleGraceSeconds); + assert.equal(document.policy.maxStaleGraceSeconds, limits.maxStaleGraceSeconds); + assert.equal(document.policy.maxCacheHorizonSeconds, limits.maxCacheHorizonSeconds); +}); + +test("bounded grace is the shipped default, and strict is still expressible", () => { + // A zero default would ship the strict policy under a bounded-grace decision. + assert.equal(artifacts.compatibilityManifest.limits.defaultStaleGraceSeconds, 86400); + const document = vectors("entitlement-freshness-vectors.json"); + const strict = document.vectors.find( + (vector) => vector.id === "strict-policy-past-valid-until", + ); + assert.equal(strict.snapshot.staleGraceSeconds, 0); + assert.equal(strict.state, "expired"); +}); + +test("validity plus stale grace may never exceed thirty days", () => { + // Bounding each field alone lets a 30-day validity and a 30-day grace window + // compose into 60 days of offline access Mosaic never confirmed. + const limits = artifacts.compatibilityManifest.limits; + assert.equal(limits.maxCacheHorizonSeconds, 2592000); + + const document = fixture("snapshots/bounded-offline-cache.json"); + assert.deepEqual( + validateAuthoritativeEntitlementV1Record(document, artifacts), + [], + ); + + document.payload.validUntil = "2026-08-27T12:00:00.000Z"; // 30 days of validity + document.payload.staleGraceSeconds = 86400; // plus a day of grace + document.payload.contentDigest = canonicalDigest( + document.payload, + "contentDigest", + ); + const errors = validateAuthoritativeEntitlementV1Record(document, artifacts); + assert.ok( + errors.some((error) => error.includes("combined offline horizon")), + `expected a combined-horizon error, got: ${errors.join("; ")}`, + ); +}); + +test("an unchanged response is bound by the same horizon as a snapshot", () => { + // Otherwise the bound could be evaded by confirming a snapshot rather than + // reissuing it. + const document = fixture("snapshots/snapshot-unchanged.json"); + assert.deepEqual( + validateAuthoritativeEntitlementV1Record(document, artifacts), + [], + ); + document.payload.validUntil = "2026-09-27T12:45:00.000Z"; + assert.notDeepEqual( + validateAuthoritativeEntitlementV1Record(document, artifacts), + [], + ); +}); diff --git a/protocol/tools/billing-ingestion-validation-v1.test.mjs b/protocol/tools/billing-ingestion-validation-v1.test.mjs index 6a22f9f5..524e3809 100644 --- a/protocol/tools/billing-ingestion-validation-v1.test.mjs +++ b/protocol/tools/billing-ingestion-validation-v1.test.mjs @@ -206,7 +206,7 @@ test("every Google Play digest vector is the SHA-256 of its UTF-8 token", () => const nonAscii = vectors.find((vector) => vector.id === "non-ascii-token"); assert.ok(nonAscii, "the non-ASCII encoding vector is missing"); // eslint-disable-next-line no-control-regex - assert.match(nonAscii.token, /[^-]/); + assert.match(nonAscii.token, /[^\x00-\x7F]/); }); test("reference vectors match the contract patterns and the canonical fixtures", () => { diff --git a/protocol/tools/billing-state-webhook-validation-v1.mjs b/protocol/tools/billing-state-webhook-validation-v1.mjs new file mode 100644 index 00000000..be31f719 --- /dev/null +++ b/protocol/tools/billing-state-webhook-validation-v1.mjs @@ -0,0 +1,487 @@ +/** + * Billing State Webhook Contract v1 validation. + * + * Two canonical schemas plus a compatibility manifest. The event is what Mosaic + * transmits; the delivery attempt is what operators read and is never + * transmitted at all. + * + * The semantic layer covers what JSON Schema cannot: time ordering, snapshot + * monotonicity, retry arithmetic, response-code agreement, and the rule that an + * event must actually report a change. It also carries three guards on the + * contract itself rather than on any document -- no event type may name a + * provider, the emitted set must be a subset of the declared vocabulary, and a + * summarized access state may never be `unavailable` -- plus the + * forbidden-value walk ported from Billing Ingestion. Only the *values* + * are ported: Billing Ingestion also bans entitlement and subscription field + * names, which here would ban the entire contract. + */ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import Ajv2020 from "ajv/dist/2020.js"; + +import { REJECTION_LAYERS_FILENAME } from "./generate-rejection-layers.mjs"; + +const toolsDirectory = dirname(fileURLToPath(import.meta.url)); +export const billingStateWebhookV1Root = resolve(toolsDirectory, ".."); + +const schemaPath = (name) => + resolve( + billingStateWebhookV1Root, + `schema/billing-state-webhook/v1/${name}.schema.json`, + ); + +export const billingStateWebhookV1Paths = Object.freeze({ + eventSchema: schemaPath("event"), + deliverySchema: schemaPath("delivery"), + compatibilityManifestSchema: schemaPath("compatibility-manifest"), + compatibilityManifest: resolve( + billingStateWebhookV1Root, + "compatibility/billing-state-webhook/v1.json", + ), + fixtureDirectory: resolve( + billingStateWebhookV1Root, + "fixtures/billing-state-webhook/v1", + ), +}); + +const FIXTURE_FAMILIES = Object.freeze({ + events: "event", + deliveries: "delivery", +}); + +const FAMILY_RECORD_TYPES = Object.freeze({ + event: ["billingStateEvent"], + delivery: ["webhookDeliveryAttempt"], +}); + +/** + * Reasons a committed change may leave every Entitlement state untouched. A + * period extension and a cancellation are real changes an application backend + * wants to hear about even though nothing gained or lost access. + */ +const NON_STATE_CHANGE_REASONS = Object.freeze([ + "subscription_period_changed", + "renewal_intent_changed", + "grant_version_changed", +]); + +export function readBillingStateWebhookV1Json(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function jsonPaths(directory) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) return jsonPaths(path); + if (entry.name === REJECTION_LAYERS_FILENAME) return []; + return entry.name.endsWith(".json") ? [path] : []; + }) + .sort(); +} + +function familyOf(path) { + const directory = dirname( + relative(billingStateWebhookV1Paths.fixtureDirectory, path), + ); + return FIXTURE_FAMILIES[directory]; +} + +export function loadBillingStateWebhookV1Artifacts() { + const fixturePaths = jsonPaths(billingStateWebhookV1Paths.fixtureDirectory); + const validFixturePaths = fixturePaths.filter( + (path) => !path.includes("/invalid/"), + ); + const invalidFixturePaths = fixturePaths.filter((path) => + path.includes("/invalid/"), + ); + return { + eventSchema: readBillingStateWebhookV1Json( + billingStateWebhookV1Paths.eventSchema, + ), + deliverySchema: readBillingStateWebhookV1Json( + billingStateWebhookV1Paths.deliverySchema, + ), + compatibilityManifestSchema: readBillingStateWebhookV1Json( + billingStateWebhookV1Paths.compatibilityManifestSchema, + ), + compatibilityManifest: readBillingStateWebhookV1Json( + billingStateWebhookV1Paths.compatibilityManifest, + ), + fixturePaths, + validFixturePaths, + invalidFixturePaths, + validFixtures: validFixturePaths.map(readBillingStateWebhookV1Json), + invalidFixtures: invalidFixturePaths.map(readBillingStateWebhookV1Json), + }; +} + +function validators(artifacts) { + const ajv = new Ajv2020({ + allErrors: true, + strict: true, + strictRequired: false, + strictTypes: false, + }); + for (const schema of [ + artifacts.eventSchema, + artifacts.deliverySchema, + artifacts.compatibilityManifestSchema, + ]) { + ajv.addSchema(schema); + } + return { + event: ajv.getSchema(artifacts.eventSchema.$id), + delivery: ajv.getSchema(artifacts.deliverySchema.$id), + manifest: ajv.getSchema(artifacts.compatibilityManifestSchema.$id), + }; +} + +function describeSchemaError(label, error) { + const at = `${label}${error.instancePath || "/"}`; + const offending = + error.params?.unevaluatedProperty ?? error.params?.additionalProperty; + if (offending !== undefined) return `${at}.${offending} is not allowed`; + return `${at} ${error.message ?? "is invalid"}`; +} + +function schemaErrors(label, errors = []) { + const specific = errors.filter((error) => error.keyword !== "oneOf"); + const reported = specific.length > 0 ? specific : errors; + return [ + ...new Set(reported.map((error) => describeSchemaError(label, error))), + ]; +} + +const JWS_SHAPE = /^[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}$/; + +function walkValues(value, path, visit) { + if (typeof value === "string") { + visit(value, path); + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => walkValues(item, `${path}/${index}`, visit)); + return; + } + if (value !== null && typeof value === "object") { + for (const [key, child] of Object.entries(value)) { + walkValues(child, `${path}/${key}`, visit); + } + } +} + +function notAfter(earlier, later) { + return Date.parse(earlier) <= Date.parse(later); +} + +function ascending(values) { + return values.every((value, index) => index === 0 || values[index - 1] < value); +} + +function eventSemantics(label, payload) { + const errors = []; + if (!notAfter(payload.occurredAt, payload.createdAt)) { + errors.push(`${label} was created before the change it reports occurred`); + } + if ( + payload.previousSnapshotVersion !== undefined && + payload.snapshotVersion <= payload.previousSnapshotVersion + ) { + errors.push( + `${label} snapshotVersion ${payload.snapshotVersion} does not advance past ` + + `previousSnapshotVersion ${payload.previousSnapshotVersion}; a consumer orders by this value`, + ); + } + const keys = payload.changedEntitlements.map((item) => item.entitlementKey); + if (!ascending(keys)) { + errors.push( + `${label} changedEntitlements are not ascending and unique by entitlementKey`, + ); + } + const changedState = payload.changedEntitlements.some( + (item) => item.previousState !== item.currentState, + ); + if ( + payload.changedEntitlements.length > 0 && + !changedState && + !NON_STATE_CHANGE_REASONS.includes(payload.sourceReason) + ) { + errors.push( + `${label} reports no state change under sourceReason ${payload.sourceReason}; ` + + "an event that changes nothing is a no-change projection, which emits no webhook", + ); + } + return errors; +} + +function deliverySemantics(label, payload) { + const errors = []; + if (payload.attempt > payload.maxAttempts) { + errors.push(`${label} attempt exceeds maxAttempts`); + } + if (payload.status === "exhausted" && payload.attempt !== payload.maxAttempts) { + errors.push( + `${label} is exhausted on attempt ${payload.attempt} of ${payload.maxAttempts}; ` + + "exhaustion means the attempts actually ran out", + ); + } + if ( + payload.respondedAt !== undefined && + !notAfter(payload.requestedAt, payload.respondedAt) + ) { + errors.push(`${label} was answered before it was sent`); + } + if ( + payload.nextAttemptAt !== undefined && + !notAfter(payload.requestedAt, payload.nextAttemptAt) + ) { + errors.push(`${label} schedules its next attempt before this one was sent`); + } + const code = payload.responseStatusCode; + if (code !== undefined) { + const success = code >= 200 && code <= 299; + if (payload.status === "succeeded" && !success) { + errors.push(`${label} succeeded with response status ${code}`); + } + if ( + (payload.status === "failed" || payload.status === "exhausted") && + success + ) { + errors.push(`${label} failed with response status ${code}`); + } + } + return errors; +} + +function recordSemantics(label, document) { + const errors = []; + if (Buffer.byteLength(JSON.stringify(document), "utf8") > 32_768) { + errors.push(`${label} exceeds the 32 KiB record limit`); + } + walkValues(document, "", (value, path) => { + if (JWS_SHAPE.test(value)) { + errors.push( + `${label}${path} carries a signed-payload-shaped value; no provider secret, purchase token, or raw provider payload crosses this contract`, + ); + } + }); + if (document.recordType === "billingStateEvent") { + errors.push(...eventSemantics(label, document.payload)); + } + if (document.recordType === "webhookDeliveryAttempt") { + errors.push(...deliverySemantics(label, document.payload)); + } + return errors; +} + +export function validateBillingStateWebhookV1Record( + document, + artifacts, + label = "Webhook record", +) { + const compiled = validators(artifacts); + const family = Object.entries(FAMILY_RECORD_TYPES).find(([, types]) => + types.includes(document?.recordType), + )?.[0]; + if (family === undefined) { + return [`${label} declares unknown record type ${document?.recordType}`]; + } + const validate = compiled[family]; + if (!validate(document)) return schemaErrors(label, validate.errors); + return recordSemantics(label, document); +} + +/** + * Guards the contract itself: the public event vocabulary may never name a + * provider. An application backend that has to branch on whether a change came + * from Apple or Google is reading a provider integration, not a Mosaic contract. + */ +function validateEventTypeVocabulary(artifacts) { + const provider = /apple|google|storekit|play|itunes|android|ios/i; + const errors = []; + for (const eventType of artifacts.eventSchema.$defs.eventType.enum) { + if (provider.test(eventType)) { + errors.push( + `Webhook event type "${eventType}" names a provider; the public event vocabulary is provider-neutral`, + ); + } + } + const manifest = artifacts.compatibilityManifest; + for (const eventType of manifest.emittedEventTypes ?? []) { + if (!manifest.eventTypes.includes(eventType)) { + errors.push(`Webhook emits ${eventType}, which is not a declared event type`); + } + } + return errors; +} + +/** + * Guards the contract itself: an event's summarized access state may never be + * `unavailable`. + * + * `unavailable` says Mosaic could not answer a read. An event is not a read -- + * it exists only because a projection committed a new snapshot, so the + * projection did answer. The worst an event can honestly say about an axis is + * `unknown`, carrying the uncertainty that explains why. Admitting + * `unavailable` here would put a service-delivery state on a record that is not + * authoritative in the first place, and a tolerant consumer would have no reason + * to distrust it. + */ +function validateSummaryAccessVocabulary(artifacts) { + const errors = []; + const members = artifacts.eventSchema.$defs.accessState.enum; + if (members.includes("unavailable")) { + errors.push( + "Webhook stateSummary.accessState may never include unavailable: an event is not a read, so Mosaic's ability to answer is not one of its states", + ); + } + for (const required of ["active", "inactive", "unknown"]) { + if (!members.includes(required)) { + errors.push( + `Webhook stateSummary.accessState must include ${required}, or a committed projection has nowhere honest to land`, + ); + } + } + return errors; +} + +function validateCompatibility(artifacts) { + const compiled = validators(artifacts); + if (!compiled.manifest(artifacts.compatibilityManifest)) { + return schemaErrors( + "Webhook compatibility manifest", + compiled.manifest.errors, + ); + } + const errors = []; + const manifest = artifacts.compatibilityManifest; + + const declaredRecordTypes = artifacts.eventSchema.$defs.recordType.enum; + if ( + manifest.recordTypes.length !== declaredRecordTypes.length || + declaredRecordTypes.some((type) => !manifest.recordTypes.includes(type)) + ) { + errors.push("Webhook record-type set is incomplete"); + } + const declaredEventTypes = artifacts.eventSchema.$defs.eventType.enum; + if ( + manifest.eventTypes.length !== declaredEventTypes.length || + declaredEventTypes.some((type) => !manifest.eventTypes.includes(type)) + ) { + errors.push("Webhook event-type set is incomplete"); + } + + const manifestDirectory = dirname( + billingStateWebhookV1Paths.compatibilityManifest, + ); + for (const path of [ + ...Object.values(manifest.schemas), + ...manifest.canonicalFixtures, + ]) { + if (!existsSync(resolve(manifestDirectory, path))) { + errors.push(`Webhook compatibility path does not exist: ${path}`); + } + } + if ( + !existsSync( + resolve(billingStateWebhookV1Root, "..", manifest.signing.signatureVectors), + ) + ) { + errors.push( + `Webhook signature vectors do not exist: ${manifest.signing.signatureVectors}`, + ); + } + + const canonical = new Set( + manifest.canonicalFixtures.map((path) => resolve(manifestDirectory, path)), + ); + for (const path of artifacts.validFixturePaths) { + if (!canonical.has(path)) { + errors.push( + `Webhook fixture ${relative(billingStateWebhookV1Root, path)} is not listed in the compatibility manifest`, + ); + } + } + const covered = new Set( + artifacts.validFixtures.map((document) => document.recordType), + ); + for (const recordType of declaredRecordTypes) { + if (!covered.has(recordType)) { + errors.push(`Webhook record type ${recordType} has no canonical fixture`); + } + } + const emitted = new Set( + artifacts.validFixtures + .filter((document) => document.recordType === "billingStateEvent") + .map((document) => document.payload.eventType), + ); + for (const eventType of manifest.emittedEventTypes) { + if (!emitted.has(eventType)) { + errors.push(`Webhook event type ${eventType} is emitted but has no fixture`); + } + } + return errors; +} + +export function validateBillingStateWebhookV1Artifacts(artifacts) { + const compiled = validators(artifacts); + const errors = [ + ...validateEventTypeVocabulary(artifacts), + ...validateSummaryAccessVocabulary(artifacts), + ...validateCompatibility(artifacts), + ]; + + for (const [index, document] of artifacts.validFixtures.entries()) { + const path = artifacts.validFixturePaths[index]; + const label = `Webhook fixture ${relative(billingStateWebhookV1Root, path)}`; + const family = familyOf(path); + if (family === undefined) { + errors.push(`${label} is in a directory with no schema family`); + continue; + } + if (!FAMILY_RECORD_TYPES[family].includes(document.recordType)) { + errors.push( + `${label} declares ${document.recordType}, which does not belong in this directory`, + ); + continue; + } + const validate = compiled[family]; + if (!validate(document)) { + errors.push(...schemaErrors(label, validate.errors)); + continue; + } + errors.push(...recordSemantics(label, document)); + } + + for (const [index, document] of artifacts.invalidFixtures.entries()) { + const path = artifacts.invalidFixturePaths[index]; + const label = `Invalid Webhook fixture ${relative(billingStateWebhookV1Root, path)}`; + const accepted = Object.keys(FAMILY_RECORD_TYPES).some((family) => { + const validate = compiled[family]; + return validate(document) && recordSemantics(label, document).length === 0; + }); + if (accepted) errors.push(`${label} was accepted`); + } + + return errors; +} + +export function validateBillingStateWebhookV1JsonFormatting() { + const paths = [ + billingStateWebhookV1Paths.eventSchema, + billingStateWebhookV1Paths.deliverySchema, + billingStateWebhookV1Paths.compatibilityManifestSchema, + billingStateWebhookV1Paths.compatibilityManifest, + ...jsonPaths(billingStateWebhookV1Paths.fixtureDirectory), + ]; + return paths.flatMap((path) => { + const source = readFileSync(path, "utf8"); + const canonical = `${JSON.stringify(JSON.parse(source), null, 2)}\n`; + return source === canonical + ? [] + : [`${relative(billingStateWebhookV1Root, path)} is not canonical JSON`]; + }); +} diff --git a/protocol/tools/billing-state-webhook-validation-v1.test.mjs b/protocol/tools/billing-state-webhook-validation-v1.test.mjs new file mode 100644 index 00000000..e00abf34 --- /dev/null +++ b/protocol/tools/billing-state-webhook-validation-v1.test.mjs @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { + billingStateWebhookV1Root, + loadBillingStateWebhookV1Artifacts, + readBillingStateWebhookV1Json, + validateBillingStateWebhookV1Artifacts, + validateBillingStateWebhookV1JsonFormatting, + validateBillingStateWebhookV1Record, +} from "./billing-state-webhook-validation-v1.mjs"; + +const artifacts = loadBillingStateWebhookV1Artifacts(); + +const signatureVectors = readBillingStateWebhookV1Json( + resolve( + billingStateWebhookV1Root, + "../packages/test-fixtures/src/webhook-signature-vectors.json", + ), +); + +function fixture(name) { + const path = artifacts.fixturePaths.find((candidate) => + candidate.endsWith(`/${name}`), + ); + assert.ok(path, `Missing fixture ${name}`); + return readBillingStateWebhookV1Json(path); +} + +test("the committed contract validates clean", () => { + assert.deepEqual(validateBillingStateWebhookV1Artifacts(artifacts), []); + assert.deepEqual(validateBillingStateWebhookV1JsonFormatting(), []); +}); + +test("the manifest is born a draft and emits one of its ten event types", () => { + const manifest = artifacts.compatibilityManifest; + assert.equal(manifest.status, "draft"); + assert.equal(manifest.eventTypes.length, 10); + assert.deepEqual(manifest.emittedEventTypes, ["customer.entitlements.changed"]); + // Over-provisioning is the point: the nine reserved names cost no contract + // version when they start being emitted. + for (const eventType of manifest.emittedEventTypes) { + assert.ok(manifest.eventTypes.includes(eventType)); + } +}); + +test("the public event vocabulary never names a provider", () => { + for (const eventType of artifacts.eventSchema.$defs.eventType.enum) { + assert.doesNotMatch(eventType, /apple|google|storekit|play|itunes/i, eventType); + } + const document = fixture("events/entitlement-activated.json"); + document.payload.eventType = "subscription.expired"; + assert.deepEqual(validateBillingStateWebhookV1Record(document, artifacts), []); +}); + +test("consumer tolerance is the documented exception to fail-closed reading", () => { + // Producers are strict; consumers are tolerant. Recorded as an explicit + // asymmetry so it stays an exception rather than becoming a habit. + const manifest = artifacts.compatibilityManifest; + assert.equal(manifest.producerPolicy.unknownField, "rejectRecord"); + assert.equal(manifest.producerPolicy.unknownEventType, "rejectRecord"); + assert.equal(manifest.consumerTolerance.unknownField, "ignore"); + assert.equal(manifest.consumerTolerance.unknownEventType, "ignore"); + assert.equal(manifest.consumerTolerance.unknownEnumerationMember, "ignore"); + assert.equal(manifest.consumerTolerance.authoritativeState, "reReadSnapshot"); + // The one thing a consumer must not be tolerant about. + assert.equal( + manifest.consumerTolerance.signatureVerification, + "requiredBeforeParsing", + ); +}); + +test("delivery is at least once and ordered by snapshot version", () => { + const manifest = artifacts.compatibilityManifest; + assert.equal(manifest.delivery.guarantee, "atLeastOnce"); + assert.equal(manifest.delivery.deduplicationKey, "eventId"); + assert.equal(manifest.delivery.orderingKey, "snapshotVersion"); + assert.equal(manifest.producerPolicy.exactlyOnceDelivery, "neverPromised"); + assert.equal( + manifest.delivery.failureIsolation, + "deliveryNeverRollsBackState", + ); +}); + +test("a retry carries the same event ID with a higher attempt", () => { + // The property consumer-side deduplication depends on. + const first = fixture("deliveries/failed-retry-scheduled.json"); + const retry = fixture("deliveries/retry-same-event-id.json"); + assert.equal(retry.payload.eventId, first.payload.eventId); + assert.equal(retry.payload.attempt, first.payload.attempt + 1); + assert.notEqual(retry.payload.deliveryId, first.payload.deliveryId); +}); + +test("exhaustion means the attempts actually ran out", () => { + const document = fixture("deliveries/exhausted.json"); + assert.equal(document.payload.attempt, document.payload.maxAttempts); + document.payload.attempt = 3; + assert.notDeepEqual( + validateBillingStateWebhookV1Record(document, artifacts), + [], + ); +}); + +test("a delivery record carries no destination URL and no signing secret", () => { + // It is operator-facing and never transmitted; a URL or secret here would be + // read by everyone with dashboard access. + const properties = Object.keys( + artifacts.deliverySchema.$defs.webhookDeliveryAttempt.properties, + ); + for (const name of properties) { + assert.doesNotMatch(name, /url|secret|signature|endpoint|token/i, name); + } + assert.equal( + artifacts.compatibilityManifest.producerPolicy.deliveryRecordTransport, + "neverSentToDestination", + ); +}); + +test("an event must report a change", () => { + // A no-change projection emits no webhook, so an event that changes nothing is + // a producer defect rather than a quiet no-op. + const document = fixture("events/entitlement-deactivated.json"); + document.payload.changedEntitlements[0].currentState = "active"; + const errors = validateBillingStateWebhookV1Record(document, artifacts); + assert.ok( + errors.some((error) => error.includes("reports no state change")), + `expected a no-change error, got: ${errors.join("; ")}`, + ); + + // A period change legitimately leaves every state untouched. + const extended = fixture("events/expiry-extended.json"); + assert.equal( + extended.payload.changedEntitlements[0].previousState, + extended.payload.changedEntitlements[0].currentState, + ); + assert.deepEqual( + validateBillingStateWebhookV1Record(extended, artifacts), + [], + ); +}); + +test("cancellation is reported without deactivating access", () => { + const document = fixture("events/subscription-cancelled-access-active.json"); + assert.equal(document.payload.stateSummary.accessState, "active"); + assert.equal(document.payload.stateSummary.renewalIntent, "auto_renew_disabled"); + assert.equal(document.payload.changedEntitlements[0].currentState, "active"); +}); + +test("an unknown transition is reported as unknown, never as a deactivation", () => { + const document = fixture("events/unknown-state-transition.json"); + assert.equal(document.payload.changedEntitlements[0].currentState, "unknown"); + assert.equal(document.payload.stateSummary.accessState, "unknown"); + assert.notEqual(document.payload.stateSummary.uncertainty.reason, "none"); +}); + +test("an event's access state is never unavailable", () => { + // unavailable says Mosaic could not answer a read. An event is not a read: it + // exists because a projection committed a snapshot, so the projection did + // answer. The worst it can say is unknown, with an uncertainty attached. + assert.deepEqual(artifacts.eventSchema.$defs.accessState.enum, [ + "active", + "inactive", + "unknown", + ]); + + const document = fixture("events/unknown-state-transition.json"); + document.payload.stateSummary.accessState = "unavailable"; + assert.notDeepEqual( + validateBillingStateWebhookV1Record(document, artifacts), + [], + ); + + for (const summary of artifacts.validFixtures + .filter((candidate) => candidate.recordType === "billingStateEvent") + .map((candidate) => candidate.payload.stateSummary)) { + assert.notEqual(summary.accessState, "unavailable"); + } +}); + +test("the contract guard rejects an accessState vocabulary that admits unavailable", () => { + // Exercised by breaking it: the narrowing has to be defended, not merely + // performed, or a later edit re-widens it without anything noticing. + const broken = structuredClone(artifacts); + broken.eventSchema = structuredClone(artifacts.eventSchema); + broken.eventSchema.$defs.accessState.enum.push("unavailable"); + const errors = validateBillingStateWebhookV1Artifacts(broken); + assert.ok( + errors.some((error) => error.includes("may never include unavailable")), + `expected an unavailable-vocabulary error, got: ${errors.join("; ")}`, + ); +}); + +test("no fixture carries a signed-payload-shaped value", () => { + const jws = /^[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}$/; + const strings = (value) => + typeof value === "string" + ? [value] + : value !== null && typeof value === "object" + ? Object.values(value).flatMap(strings) + : []; + for (const document of artifacts.validFixtures) { + for (const value of strings(document)) assert.doesNotMatch(value, jws); + } +}); + +test("every signature vector is the HMAC of its own signed payload", () => { + const { scheme, vectors } = signatureVectors; + assert.equal(scheme.algorithm, "HMAC-SHA256"); + assert.equal(scheme.signingVersion, "v1"); + + for (const vector of vectors) { + assert.equal( + vector.signedPayload, + `${scheme.signingVersion}.${vector.timestamp}.${vector.eventId}.${vector.rawBody}`, + `${vector.id} signed payload does not follow the template`, + ); + assert.equal( + createHmac("sha256", vector.secret).update(vector.signedPayload, "utf8").digest("hex"), + vector.signature, + `${vector.id} signature is not HMAC-SHA256 over its signed payload`, + ); + assert.match(vector.signature, /^[a-f0-9]{64}$/); + assert.equal(vector.header, `t=${vector.timestamp}, v1=${vector.signature}`); + } +}); + +test("the signature covers the body, the event ID, and the timestamp", () => { + const byId = Object.fromEntries( + signatureVectors.vectors.map((vector) => [vector.id, vector]), + ); + const canonical = byId["canonical-event-primary-key"]; + for (const id of [ + "tampered-body-must-not-verify", + "different-event-id-must-not-verify", + "different-timestamp-must-not-verify", + "canonical-event-rotation-key", + ]) { + assert.notEqual( + byId[id].signature, + canonical.signature, + `${id} produces the same signature as the canonical vector`, + ); + } + // Rotation: two active keys over identical bytes, either verifying is enough. + const rotated = byId["canonical-event-rotation-key"]; + assert.equal(rotated.rawBody, canonical.rawBody); + assert.equal(rotated.eventId, canonical.eventId); + assert.notEqual(rotated.secret, canonical.secret); +}); + +test("the signature vectors sign the bytes of the canonical event fixture", () => { + // The raw body must be hashed as received. A verifier that re-serializes the + // parsed JSON gets different bytes and rejects every genuine delivery. + const canonical = signatureVectors.vectors.find( + (vector) => vector.id === "canonical-event-primary-key", + ); + const onDisk = readFileSync( + resolve(billingStateWebhookV1Root, "..", signatureVectors.eventFixture), + "utf8", + ).trimEnd(); + assert.equal(canonical.rawBody, onDisk, "the vector and the event fixture have drifted apart"); + assert.equal(canonical.eventId, JSON.parse(onDisk).payload.eventId); +}); diff --git a/protocol/tools/customer-access-token-validation-v1.mjs b/protocol/tools/customer-access-token-validation-v1.mjs new file mode 100644 index 00000000..b62d48d8 --- /dev/null +++ b/protocol/tools/customer-access-token-validation-v1.mjs @@ -0,0 +1,383 @@ +/** + * Customer Access Token Contract v1 validation. + * + * One canonical schema plus a compatibility manifest. The contract describes an + * OPAQUE credential, so there is nothing inside a token to validate: everything + * here validates metadata *about* a token, and the most valuable checks are the + * two that watch the contract itself. + * + * - `validateOpaqueTokenModel` fails if the contract ever grows a signing + * vocabulary -- an algorithm, a key identifier, a JWK, a JWS header. The + * opaque model is an owner-approved deviation from the orchestration + * prompt's "signed" wording, and a deviation that can drift back silently is + * not a decision, it is a coincidence. + * - the same guard fails if any property name suggests the token carries + * access state. A token that carried entitlements would keep granting them + * after a refund, for as long as it lived. + * + * The semantic layer otherwise covers lifetime arithmetic, revocation ordering, + * and the forbidden-value walk ported from Billing Ingestion. + */ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import Ajv2020 from "ajv/dist/2020.js"; + +import { REJECTION_LAYERS_FILENAME } from "./generate-rejection-layers.mjs"; + +const toolsDirectory = dirname(fileURLToPath(import.meta.url)); +export const customerAccessTokenV1Root = resolve(toolsDirectory, ".."); + +export const customerAccessTokenV1Paths = Object.freeze({ + tokenSchema: resolve( + customerAccessTokenV1Root, + "schema/customer-access-token/v1/token.schema.json", + ), + compatibilityManifestSchema: resolve( + customerAccessTokenV1Root, + "schema/customer-access-token/v1/compatibility-manifest.schema.json", + ), + compatibilityManifest: resolve( + customerAccessTokenV1Root, + "compatibility/customer-access-token/v1.json", + ), + fixtureDirectory: resolve( + customerAccessTokenV1Root, + "fixtures/customer-access-token/v1", + ), +}); + +const RECORD_TYPES = Object.freeze([ + "customerAccessTokenIssuanceRequest", + "customerAccessTokenIssuanceResult", + "customerAccessTokenMetadata", + "customerAccessTokenRevocation", +]); + +export function readCustomerAccessTokenV1Json(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function jsonPaths(directory) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) return jsonPaths(path); + if (entry.name === REJECTION_LAYERS_FILENAME) return []; + return entry.name.endsWith(".json") ? [path] : []; + }) + .sort(); +} + +export function loadCustomerAccessTokenV1Artifacts() { + const fixturePaths = jsonPaths(customerAccessTokenV1Paths.fixtureDirectory); + const validFixturePaths = fixturePaths.filter( + (path) => !path.includes("/invalid/"), + ); + const invalidFixturePaths = fixturePaths.filter((path) => + path.includes("/invalid/"), + ); + return { + tokenSchema: readCustomerAccessTokenV1Json( + customerAccessTokenV1Paths.tokenSchema, + ), + compatibilityManifestSchema: readCustomerAccessTokenV1Json( + customerAccessTokenV1Paths.compatibilityManifestSchema, + ), + compatibilityManifest: readCustomerAccessTokenV1Json( + customerAccessTokenV1Paths.compatibilityManifest, + ), + fixturePaths, + validFixturePaths, + invalidFixturePaths, + validFixtures: validFixturePaths.map(readCustomerAccessTokenV1Json), + invalidFixtures: invalidFixturePaths.map(readCustomerAccessTokenV1Json), + }; +} + +function validators(artifacts) { + const ajv = new Ajv2020({ + allErrors: true, + strict: true, + strictRequired: false, + strictTypes: false, + }); + for (const schema of [ + artifacts.tokenSchema, + artifacts.compatibilityManifestSchema, + ]) { + ajv.addSchema(schema); + } + return { + token: ajv.getSchema(artifacts.tokenSchema.$id), + manifest: ajv.getSchema(artifacts.compatibilityManifestSchema.$id), + }; +} + +function describeSchemaError(label, error) { + const at = `${label}${error.instancePath || "/"}`; + const offending = + error.params?.unevaluatedProperty ?? error.params?.additionalProperty; + if (offending !== undefined) return `${at}.${offending} is not allowed`; + return `${at} ${error.message ?? "is invalid"}`; +} + +function schemaErrors(label, errors = []) { + const specific = errors.filter((error) => error.keyword !== "oneOf"); + const reported = specific.length > 0 ? specific : errors; + return [ + ...new Set(reported.map((error) => describeSchemaError(label, error))), + ]; +} + +const JWS_SHAPE = /^[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}$/; + +function walkValues(value, path, visit) { + if (typeof value === "string") { + visit(value, path); + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => walkValues(item, `${path}/${index}`, visit)); + return; + } + if (value !== null && typeof value === "object") { + for (const [key, child] of Object.entries(value)) { + walkValues(child, `${path}/${key}`, visit); + } + } +} + +const MAXIMUM_LIFETIME_SECONDS = 86_400; + +function metadataSemantics(label, metadata) { + const errors = []; + const issued = Date.parse(metadata.issuedAt); + const expires = Date.parse(metadata.expiresAt); + if (expires <= issued) { + errors.push(`${label} expires before or when it was issued`); + } else if ((expires - issued) / 1000 > MAXIMUM_LIFETIME_SECONDS) { + errors.push( + `${label} lives longer than the ${MAXIMUM_LIFETIME_SECONDS}-second maximum; ` + + "a short life is the only thing limiting the damage of a leaked opaque token", + ); + } + if ( + metadata.notBefore !== undefined && + Date.parse(metadata.notBefore) > expires + ) { + errors.push(`${label} becomes valid after it expires`); + } + if ( + metadata.revokedAt !== undefined && + Date.parse(metadata.revokedAt) < issued + ) { + errors.push(`${label} was revoked before it was issued`); + } + if ( + metadata.lastUsedAt !== undefined && + Date.parse(metadata.lastUsedAt) < issued + ) { + errors.push(`${label} was used before it was issued`); + } + return errors; +} + +function recordSemantics(label, document) { + const errors = []; + if (Buffer.byteLength(JSON.stringify(document), "utf8") > 8_192) { + errors.push(`${label} exceeds the 8 KiB record limit`); + } + walkValues(document, "", (value, path) => { + if (JWS_SHAPE.test(value)) { + errors.push( + `${label}${path} carries a signed-payload-shaped value; a Customer Access Token is opaque and this contract carries no signed material`, + ); + } + }); + + const payload = document.payload; + if (document.recordType === "customerAccessTokenMetadata") { + errors.push(...metadataSemantics(label, payload)); + } + if (document.recordType === "customerAccessTokenIssuanceResult") { + errors.push(...metadataSemantics(`${label} metadata`, payload.metadata)); + if (payload.metadata.status !== "active") { + errors.push( + `${label} issues a token whose metadata is already ${payload.metadata.status}`, + ); + } + } + return errors; +} + +export function validateCustomerAccessTokenV1Record( + document, + artifacts, + label = "Customer Access Token record", +) { + const compiled = validators(artifacts); + if (!RECORD_TYPES.includes(document?.recordType)) { + return [`${label} declares unknown record type ${document?.recordType}`]; + } + if (!compiled.token(document)) { + return schemaErrors(label, compiled.token.errors); + } + return recordSemantics(label, document); +} + +/** + * Guards the contract itself. The token is opaque by an explicit owner decision; + * these two checks are what stop that decision from decaying into a signed token + * or a token that carries access. + */ +function validateOpaqueTokenModel(artifacts) { + const errors = []; + const signing = /^(alg|kid|jwk|jwks|jws|jwt|signature|signingKey|publicKey|privateKey|keyId)$/i; + const accessState = + /(entitlement|accessState|grant|subscription|snapshotVersion)/i; + + const walk = (node, path) => { + if (node === null || typeof node !== "object") return; + for (const name of Object.keys(node.properties ?? {})) { + if (signing.test(name)) { + errors.push( + `Customer Access Token contract declares "${name}" at ${path}; the token is opaque and has no signing vocabulary`, + ); + } + if (accessState.test(name)) { + errors.push( + `Customer Access Token contract declares "${name}" at ${path}; a token never carries access state, or it would outlive a refund`, + ); + } + } + for (const [key, child] of Object.entries(node)) { + walk(child, `${path}/${key}`); + } + }; + walk(artifacts.tokenSchema, ""); + + const model = artifacts.compatibilityManifest.tokenModel; + if (model.signed !== false) { + errors.push("Customer Access Token model claims to be signed; v1 tokens are opaque"); + } + if (model.carriesEntitlementState !== false) { + errors.push("Customer Access Token model claims to carry Entitlement state"); + } + if (model.storage !== "digestOnly") { + errors.push( + "Customer Access Token storage must be digest-only; Mosaic never holds a token it could replay", + ); + } + return errors; +} + +function validateCompatibility(artifacts) { + const compiled = validators(artifacts); + if (!compiled.manifest(artifacts.compatibilityManifest)) { + return schemaErrors( + "Customer Access Token compatibility manifest", + compiled.manifest.errors, + ); + } + const errors = []; + const manifest = artifacts.compatibilityManifest; + + const declared = artifacts.tokenSchema.$defs.recordType.enum; + if ( + manifest.recordTypes.length !== declared.length || + declared.some((recordType) => !manifest.recordTypes.includes(recordType)) + ) { + errors.push("Customer Access Token record-type set is incomplete"); + } + if (manifest.lifetime.maximumSeconds !== MAXIMUM_LIFETIME_SECONDS) { + errors.push( + "Customer Access Token manifest pins a maximum lifetime the validator does not enforce", + ); + } + + const manifestDirectory = dirname( + customerAccessTokenV1Paths.compatibilityManifest, + ); + for (const path of [ + ...Object.values(manifest.schemas), + ...manifest.canonicalFixtures, + ]) { + if (!existsSync(resolve(manifestDirectory, path))) { + errors.push(`Customer Access Token compatibility path does not exist: ${path}`); + } + } + const canonical = new Set( + manifest.canonicalFixtures.map((path) => resolve(manifestDirectory, path)), + ); + for (const path of artifacts.validFixturePaths) { + if (!canonical.has(path)) { + errors.push( + `Customer Access Token fixture ${relative(customerAccessTokenV1Root, path)} is not listed in the compatibility manifest`, + ); + } + } + const covered = new Set( + artifacts.validFixtures.map((document) => document.recordType), + ); + for (const recordType of declared) { + if (!covered.has(recordType)) { + errors.push( + `Customer Access Token record type ${recordType} has no canonical fixture`, + ); + } + } + return errors; +} + +export function validateCustomerAccessTokenV1Artifacts(artifacts) { + const compiled = validators(artifacts); + const errors = [ + ...validateOpaqueTokenModel(artifacts), + ...validateCompatibility(artifacts), + ]; + + for (const [index, document] of artifacts.validFixtures.entries()) { + const path = artifacts.validFixturePaths[index]; + const label = `Customer Access Token fixture ${relative(customerAccessTokenV1Root, path)}`; + if (!RECORD_TYPES.includes(document.recordType)) { + errors.push(`${label} declares unknown record type ${document.recordType}`); + continue; + } + if (!compiled.token(document)) { + errors.push(...schemaErrors(label, compiled.token.errors)); + continue; + } + errors.push(...recordSemantics(label, document)); + } + + for (const [index, document] of artifacts.invalidFixtures.entries()) { + const path = artifacts.invalidFixturePaths[index]; + const label = `Invalid Customer Access Token fixture ${relative(customerAccessTokenV1Root, path)}`; + if ( + compiled.token(document) && + recordSemantics(label, document).length === 0 + ) { + errors.push(`${label} was accepted`); + } + } + + return errors; +} + +export function validateCustomerAccessTokenV1JsonFormatting() { + const paths = [ + customerAccessTokenV1Paths.tokenSchema, + customerAccessTokenV1Paths.compatibilityManifestSchema, + customerAccessTokenV1Paths.compatibilityManifest, + ...jsonPaths(customerAccessTokenV1Paths.fixtureDirectory), + ]; + return paths.flatMap((path) => { + const source = readFileSync(path, "utf8"); + const canonical = `${JSON.stringify(JSON.parse(source), null, 2)}\n`; + return source === canonical + ? [] + : [`${relative(customerAccessTokenV1Root, path)} is not canonical JSON`]; + }); +} diff --git a/protocol/tools/customer-access-token-validation-v1.test.mjs b/protocol/tools/customer-access-token-validation-v1.test.mjs new file mode 100644 index 00000000..95becc99 --- /dev/null +++ b/protocol/tools/customer-access-token-validation-v1.test.mjs @@ -0,0 +1,203 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + loadCustomerAccessTokenV1Artifacts, + readCustomerAccessTokenV1Json, + validateCustomerAccessTokenV1Artifacts, + validateCustomerAccessTokenV1JsonFormatting, + validateCustomerAccessTokenV1Record, +} from "./customer-access-token-validation-v1.mjs"; + +const artifacts = loadCustomerAccessTokenV1Artifacts(); + +function fixture(name) { + const path = artifacts.fixturePaths.find((candidate) => + candidate.endsWith(`/${name}`), + ); + assert.ok(path, `Missing fixture ${name}`); + return readCustomerAccessTokenV1Json(path); +} + +test("the committed contract validates clean", () => { + assert.deepEqual(validateCustomerAccessTokenV1Artifacts(artifacts), []); + assert.deepEqual(validateCustomerAccessTokenV1JsonFormatting(), []); +}); + +test("the token is opaque, not signed", () => { + // An explicit owner-approved deviation from the orchestration prompt's + // "signed" wording. Pinned so it cannot drift back without a visible change. + const model = artifacts.compatibilityManifest.tokenModel; + assert.equal(artifacts.compatibilityManifest.status, "draft"); + assert.equal(model.form, "opaqueRandom"); + assert.equal(model.signed, false); + assert.equal(model.parseable, false); + assert.equal(model.entropyBits, 256); + assert.equal(model.storage, "digestOnly"); + assert.equal(model.digestAlgorithm, "sha256"); + assert.equal(model.revocation, "immediateServerSide"); + assert.equal(model.scopeEvaluation, "serverSideColumns"); +}); + +test("the contract declares no signing vocabulary anywhere", () => { + const source = JSON.stringify(artifacts.tokenSchema); + for (const term of ['"alg"', '"kid"', '"jwk"', '"jwks"', '"jws"', '"signature"']) { + assert.ok(!source.includes(`"properties":{${term}`), `${term} appears as a property`); + } + const broken = structuredClone(artifacts); + broken.tokenSchema = structuredClone(artifacts.tokenSchema); + broken.tokenSchema.$defs.customerAccessTokenMetadata.properties.kid = { + type: "string", + }; + const errors = validateCustomerAccessTokenV1Artifacts(broken); + assert.ok( + errors.some((error) => error.includes("no signing vocabulary")), + `expected a signing-vocabulary error, got: ${errors.join("; ")}`, + ); +}); + +test("a token never carries Entitlement state", () => { + // A token that carried entitlements would keep granting them after a refund, + // for as long as it lived. There is nothing to revoke inside a bearer claim. + assert.equal( + artifacts.compatibilityManifest.tokenModel.carriesEntitlementState, + false, + ); + const document = fixture("tokens/metadata-active.json"); + document.payload.entitlements = [{ entitlementKey: "pro", state: "active" }]; + assert.notDeepEqual( + validateCustomerAccessTokenV1Record(document, artifacts), + [], + ); + + const broken = structuredClone(artifacts); + broken.tokenSchema = structuredClone(artifacts.tokenSchema); + broken.tokenSchema.$defs.customerAccessTokenMetadata.properties.entitlementKeys = { + type: "array", + }; + const errors = validateCustomerAccessTokenV1Artifacts(broken); + assert.ok( + errors.some((error) => error.includes("never carries access state")), + `expected an access-state error, got: ${errors.join("; ")}`, + ); +}); + +test("a token is bound to exactly one customer, Project, and Environment", () => { + const metadata = artifacts.tokenSchema.$defs.customerAccessTokenMetadata; + for (const member of ["billingCustomerId", "projectId", "environmentId", "audience"]) { + assert.ok(metadata.required.includes(member), `${member} is not required`); + } + const document = fixture("tokens/metadata-active.json"); + delete document.payload.billingCustomerId; + assert.notDeepEqual( + validateCustomerAccessTokenV1Record(document, artifacts), + [], + ); + assert.equal( + artifacts.compatibilityManifest.readerPolicy.customerMismatch, + "refuseRequest", + ); +}); + +test("an issuance request never asserts its own tenant scope", () => { + // Tenant scope comes from the authenticated secret server key. A request that + // could name a Project could mint a token into a tenant it does not own. + const request = artifacts.tokenSchema.$defs.customerAccessTokenIssuanceRequest; + for (const member of ["projectId", "environmentId"]) { + assert.ok( + !(member in request.properties), + `${member} must not be assertable on an issuance request`, + ); + } +}); + +test("token lifetime is bounded", () => { + const lifetime = artifacts.compatibilityManifest.lifetime; + assert.equal(lifetime.defaultSeconds, 3600); + assert.equal(lifetime.maximumSeconds, 86400); + assert.equal(lifetime.refreshResponsibility, "hostApplicationBackend"); + + const document = fixture("tokens/metadata-active.json"); + document.payload.expiresAt = "2026-07-30T12:00:00.000Z"; + const errors = validateCustomerAccessTokenV1Record(document, artifacts); + assert.ok( + errors.some((error) => error.includes("maximum")), + `expected a lifetime error, got: ${errors.join("; ")}`, + ); + + const backwards = fixture("tokens/metadata-active.json"); + backwards.payload.expiresAt = "2026-07-28T11:00:00.000Z"; + assert.notDeepEqual( + validateCustomerAccessTokenV1Record(backwards, artifacts), + [], + ); +}); + +test("the wire form is contract-owned", () => { + // Header names must be agreed by the three SDKs, the backend, and any host + // proxy. Pinning them here makes a rename a contract change. + const wire = artifacts.compatibilityManifest.wireForm; + assert.equal(wire.customerTokenHeader, "Authorization"); + assert.equal(wire.customerTokenScheme, "Bearer"); + assert.equal(wire.publicSdkKeyHeader, "Mosaic-SDK-Key"); + assert.equal(wire.publicSdkKeyAloneSufficient, false); + assert.equal(wire.clockSkewToleranceSeconds, 60); + assert.equal(wire.clockEvaluatedBy, "server"); +}); + +test("SDK obligations keep the token out of storage and out of logs", () => { + const obligations = artifacts.compatibilityManifest.sdkObligations; + assert.equal(obligations.storage, "memoryOnly"); + assert.equal(obligations.parsing, "forbidden"); + assert.equal(obligations.loggingToken, "forbidden"); + assert.equal(obligations.refreshOnUnauthorized, "oncePerGeneration"); + assert.equal(obligations.onLogout, "discardTokenAndClearCache"); + assert.equal( + obligations.onIdentityChange, + "bumpGenerationCancelInFlightClearCache", + ); + // A host backend that cannot mint a token yields unavailable, never inactive. + assert.equal(obligations.onProviderFailure, "reportUnavailableNeverInactive"); + assert.equal( + artifacts.compatibilityManifest.readerPolicy.tokenPersistedToDisk, + "forbidden", + ); +}); + +test("revocation state is all-or-nothing", () => { + const revoked = fixture("tokens/metadata-revoked.json"); + assert.equal(revoked.payload.status, "revoked"); + assert.ok(revoked.payload.revokedAt); + assert.ok(revoked.payload.revocationReason); + assert.deepEqual(validateCustomerAccessTokenV1Record(revoked, artifacts), []); + + const partial = fixture("tokens/metadata-revoked.json"); + delete partial.payload.revocationReason; + assert.notDeepEqual( + validateCustomerAccessTokenV1Record(partial, artifacts), + [], + ); + + const contradictory = fixture("tokens/metadata-active.json"); + contradictory.payload.revokedAt = "2026-07-28T12:20:00.000Z"; + contradictory.payload.revocationReason = "operator_revoked"; + assert.notDeepEqual( + validateCustomerAccessTokenV1Record(contradictory, artifacts), + [], + ); +}); + +test("the token value is opaque and no fixture carries a signed one", () => { + const pattern = new RegExp(artifacts.tokenSchema.$defs.tokenValue.pattern); + const issued = fixture("tokens/issuance-result.json"); + assert.match(issued.payload.token, pattern); + assert.ok(!issued.payload.token.includes("."), "an opaque token has no segments"); + + const signed = fixture("tokens/issuance-result.json"); + signed.payload.token = + "eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlLWN1c3RvbWVyLTAwMDEifQ.c2lnbmF0dXJl"; + assert.notDeepEqual( + validateCustomerAccessTokenV1Record(signed, artifacts), + [], + ); +}); diff --git a/protocol/tools/generate-rejection-layers.mjs b/protocol/tools/generate-rejection-layers.mjs index 74886f9a..2c48b6be 100644 --- a/protocol/tools/generate-rejection-layers.mjs +++ b/protocol/tools/generate-rejection-layers.mjs @@ -98,6 +98,42 @@ function billingIngestionV1UnionValidator() { }); } +const AUTHORITATIVE_ENTITLEMENT = [ + "schema/authoritative-entitlement/v1/snapshot.schema.json", + "schema/authoritative-entitlement/v1/sync-request.schema.json", + "schema/authoritative-entitlement/v1/check.schema.json", + "schema/authoritative-entitlement/v1/subscription.schema.json", + "schema/authoritative-entitlement/v1/restore.schema.json", +]; + +const BILLING_STATE_WEBHOOK = [ + "schema/billing-state-webhook/v1/event.schema.json", + "schema/billing-state-webhook/v1/delivery.schema.json", +]; + +/** + * Compiles a union probe for a contract whose invalid fixtures are documents of + * several sibling envelope schemas. Each schema pins its own `recordType` + * subset, so a well-formed record matches exactly one branch and a rejected + * record matches none. + */ +function unionValidator(schemas) { + return () => { + const ajv = new Ajv2020({ + allErrors: true, + strict: true, + strictRequired: false, + strictTypes: false, + }); + for (const schema of schemas) ajv.addSchema(schemaAt(schema)); + return ajv.compile({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: `urn:mosaic:protocol:schema:rejection-probe:${schemas[0]}`, + anyOf: schemas.map((schema) => ({ $ref: schemaAt(schema).$id })), + }); + }; +} + /** * Every `invalid/` fixture directory, with the pure schema its fixtures are * documents of. Kept explicit: a new contract must be registered deliberately, @@ -157,6 +193,22 @@ export const rejectionLayerTargets = Object.freeze([ directory: "fixtures/billing-ingestion/v1/invalid", validator: billingIngestionV1UnionValidator, }, + { + contract: "Authoritative Entitlement v1", + directory: "fixtures/authoritative-entitlement/v1/invalid", + validator: unionValidator(AUTHORITATIVE_ENTITLEMENT), + }, + { + contract: "Billing State Webhook v1", + directory: "fixtures/billing-state-webhook/v1/invalid", + validator: unionValidator(BILLING_STATE_WEBHOOK), + }, + { + contract: "Customer Access Token v1", + directory: "fixtures/customer-access-token/v1/invalid", + validator: () => + pureSchemaValidator("schema/customer-access-token/v1/token.schema.json"), + }, ]); const DESCRIPTION = diff --git a/protocol/tools/validate.mjs b/protocol/tools/validate.mjs index 34ea567d..842b3509 100644 --- a/protocol/tools/validate.mjs +++ b/protocol/tools/validate.mjs @@ -72,6 +72,21 @@ import { validateBillingIngestionV1Artifacts, validateBillingIngestionV1JsonFormatting, } from "./billing-ingestion-validation-v1.mjs"; +import { + loadAuthoritativeEntitlementV1Artifacts, + validateAuthoritativeEntitlementV1Artifacts, + validateAuthoritativeEntitlementV1JsonFormatting, +} from "./authoritative-entitlement-validation-v1.mjs"; +import { + loadCustomerAccessTokenV1Artifacts, + validateCustomerAccessTokenV1Artifacts, + validateCustomerAccessTokenV1JsonFormatting, +} from "./customer-access-token-validation-v1.mjs"; +import { + loadBillingStateWebhookV1Artifacts, + validateBillingStateWebhookV1Artifacts, + validateBillingStateWebhookV1JsonFormatting, +} from "./billing-state-webhook-validation-v1.mjs"; import { validateAnalyticsMinimizationProjection } from "./generate-analytics-minimization.mjs"; import { validateRejectionLayers } from "./generate-rejection-layers.mjs"; @@ -92,6 +107,10 @@ try { const deliveryArtifactsV3 = loadDeliveryV3Artifacts(); const analyticsEventArtifactsV2 = loadAnalyticsEventV2Artifacts(); const billingIngestionArtifactsV1 = loadBillingIngestionV1Artifacts(); + const authoritativeEntitlementArtifactsV1 = + loadAuthoritativeEntitlementV1Artifacts(); + const customerAccessTokenArtifactsV1 = loadCustomerAccessTokenV1Artifacts(); + const billingStateWebhookArtifactsV1 = loadBillingStateWebhookV1Artifacts(); const errors = [ ...validateBrowserContractGeneration(), ...validateProtocolV02(artifactsV02), @@ -143,6 +162,14 @@ try { ...validateAnalyticsEventV2JsonFormatting(), ...validateBillingIngestionV1Artifacts(billingIngestionArtifactsV1), ...validateBillingIngestionV1JsonFormatting(), + ...validateAuthoritativeEntitlementV1Artifacts( + authoritativeEntitlementArtifactsV1, + ), + ...validateAuthoritativeEntitlementV1JsonFormatting(), + ...validateCustomerAccessTokenV1Artifacts(customerAccessTokenArtifactsV1), + ...validateCustomerAccessTokenV1JsonFormatting(), + ...validateBillingStateWebhookV1Artifacts(billingStateWebhookArtifactsV1), + ...validateBillingStateWebhookV1JsonFormatting(), ...validateAnalyticsMinimizationProjection(), ...validateRejectionLayers(), ]; @@ -160,7 +187,9 @@ try { "Commerce Provider Contracts v1/v2, Commerce Configurations v1/v2, " + "Placement Decision v1, Configuration Delivery v2, Analytics Event " + "v1/v2, Experiment Assignment v1, Configuration Delivery v3, Billing " + - "Ingestion v1 (draft), and the browser contract.", + "Ingestion v1 (draft), Authoritative Entitlement v1 (draft), Customer " + + "Access Token v1 (draft), Billing State Webhook v1 (draft), and the " + + "browser contract.", ); } } catch (error) { diff --git a/sdk/android/CHANGELOG.md b/sdk/android/CHANGELOG.md index ec1b36b2..5b788e23 100644 --- a/sdk/android/CHANGELOG.md +++ b/sdk/android/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +## Unreleased (Phase 9B: subscription state and authoritative entitlements) + +- Add authoritative entitlements behind + `MosaicConfiguration.customerAccessTokenProvider`, which is `null` by default + and leaves the whole feature inert: no request, no file, and every + authoritative surface reporting `unavailable`. The change is purely additive — + the provider-observed commerce API, `MosaicEntitlement`, and Placement + targeting are untouched, and no existing symbol was renamed or deprecated. +- Mosaic Billing requires an application backend. Access is read with an opaque + Customer Access Token that only the host's authenticated server can mint; a + public SDK key can never select a Billing Customer. Tokens are held in memory + only, never persisted, never logged, and never parsed, and + `MosaicCustomerAccessToken.toString()` redacts itself. +- New API: `customerEntitlements` (a `StateFlow` with explicit `Loading`, + `SignedOut`, `Available`, and `Unavailable` states), `checkCustomerEntitlement`, + `refreshCustomerEntitlements`, `identifyCustomer`, `signOutCustomer`, + `restoreAndSyncCustomerEntitlements`, and `customerEntitlementDiagnostics`. + There is no boolean convenience API anywhere: `unknown` and `unavailable` are + real answers a `Boolean` cannot carry. +- Sync is a `POST` carrying an `entitlementSyncRequest` record; conditional + revalidation travels in that body and the unchanged answer is a `200` + `snapshotUnchanged` record carrying its own refreshed window. The request never + asserts a `billingCustomerId` — the Customer Access Token is the sole customer + selector — and a bare `304` preserves the cache without sliding freshness. +- `inactive` is produced only from an accepted snapshot that carries an entry + saying so; an Entitlement key the snapshot does not carry reads `unknown`. Every + failure path — transport, token, digest mismatch, unsupported contract version, + rejected record, expired cache, unreliable device clock — produces `unknown` + and preserves the cache, except a customer/Project/Environment binding + mismatch, which clears it and raises a high-severity diagnostic. +- Snapshots are cached under `noBackupFilesDir` in a per-customer directory named + by a digest of the Billing Customer identifier, written atomically, and + verified by an integrity digest that detects truncation and tampering. Sign-out + deletes them; an identity change removes every other customer's directory. +- Offline access follows the shipped bounded-grace policy with a 60-second + clock-skew tolerance. A device clock earlier than issuance is treated as + unreliable and forces expired-equivalent behaviour rather than becoming a fifth + cache state. +- Conformance is asserted against the canonical fixtures in + `protocol/fixtures/authoritative-entitlement/v1/` and the shared cache-decision, + freshness, and snapshot-digest reference vectors, so Kotlin cannot drift from + the other implementations. +- Transaction Observation submissions now carry the current Customer Access Token + in a `Mosaic-Customer-Token` header when one is available, so a validated + purchase can be bound to an identified Billing Customer instead of anchoring + anonymously. The token is read at send time rather than enqueue time, is read + from the already-held token only and never mints one, is never persisted with + the queue, and is never logged; when it is absent the header is omitted and the + anonymous submission stays valid. Billing Ingestion Contract 1 observation + records are unchanged. +- No new Gradle dependency. + ## Unreleased (Phase 9A: transaction ingestion and validation) - Add the optional Transaction Observation handoff, off by default behind diff --git a/sdk/android/README.md b/sdk/android/README.md index 5e043489..eb111490 100644 --- a/sdk/android/README.md +++ b/sdk/android/README.md @@ -238,6 +238,153 @@ Behaviour: `retryAfterSeconds` inside the record takes precedence over a `Retry-After` header. - No store credential exists in any Mosaic SDK. +- When a Customer Access Token is available, a submission carries it in a + `Mosaic-Customer-Token` header so Mosaic can bind the purchase to the Billing + Customer the host already authenticated. Without it the purchase still + validates but anchors only to its store lineage. The token is read when the + request is built, never when the observation is queued — an observation can sit + in the durable queue across a sign-in — and it is never written beside the + queued record. The submission path reads an **already-held** token only and + never mints one, so a background flush initiates no work against the host's + backend. The 9A observation record itself is unchanged: this is a transport + header, not a contract field, and a missing token simply omits it. + +## Authoritative entitlements (optional, off by default) + +Two different questions have two different answers, and Mosaic keeps them apart: + +- **Provider-observed** — what the store told *this device* a moment ago. + `MosaicPurchaseProvider.activeEntitlements()`, `MosaicEntitlement`, and + Placement targeting. Nothing about it changed, and no symbol was renamed or + deprecated. +- **Authoritative** — what Mosaic has validated server-side and projected into a + Customer Entitlement Snapshot. The `MosaicCustomer…` namespace. This is the + answer that survives a refund, a revocation, a reinstall, and a second device. + +Both exist because neither is sufficient alone: the provider answer is instant +but local and easily stale after a server-side change, and the authoritative +answer is durable but requires a network and an identified customer. + +### Mosaic Billing requires an application backend + +A public SDK key can never select a Billing Customer. Access is read with a +**Customer Access Token**, which only the host's own authenticated backend can +mint (through Mosaic's trusted server API). There is deliberately no client-only +path: one would have to accept a client-asserted identifier, which is the same +as letting any device read any customer's entitlements. + +```kotlin +val mosaic = Mosaic.configure( + apiKey = "mosaic_sdk_…", + purchaseProvider = provider, + // Null — the default — leaves the whole feature inert. + customerAccessTokenProvider = { forceRefresh -> + when (val token = myBackend.mosaicCustomerToken(forceRefresh)) { + null -> MosaicCustomerAccessTokenResult.SignedOut + else -> MosaicCustomerAccessTokenResult.Issued(MosaicCustomerAccessToken(token)) + } + }, +) +val client = mosaic.hostedConfiguration(context) +client.identifyCustomer("billing-customer-id") + +when (val check = client.checkCustomerEntitlement("pro").state) { + is MosaicCustomerEntitlementState.Active -> unlock(stale = check.isStale) + is MosaicCustomerEntitlementState.Inactive -> showPaywall() + // Mosaic could not answer. This is never "not entitled". + is MosaicCustomerEntitlementState.Unknown, + is MosaicCustomerEntitlementState.Unavailable -> keepCurrentAccess() +} +``` + +The token is held in memory only. It is never persisted, never logged, never +parsed — Mosaic's tokens are opaque — and `MosaicCustomerAccessToken.toString()` +redacts itself so an interpolated log line cannot leak it. The SDK calls the +provider on the IO dispatcher and never re-entrantly: concurrent readers collapse +onto one call, and a refused token triggers exactly one forced refresh and one +retry. + +### There is no boolean API, and never `inactive` from a failure + +`unknown` and `unavailable` are real answers and a `Boolean` has nowhere to put +them. `inactive` means Mosaic looked, found no qualifying source, and is +confident; it is produced only from an accepted snapshot that carries an entry +saying so. An Entitlement key the snapshot does not carry reads `unknown`, not +`inactive` — the sync may have been narrowed, the Entitlement may be newer than +the snapshot, or the key may simply be misspelled, and none of those is Mosaic +saying the customer lacks access. A network +failure, a timeout, an expired cache, a digest mismatch, an unsupported version, +a rejected document, and an unreadable device clock all produce `unknown`. A +reader that collapses "I could not find out" into "you do not have it" turns +every Mosaic outage into a mass revocation experienced by paying customers. + +### The sync flow + +Every sync is a `POST` carrying an `entitlementSyncRequest` record. Conditional +revalidation lives **in that body** — `knownSnapshotVersion` and `entityTag` — +rather than in `If-None-Match`, and the unchanged answer is a `200` carrying a +`snapshotUnchanged` record. One path decodes one document, and the refreshed +freshness window arrives inside the record the schema and content digest already +cover, so a proxy that rewrites or strips a header cannot change how long a +device believes its cache is valid. + +The request never asserts a `billingCustomerId`. The Customer Access Token is the +sole customer selector, so there is no field through which a client could try to +read another customer's access. + +A bare `304`, which only an intermediary can produce here, preserves the cache +and slides **nothing**: the cache runs out its own clock. Extending offline +validity requires an answer Mosaic actually produced. + +### Caching, bounded grace, and the device clock + +The accepted snapshot is written under `noBackupFilesDir`, in a directory named +by a digest of the Billing Customer identifier, through a four-step atomic write. +It is backup-excluded from day one so a snapshot cannot travel to another device +and grant one person's access on somebody else's phone. + +Freshness follows the shipped bounded-grace policy, with a 60-second clock-skew +tolerance applied in the direction that favours the user: + +| Window | Cache state | Behaviour | +| --- | --- | --- | +| before `refreshAfter` | `fresh` | Serve; do not refresh. | +| → `validUntil` | `refresh_recommended` | Fully valid; refresh opportunistically. | +| → `validUntil + staleGraceSeconds` | `stale_within_grace` | Previously active Entitlements stay active and are marked stale. | +| after that | `expired` | Report `unknown`. | + +A device clock earlier than issuance by more than the tolerance is *unreliable*. +That is not a fifth state: it forces expired-equivalent behaviour and raises the +`customer.entitlements.clockUnreliable` diagnostic, because a cache whose age +cannot be measured cannot be trusted to be young. Without that rule, moving the +device clock backwards buys unlimited offline access. + +### Identity changes + +`identifyCustomer` and `signOutCustomer` bump an identity generation, orphan +anything in flight, publish `Loading` **before** reading anything, swap the +token, and isolate the on-device directory — in that order — so the previous +customer's grants are never observable for even one frame after a sign-in. +Sign-out deletes every stored snapshot. The Phase 6 installation identity is +untouched: a person signing in is not a new installation. + +### Restore + +`restoreAndSyncCustomerEntitlements()` runs the existing provider recovery +(unchanged, including Google Play acknowledgement), then waits a bounded three +attempts over roughly six seconds for Mosaic to validate it. +`AuthoritativeEntitlementsUpdated` is returned only when an accepted snapshot +actually advanced; otherwise the honest answer is `NativeRecoveryCompleted` with +`validationPending`. A purchase also triggers a debounced refresh that is never +awaited by the purchase path, so a hung entitlement endpoint costs a missed +refresh rather than a stalled purchase. + +### Not a credential + +A snapshot is a read model. Possessing it authorizes nothing, and a backend must +never accept one presented by a client as proof of access. The SDK cache supports +UI continuity and feature gating; protected backend resources are authorized by +the application's own server. ## Supported version matrix diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerAuthentication.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerAuthentication.kt new file mode 100644 index 00000000..b83495b5 --- /dev/null +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerAuthentication.kt @@ -0,0 +1,244 @@ +package dev.mosaic.sdk + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * A Customer Access Token, minted by the host application's own backend. + * + * The SDK treats the value as **opaque**: it never parses it, never inspects it for structure, + * never persists it, and never logs it. Mosaic's tokens are opaque random values stored server-side + * as digests, so there is nothing inside one to read; a client that learned to parse a token would + * also have learned to depend on a shape Mosaic is free to change. + * + * The value class exists so a token cannot be passed where a `String` is expected — an SDK key, a + * user identifier, a log message — by accident. [toString] is overridden for the same reason: the + * default would print the credential into any interpolated string. + */ +@JvmInline +value class MosaicCustomerAccessToken(val value: String) { + init { + require(value.length in 16..4096) { "A Customer Access Token must be between 16 and 4096 characters." } + require(value.none { it.isWhitespace() || it.isISOControl() }) { + "A Customer Access Token must not contain whitespace or control characters." + } + } + + override fun toString(): String = "MosaicCustomerAccessToken(redacted)" +} + +/** + * What the host's token provider had to say. + * + * [SignedOut] and [Unavailable] are deliberately distinct. Signed out is a definite answer about the + * application's own state and produces a signed-out surface with no customer at all; unavailable is + * a failure to answer and produces `unknown`. Collapsing them would make every backend outage look + * like a logout, and every logout look like an outage. + */ +sealed interface MosaicCustomerAccessTokenResult { + data class Issued( + val token: MosaicCustomerAccessToken, + /** + * Optional binding hint. The server always derives the customer from the token itself and + * verifies any hint against it, so this value can never widen access; it lets the SDK + * isolate the on-device cache before the first snapshot arrives. + */ + val billingCustomerId: String? = null, + ) : MosaicCustomerAccessTokenResult + + data class Unavailable( + val diagnosticCode: String = "customer.token.unavailable", + val retryAfterSeconds: Int? = null, + ) : MosaicCustomerAccessTokenResult + + data object SignedOut : MosaicCustomerAccessTokenResult +} + +/** + * The host's hook for minting Customer Access Tokens. + * + * Mosaic Billing requires an application backend: a public SDK key can never select a Billing + * Customer, so the token must be minted by a server that has already authenticated the user. The SDK + * calls this on the IO dispatcher and never re-entrantly — at most one call is outstanding at a + * time, whatever the caller concurrency — so an implementation may perform network work directly. + * + * `forceRefresh` is true only after Mosaic saw the current token refused. An implementation that + * caches should bypass its cache in that case; one that always mints fresh may ignore the flag. + */ +fun interface MosaicCustomerAccessTokenProvider { + suspend fun customerAccessToken(forceRefresh: Boolean): MosaicCustomerAccessTokenResult +} + +/** + * Single-flight token holder. + * + * The collapse is a [Mutex] plus a shared [CompletableDeferred] rather than a lock held across the + * provider call: N concurrent syncs after a cold start, or after a 401, must produce exactly **one** + * provider call, and a lock-per-caller would produce N sequential ones — on a backend that may well + * be rate limiting per user. + * + * The generation counter is what makes identity changes safe. `clear()` bumps it, so a provider call + * that was already in flight when the user logged out completes into nothing: its result is + * discarded rather than cached under the new identity. + */ +internal class MosaicCustomerTokenSession( + private val provider: MosaicCustomerAccessTokenProvider, + private val now: () -> Long = System::currentTimeMillis, + private val cooldownMillis: Long = 30_000, +) { + private class Attempt( + val forced: Boolean, + val generation: Int, + val deferred: CompletableDeferred = CompletableDeferred(), + ) + + private val mutex = Mutex() + private var cached: MosaicCustomerAccessToken? = null + private var cachedCustomerId: String? = null + private var inFlight: Attempt? = null + private var generation = 0 + private var unavailableUntil = 0L + private var lastUnavailable: MosaicCustomerAccessTokenResult? = null + private var signedOut = false + + /** Provider calls made, exposed so tests can assert the collapse rather than infer it. */ + @Volatile + var providerCallCount: Int = 0 + private set + + suspend fun generation(): Int = mutex.withLock { generation } + + suspend fun currentCustomerId(): String? = mutex.withLock { cachedCustomerId } + + /** + * The token already held, or null. **Never** calls the provider. + * + * This is the read for opportunistic, fire-and-forget work — attributing a queued Transaction + * Observation, for instance — where the token is a bonus rather than a requirement. Minting one + * there would make a background flush initiate network work against the host's backend on a + * path nothing is waiting for, and on a cold start it would do so before the app has any reason + * to believe a customer is even signed in. Work that genuinely needs a token calls [token]. + */ + suspend fun heldToken(): MosaicCustomerAccessToken? = mutex.withLock { cached } + + suspend fun token(forceRefresh: Boolean = false): MosaicCustomerAccessTokenResult { + // Bounded: each iteration either returns or joins an attempt, and a joined attempt clears + // itself, so the loop cannot spin against a live provider. + repeat(MAX_COLLAPSE_ROUNDS) { + var owned: Attempt? = null + val attempt: Attempt + mutex.withLock { + if (!forceRefresh) { + if (signedOut) return MosaicCustomerAccessTokenResult.SignedOut + cached?.let { return MosaicCustomerAccessTokenResult.Issued(it, cachedCustomerId) } + // A failing provider is not asked again immediately: a token backend that is + // down would otherwise be retried once per entitlement read. + if (now() < unavailableUntil) { + return lastUnavailable ?: MosaicCustomerAccessTokenResult.Unavailable() + } + } + // At most one provider call exists at any time, so a caller either owns the attempt + // or joins the one already running. A forced caller that joins an unforced attempt + // is not satisfied by it and starts its own on the next round. + attempt = inFlight ?: Attempt(forceRefresh, generation).also { inFlight = it; owned = it } + } + + val own = owned + if (own == null) { + val joined = attempt.deferred.await() + // Joining an unforced attempt does not satisfy a forced caller; go round again and + // start the forced call the caller actually asked for. + if (!forceRefresh || attempt.forced) return joined + return@repeat + } + + val result = try { + providerCallCount += 1 + withContext(Dispatchers.IO) { provider.customerAccessToken(forceRefresh) } + } catch (cancellation: CancellationException) { + mutex.withLock { if (inFlight === own) inFlight = null } + own.deferred.completeExceptionally(cancellation) + throw cancellation + } catch (_: Throwable) { + // A host token provider that throws is a host defect, never a Mosaic crash, and + // never an entitlement decision: it degrades to "cannot answer". + MosaicCustomerAccessTokenResult.Unavailable("customer.token.providerFailed") + } + + mutex.withLock { + if (inFlight === own) inFlight = null + // A result minted for a previous identity is discarded, not cached. + if (own.generation == generation) apply(result) + } + own.deferred.complete(result) + return result + } + return MosaicCustomerAccessTokenResult.Unavailable("customer.token.unavailable") + } + + /** + * Drops the token only if it is still the one the caller saw refused. + * + * The compare-and-set matters: without it, a 401 answered with a token that had already been + * replaced by a concurrent refresh would throw away the good replacement and start an + * invalidation loop. + */ + suspend fun invalidate(token: MosaicCustomerAccessToken) = mutex.withLock { + if (cached == token) { + cached = null + } + } + + /** Logout. The generation bump orphans any in-flight provider call. */ + suspend fun clear() = mutex.withLock { + generation += 1 + cached = null + cachedCustomerId = null + inFlight = null + unavailableUntil = 0 + lastUnavailable = null + signedOut = true + } + + /** Sign-in, or an identity change. Also orphans anything in flight for the previous identity. */ + suspend fun reset(billingCustomerId: String?) = mutex.withLock { + generation += 1 + cached = null + cachedCustomerId = billingCustomerId + inFlight = null + unavailableUntil = 0 + lastUnavailable = null + signedOut = false + } + + private fun apply(result: MosaicCustomerAccessTokenResult) { + when (result) { + is MosaicCustomerAccessTokenResult.Issued -> { + cached = result.token + result.billingCustomerId?.let { cachedCustomerId = it } + signedOut = false + unavailableUntil = 0 + lastUnavailable = null + } + is MosaicCustomerAccessTokenResult.Unavailable -> { + cached = null + lastUnavailable = result + unavailableUntil = now() + + (result.retryAfterSeconds?.toLong()?.times(1_000) ?: cooldownMillis) + } + MosaicCustomerAccessTokenResult.SignedOut -> { + cached = null + cachedCustomerId = null + signedOut = true + } + } + } + + private companion object { + const val MAX_COLLAPSE_ROUNDS = 4 + } +} diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementCache.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementCache.kt new file mode 100644 index 00000000..19f7a84c Binary files /dev/null and b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementCache.kt differ diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementCodec.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementCodec.kt new file mode 100644 index 00000000..dabc31a8 --- /dev/null +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementCodec.kt @@ -0,0 +1,595 @@ +package dev.mosaic.sdk + +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.security.MessageDigest + +/** + * Explicit tree codec for Authoritative Entitlement Contract 1. + * + * Gson is used for its JSON tree and string-escaping APIs only. Nothing here is reflectively bound, + * so R8 may rename every field in this SDK without changing a persisted or transmitted byte — the + * same discipline the rest of Mosaic's persisted records already follow. + * + * Reading is closed at every level: an unknown field, record type, contract version, or enumeration + * member rejects the whole record. The single exception is `entitlementKey`, which is Project data + * rather than contract vocabulary: rejecting an unrecognized key would make *defining a new + * Entitlement* a breaking change for every already-shipped SDK. + */ +internal sealed interface MosaicCustomerRecordDecoding { + data class Snapshot( + val snapshot: MosaicCustomerEntitlementSnapshot, + /** + * Carried rather than thrown: a digest mismatch is a cache **decision** (reject, preserve), + * and the acceptance gate must see it in check order rather than as a parse failure. + */ + val contentDigestValid: Boolean, + ) : MosaicCustomerRecordDecoding + + data class Unchanged(val unchanged: MosaicCustomerSnapshotUnchanged) : MosaicCustomerRecordDecoding + + data class Unreadable(val rejection: MosaicCustomerSnapshotRejection) : MosaicCustomerRecordDecoding +} + +/** A cached snapshot with the freshness window it is currently governed by. */ +internal data class MosaicCachedCustomerEntitlements( + val snapshot: MosaicCustomerEntitlementSnapshot, + val window: MosaicCustomerEntitlementFreshnessWindow, +) + +internal object MosaicCustomerEntitlementCodec { + const val CONTRACT_VERSION: String = "1" + const val CACHE_FORMAT_VERSION: String = "1" + + /** `limits.maxRecordBytes` from the compatibility manifest. */ + const val MAX_RECORD_BYTES: Int = 64 * 1024 + + /** `limits.maxCacheHorizonSeconds`: 30 days over validity *and* grace combined. */ + const val MAX_CACHE_HORIZON_SECONDS: Long = 2_592_000 + + private const val MAX_CACHE_RECORD_BYTES = 4 * MAX_RECORD_BYTES + + private val gson = GsonBuilder().disableHtmlEscaping().create() + + private val identifierPattern = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + private val entitlementKeyPattern = Regex("^[a-z][a-z0-9_.-]{0,63}$") + private val entityTagPattern = Regex("^[A-Za-z0-9._-]{8,128}$") + private val digestPattern = Regex("^sha256:[a-f0-9]{64}$") + private val diagnosticCodePattern = Regex("^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$") + private val safeTextPattern = Regex("^[^\\r\\n\\u0000-\\u001F\\u007F]{1,240}$") + + // ------------------------------------------------------------------------------------------ + // Records + // ------------------------------------------------------------------------------------------ + + fun decodeRecord(source: String): MosaicCustomerRecordDecoding { + if (source.toByteArray(Charsets.UTF_8).size > MAX_RECORD_BYTES) { + return MosaicCustomerRecordDecoding.Unreadable(MosaicCustomerSnapshotRejection.MALFORMED_RECORD) + } + val root = runCatching { JsonParser.parseString(source).asJsonObject }.getOrNull() + ?: return MosaicCustomerRecordDecoding.Unreadable(MosaicCustomerSnapshotRejection.MALFORMED_RECORD) + + // The version check runs before anything else is trusted: a document in an unknown version + // cannot be relied on to have interpretable binding fields. + val version = runCatching { root.get("authoritativeEntitlementContractVersion")?.asString }.getOrNull() + if (version != CONTRACT_VERSION) { + return MosaicCustomerRecordDecoding.Unreadable( + MosaicCustomerSnapshotRejection.UNSUPPORTED_CONTRACT_VERSION, + ) + } + return runCatching { + root.requireExact( + setOf("authoritativeEntitlementContractVersion", "recordType", "payload"), + emptySet(), + "$", + ) + val payload = root.getAsJsonObject("payload") + when (root.get("recordType").asString) { + "customerEntitlementSnapshot" -> decodeSnapshot(payload) + "snapshotUnchanged" -> MosaicCustomerRecordDecoding.Unchanged(decodeUnchanged(payload)) + else -> MosaicCustomerRecordDecoding.Unreadable( + MosaicCustomerSnapshotRejection.MALFORMED_RECORD, + ) + } + }.getOrElse { + MosaicCustomerRecordDecoding.Unreadable(MosaicCustomerSnapshotRejection.MALFORMED_RECORD) + } + } + + /** + * Builds the sync request. + * + * `billingCustomerId` is deliberately **not** emitted, even though the contract permits it as a + * hint. The Customer Access Token is the sole customer selector; a caller cannot widen access by + * asserting an identifier, and omitting the field entirely means there is no second place where + * a stale or wrong customer identity could be introduced. The parameter does not exist here so + * that no future call site can reintroduce it by mistake. + */ + fun encodeSyncRequest( + correlationId: String, + knownSnapshotVersion: Long?, + entityTag: String?, + requestedEntitlementKeys: List, + ): String { + val payload = JsonObject().apply { + knownSnapshotVersion?.takeIf { it >= 0 }?.let { addProperty("knownSnapshotVersion", it) } + entityTag?.let { addProperty("entityTag", it) } + add( + "supportedAuthoritativeEntitlementContracts", + JsonArray().apply { add(CONTRACT_VERSION) }, + ) + requestedEntitlementKeys.takeIf { it.isNotEmpty() }?.let { keys -> + add("requestedEntitlementKeys", JsonArray().apply { keys.forEach(::add) }) + } + addProperty("correlationId", correlationId) + } + return gson.toJson( + JsonObject().apply { + addProperty("authoritativeEntitlementContractVersion", CONTRACT_VERSION) + addProperty("recordType", "entitlementSyncRequest") + add("payload", payload) + }, + ) + } + + // ------------------------------------------------------------------------------------------ + // Cache record + // ------------------------------------------------------------------------------------------ + + /** + * The persisted form: the accepted contract record verbatim, plus the freshness window, which a + * `snapshotUnchanged` response slides without producing a new snapshot. + * + * `integrityDigest` detects local corruption and truncation. It is explicitly **not** a security + * control: it is computed here with no secret, so anyone who can write the file can recompute + * it. The security property this cache relies on is the private, backup-excluded directory. + */ + fun encodeCacheRecord(record: String, window: MosaicCustomerEntitlementFreshnessWindow): String { + val body = JsonObject().apply { + addProperty("cacheFormatVersion", CACHE_FORMAT_VERSION) + add( + "freshness", + JsonObject().apply { + addProperty("issuedAt", window.issuedAt) + addProperty("refreshAfter", window.refreshAfter) + addProperty("staleGraceSeconds", window.staleGraceSeconds) + addProperty("validUntil", window.validUntil) + }, + ) + add("record", JsonParser.parseString(record)) + } + val digest = digest(body) + return gson.toJson(JsonObject().apply { add("body", body); addProperty("integrityDigest", digest) }) + } + + /** Returns null for every unreadable, truncated, tampered, or foreign-format cache entry. */ + fun decodeCacheRecord(source: String): MosaicCachedCustomerEntitlements? = runCatching { + require(source.toByteArray(Charsets.UTF_8).size <= MAX_CACHE_RECORD_BYTES) + val root = JsonParser.parseString(source).asJsonObject + root.requireExact(setOf("body", "integrityDigest"), emptySet(), "$") + val body = root.getAsJsonObject("body") + require(root.get("integrityDigest").asString == digest(body)) + body.requireExact(setOf("cacheFormatVersion", "freshness", "record"), emptySet(), "$.body") + require(body.get("cacheFormatVersion").asString == CACHE_FORMAT_VERSION) + + val freshness = body.getAsJsonObject("freshness") + freshness.requireExact( + setOf("issuedAt", "refreshAfter", "staleGraceSeconds", "validUntil"), + emptySet(), + "$.body.freshness", + ) + val window = MosaicCustomerEntitlementFreshnessWindow( + issuedAt = timestamp(freshness, "issuedAt"), + refreshAfter = timestamp(freshness, "refreshAfter"), + validUntil = timestamp(freshness, "validUntil"), + staleGraceSeconds = boundedInt(freshness, "staleGraceSeconds", 0, MAX_CACHE_HORIZON_SECONDS.toInt()), + ) + requireBoundedHorizon(window) + + val decoded = decodeRecord(gson.toJson(body.get("record"))) + require(decoded is MosaicCustomerRecordDecoding.Snapshot) + // A cache entry whose own record fails its content digest is corrupt, not merely stale. + require(decoded.contentDigestValid) + MosaicCachedCustomerEntitlements(decoded.snapshot, window) + }.getOrNull() + + // ------------------------------------------------------------------------------------------ + // Canonical serialization + // ------------------------------------------------------------------------------------------ + + /** + * SHA-256 over the canonical serialization: minified, members ascending by UTF-16 code unit at + * every depth, array order preserved exactly, absent members omitted, and `null` never emitted. + * + * Array order is normative in this contract — entries ascend by `entitlementKey`, sources by + * `sourceId` — so sorting an array here would silently repair a document the semantic rules + * exist to reject. + */ + fun digest(value: JsonElement): String = "sha256:" + MessageDigest.getInstance("SHA-256") + .digest(canonicalJson(value).toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + fun canonicalJson(value: JsonElement): String = when { + value.isJsonNull -> throw IllegalArgumentException("null is never admissible in this contract.") + value.isJsonArray -> value.asJsonArray.joinToString(",", "[", "]") { canonicalJson(it) } + value.isJsonObject -> value.asJsonObject.entrySet() + .sortedBy { it.key } + .joinToString(",", "{", "}") { (key, child) -> "${gson.toJson(key)}:${canonicalJson(child)}" } + value.asJsonPrimitive.isString -> gson.toJson(value.asString) + value.asJsonPrimitive.isBoolean -> value.asBoolean.toString() + // This contract contains no non-integer numbers, so shortest decimal form is exact. + else -> value.asBigDecimal.stripTrailingZeros().toPlainString() + } + + // ------------------------------------------------------------------------------------------ + // Snapshot decoding + // ------------------------------------------------------------------------------------------ + + private fun decodeSnapshot(payload: JsonObject): MosaicCustomerRecordDecoding { + payload.requireExact( + required = setOf( + "snapshotId", "billingCustomerId", "projectId", "environmentId", "snapshotVersion", + "projectionRuleVersion", "issuedAt", "asOf", "refreshAfter", "validUntil", + "entityTag", "contentDigest", "entries", "sources", "projectionStatus", + "changeReason", "correlationId", + ), + optional = setOf("previousSnapshotVersion", "staleGraceSeconds", "diagnostics"), + path = "$.payload", + ) + + val declaredDigest = payload.get("contentDigest").asString + require(digestPattern.matches(declaredDigest)) + val recomputed = digest(payload.deepCopy().also { it.remove("contentDigest") }) + + val window = MosaicCustomerEntitlementFreshnessWindow( + issuedAt = timestamp(payload, "issuedAt"), + refreshAfter = timestamp(payload, "refreshAfter"), + validUntil = timestamp(payload, "validUntil"), + staleGraceSeconds = payload.get("staleGraceSeconds") + ?.let { boundedInt(payload, "staleGraceSeconds", 0, MAX_CACHE_HORIZON_SECONDS.toInt()) } + // Absent means zero, so a producer that intends bounded grace states it explicitly. + ?: 0, + ) + require(window.refreshAfterEpochMillis <= window.validUntilEpochMillis) { + "refreshAfter must not be later than validUntil." + } + requireBoundedHorizon(window) + + val entries = payload.getAsJsonArray("entries").map { decodeEntry(it.asJsonObject) } + val sources = payload.getAsJsonArray("sources").map { decodeSource(it.asJsonObject) } + require(entries.size <= 200 && sources.size <= 200) + + val snapshot = MosaicCustomerEntitlementSnapshot( + snapshotId = identifier(payload, "snapshotId"), + billingCustomerId = identifier(payload, "billingCustomerId"), + projectId = identifier(payload, "projectId"), + environmentId = identifier(payload, "environmentId"), + snapshotVersion = boundedLong(payload, "snapshotVersion", 0, 999_999_999_999), + previousSnapshotVersion = payload.get("previousSnapshotVersion") + ?.let { boundedLong(payload, "previousSnapshotVersion", 0, 999_999_999_999) }, + projectionRuleVersion = boundedInt(payload, "projectionRuleVersion", 1, 1_000_000), + asOf = timestamp(payload, "asOf"), + freshness = window, + entityTag = payload.get("entityTag").asString.also { require(entityTagPattern.matches(it)) }, + contentDigest = declaredDigest, + entries = entries, + sources = sources, + projectionStatus = decodeProjectionStatus(payload.getAsJsonObject("projectionStatus")), + changeReason = requireNotNull( + MosaicCustomerSnapshotChangeReason.from(payload.get("changeReason").asString), + ), + correlationId = identifier(payload, "correlationId"), + diagnostics = payload.getAsJsonArray("diagnostics") + ?.also { require(it.size() <= 10) } + ?.map { decodeDiagnostic(it.asJsonObject) } + .orEmpty(), + ) + if (snapshot.snapshotVersion == 0L) { + require(snapshot.previousSnapshotVersion == null) + require(snapshot.entries.isEmpty() && snapshot.sources.isEmpty()) + require(snapshot.projectionStatus.state == MosaicCustomerProjectionState.PENDING) + require(snapshot.projectionStatus.pendingFactCount != null) + require(snapshot.projectionStatus.lastProjectedAt == snapshot.asOf) + require(snapshot.changeReason == MosaicCustomerSnapshotChangeReason.INITIAL_PROJECTION) + } + validateEntitlementGraph(snapshot) + return MosaicCustomerRecordDecoding.Snapshot(snapshot, contentDigestValid = declaredDigest == recomputed) + } + + private fun decodeUnchanged(payload: JsonObject): MosaicCustomerSnapshotUnchanged { + payload.requireExact( + required = setOf( + "billingCustomerId", "projectId", "environmentId", "snapshotVersion", "entityTag", + "issuedAt", "asOf", "refreshAfter", "validUntil", "projectionStatus", "correlationId", + ), + optional = setOf("staleGraceSeconds", "diagnostics"), + path = "$.payload", + ) + val window = MosaicCustomerEntitlementFreshnessWindow( + issuedAt = timestamp(payload, "issuedAt"), + refreshAfter = timestamp(payload, "refreshAfter"), + validUntil = timestamp(payload, "validUntil"), + staleGraceSeconds = payload.get("staleGraceSeconds") + ?.let { boundedInt(payload, "staleGraceSeconds", 0, MAX_CACHE_HORIZON_SECONDS.toInt()) } + ?: 0, + ) + require(window.refreshAfterEpochMillis <= window.validUntilEpochMillis) + // Enforced on the unchanged record too: otherwise the 30-day horizon could be evaded by + // confirming a snapshot rather than reissuing it. + requireBoundedHorizon(window) + return MosaicCustomerSnapshotUnchanged( + billingCustomerId = identifier(payload, "billingCustomerId"), + projectId = identifier(payload, "projectId"), + environmentId = identifier(payload, "environmentId"), + snapshotVersion = boundedLong(payload, "snapshotVersion", 1, 999_999_999_999), + entityTag = payload.get("entityTag").asString.also { require(entityTagPattern.matches(it)) }, + asOf = timestamp(payload, "asOf"), + freshness = window, + projectionStatus = decodeProjectionStatus(payload.getAsJsonObject("projectionStatus")), + correlationId = identifier(payload, "correlationId"), + ) + } + + private fun decodeEntry(value: JsonObject): MosaicCustomerEntitlementEntry { + value.requireExact( + required = setOf( + "entitlementId", "entitlementKey", "state", "endKnown", "sourceIds", "sourceCount", + "primaryExplanation", + ), + optional = setOf("effectiveStart", "effectiveEnd", "refreshRecommendedAt", "uncertainty"), + path = "$.payload.entries[]", + ) + val key = value.get("entitlementKey").asString + require(entitlementKeyPattern.matches(key)) + val explanation = decodeExplanation(value.getAsJsonObject("primaryExplanation")) + val endKnown = value.get("endKnown").asBoolean + val effectiveEnd = value.get("effectiveEnd")?.let { timestamp(value, "effectiveEnd") } + require(endKnown || effectiveEnd == null) { + "An uncertain end must not carry an effectiveEnd; a reader must display no expiry at all." + } + val uncertainty = value.get("uncertainty")?.let { decodeUncertainty(value.getAsJsonObject("uncertainty")) } + + val state = when (val declared = value.get("state").asString) { + "active" -> MosaicCustomerEntitlementState.Active( + explanation = explanation, + effectiveStart = timestamp(value, "effectiveStart"), + effectiveEnd = effectiveEnd, + endKnown = endKnown, + ) + "inactive" -> MosaicCustomerEntitlementState.Inactive(explanation) + "unknown" -> MosaicCustomerEntitlementState.Unknown( + explanation, + requireNotNull(uncertainty) { "An unknown entry must explain itself." } + .also { require(it.reason != MosaicCustomerUncertaintyReason.NONE) }, + ) + // "unavailable" is a service state and is structurally inadmissible inside an immutable + // snapshot: a service failure must never be persisted as customer state. + else -> throw IllegalArgumentException("Unsupported persisted entitlement state $declared.") + } + + val sourceIds = value.getAsJsonArray("sourceIds").map { it.asString.also(::requireIdentifier) } + require(sourceIds.size == sourceIds.toSet().size && sourceIds.size <= 64) + require(sourceIds == sourceIds.sorted()) { "sourceIds must ascend." } + val sourceCount = boundedInt(value, "sourceCount", 0, 64) + require(sourceCount == sourceIds.size) { "sourceCount must equal the number of sourceIds." } + + return MosaicCustomerEntitlementEntry( + entitlementId = identifier(value, "entitlementId"), + entitlementKey = key, + state = state, + sourceIds = sourceIds, + sourceCount = sourceCount, + refreshRecommendedAt = value.get("refreshRecommendedAt")?.let { timestamp(value, "refreshRecommendedAt") }, + ) + } + + private fun decodeSource(value: JsonObject): MosaicCustomerEntitlementSource { + value.requireExact( + required = setOf( + "sourceId", "sourceType", "mosaicProductId", "grantVersionId", "sourceSnapshotId", + "start", "sourceState", "uncertainty", "explanationCode", "isTestSource", + ), + optional = setOf("subscriptionInstanceId", "oneTimePurchaseInstanceId", "storePlatform", "end"), + path = "$.payload.sources[]", + ) + val sourceType = requireNotNull(MosaicCustomerSourceType.from(value.get("sourceType").asString)) + val subscriptionInstanceId = value.get("subscriptionInstanceId")?.let { identifier(value, "subscriptionInstanceId") } + val oneTimePurchaseInstanceId = value.get("oneTimePurchaseInstanceId")?.let { identifier(value, "oneTimePurchaseInstanceId") } + if (sourceType == MosaicCustomerSourceType.ONE_TIME_NON_CONSUMABLE) { + require(oneTimePurchaseInstanceId != null && subscriptionInstanceId == null) + } else { + require(subscriptionInstanceId != null && oneTimePurchaseInstanceId == null) + } + val sourceState = requireNotNull(MosaicCustomerSourceState.from(value.get("sourceState").asString)) + val uncertainty = decodeUncertainty(value.getAsJsonObject("uncertainty")) + if (sourceState == MosaicCustomerSourceState.UNKNOWN) { + require(uncertainty.reason != MosaicCustomerUncertaintyReason.NONE) + } + return MosaicCustomerEntitlementSource( + sourceId = identifier(value, "sourceId"), + sourceType = sourceType, + subscriptionInstanceId = subscriptionInstanceId, + oneTimePurchaseInstanceId = oneTimePurchaseInstanceId, + mosaicProductId = identifier(value, "mosaicProductId"), + grantVersionId = identifier(value, "grantVersionId"), + sourceSnapshotId = identifier(value, "sourceSnapshotId"), + storePlatform = value.get("storePlatform") + ?.let { requireNotNull(MosaicCustomerStorePlatform.from(it.asString)) }, + start = timestamp(value, "start"), + end = value.get("end")?.let { timestamp(value, "end") }, + sourceState = sourceState, + uncertainty = uncertainty, + explanationCode = requireNotNull( + MosaicCustomerEntitlementExplanationCode.from(value.get("explanationCode").asString), + ), + isTestSource = value.get("isTestSource").asBoolean, + ) + } + + private fun decodeExplanation(value: JsonObject): MosaicCustomerEntitlementExplanation { + value.requireExact(setOf("code"), setOf("sourceId", "safeSummary"), "primaryExplanation") + val summary = value.get("safeSummary")?.asString + require(summary == null || safeTextPattern.matches(summary)) + return MosaicCustomerEntitlementExplanation( + code = requireNotNull(MosaicCustomerEntitlementExplanationCode.from(value.get("code").asString)), + sourceId = value.get("sourceId")?.let { identifier(value, "sourceId") }, + safeSummary = summary, + ) + } + + private fun decodeUncertainty(value: JsonObject): MosaicCustomerUncertainty { + value.requireExact( + setOf("reason"), + setOf("since", "expectedResolution", "diagnosticCode"), + "uncertainty", + ) + val reason = requireNotNull(MosaicCustomerUncertaintyReason.from(value.get("reason").asString)) + val since = value.get("since")?.let { timestamp(value, "since") } + // The pairing is enforced in both directions: a definite state has no `since`, and a + // non-definite one must say when it started. + require((reason == MosaicCustomerUncertaintyReason.NONE) == (since == null)) + val diagnosticCode = value.get("diagnosticCode")?.asString + require(diagnosticCode == null || diagnosticCodePattern.matches(diagnosticCode)) + return MosaicCustomerUncertainty( + reason = reason, + since = since, + expectedResolution = value.get("expectedResolution") + ?.let { requireNotNull(MosaicCustomerExpectedResolution.from(it.asString)) }, + diagnosticCode = diagnosticCode, + ) + } + + private fun decodeProjectionStatus(value: JsonObject): MosaicCustomerProjectionStatus { + value.requireExact( + setOf("state", "lastProjectedAt"), + setOf("pendingFactCount", "diagnosticCode"), + "projectionStatus", + ) + val state = requireNotNull(MosaicCustomerProjectionState.from(value.get("state").asString)) + val pending = value.get("pendingFactCount")?.let { boundedInt(value, "pendingFactCount", 0, 1_000_000) } + val diagnosticCode = value.get("diagnosticCode")?.asString + require(diagnosticCode == null || diagnosticCodePattern.matches(diagnosticCode)) + if (state == MosaicCustomerProjectionState.PENDING) require(pending != null) + if (state == MosaicCustomerProjectionState.DEGRADED || state == MosaicCustomerProjectionState.FAILED) { + require(diagnosticCode != null) + } + return MosaicCustomerProjectionStatus(state, timestamp(value, "lastProjectedAt"), pending, diagnosticCode) + } + + private fun decodeDiagnostic(value: JsonObject): MosaicCustomerEntitlementDiagnostic { + value.requireExact( + setOf("code", "safeMessage", "severity", "retryable", "correlationId"), + setOf("retryAfterSeconds", "recoveryAction"), + "diagnostic", + ) + val code = value.get("code").asString + require(diagnosticCodePattern.matches(code)) + val message = value.get("safeMessage").asString + require(safeTextPattern.matches(message)) + val severity = value.get("severity").asString + require(severity in setOf("info", "warning", "error")) + val recovery = value.get("recoveryAction")?.asString + require( + recovery == null || recovery in setOf( + "retry", "refreshCustomerAccessToken", "requestAuthoritativeSync", + "resolveIdentityConflict", "fixProductMapping", "contactProvider", "none", + ), + ) + return MosaicCustomerEntitlementDiagnostic( + code = code, + safeMessage = message, + severity = severity, + retryable = value.get("retryable").asBoolean, + correlationId = identifier(value, "correlationId"), + retryAfterSeconds = value.get("retryAfterSeconds")?.let { boundedInt(value, "retryAfterSeconds", 1, 86_400) }, + recoveryAction = recovery, + ) + } + + /** + * The semantic rules no JSON Schema can express. + * + * The two access rules are the projection's half of the contract's top rule: an active entry + * always has a reason, and unresolved evidence yields `unknown` rather than `inactive`. + */ + private fun validateEntitlementGraph(snapshot: MosaicCustomerEntitlementSnapshot) { + val keys = snapshot.entries.map { it.entitlementKey } + require(keys == keys.sorted() && keys.size == keys.toSet().size) { + "Entries must ascend by entitlementKey and be unique." + } + val sourceIds = snapshot.sources.map { it.sourceId } + require(sourceIds == sourceIds.sorted() && sourceIds.size == sourceIds.toSet().size) { + "Sources must ascend by sourceId and be unique." + } + val byId = snapshot.sources.associateBy { it.sourceId } + val referenced = mutableSetOf() + snapshot.entries.forEach { entry -> + val contributing = entry.sourceIds.map { id -> + requireNotNull(byId[id]) { "An entry references a source the snapshot does not carry." } + } + referenced += entry.sourceIds + when (entry.state) { + is MosaicCustomerEntitlementState.Active -> require( + contributing.any { it.sourceState == MosaicCustomerSourceState.GRANTING }, + ) { "An active Entitlement always has a granting source." } + is MosaicCustomerEntitlementState.Inactive -> require( + contributing.none { + it.sourceState == MosaicCustomerSourceState.GRANTING || + it.sourceState == MosaicCustomerSourceState.UNKNOWN + }, + ) { "Unresolved evidence yields unknown, never inactive." } + else -> Unit + } + } + require(referenced.containsAll(sourceIds)) { + "Every source must be accounted for by at least one entry." + } + } + + // ------------------------------------------------------------------------------------------ + // Primitives + // ------------------------------------------------------------------------------------------ + + private fun requireBoundedHorizon(window: MosaicCustomerEntitlementFreshnessWindow) { + val horizonSeconds = + (window.validUntilEpochMillis - window.issuedAtEpochMillis) / 1_000 + window.staleGraceSeconds + require(horizonSeconds in 0..MAX_CACHE_HORIZON_SECONDS) { + "Validity and grace may never compose into more than a 30-day unconfirmed horizon." + } + } + + private fun identifier(value: JsonObject, name: String): String = + value.get(name).asString.also(::requireIdentifier) + + /** + * Identifiers are validated against the contract pattern and nothing more. + * + * A JWS-shaped value in an identifier field — a signed provider payload smuggled into, say, a + * `correlationId` — is a **producer-side** defect, caught by the semantic validator that guards + * what Mosaic emits. It is deliberately not a reader rejection: the reader's job is to refuse + * documents it cannot interpret, and this one is fully interpretable. Rejecting it here would + * mean a customer loses access because a server put an odd-looking string in a field the SDK + * only ever passes through, which is a worse outcome than carrying it. + */ + private fun requireIdentifier(value: String) { + require(identifierPattern.matches(value)) { "Invalid Mosaic identifier." } + } + + private fun timestamp(value: JsonObject, name: String): String = + value.get(name).asString.also { mosaicContractInstantMillis(it) } + + private fun boundedInt(value: JsonObject, name: String, min: Int, max: Int): Int { + val number = value.get(name).asBigDecimal + require(number.stripTrailingZeros().scale() <= 0) { "This contract contains no non-integer numbers." } + return number.toInt().also { require(it in min..max) } + } + + private fun boundedLong(value: JsonObject, name: String, min: Long, max: Long): Long { + val number = value.get(name).asBigDecimal + require(number.stripTrailingZeros().scale() <= 0) { "This contract contains no non-integer numbers." } + return number.toLong().also { require(it in min..max) } + } +} diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementModels.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementModels.kt new file mode 100644 index 00000000..899aeb61 --- /dev/null +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementModels.kt @@ -0,0 +1,457 @@ +package dev.mosaic.sdk + +/** + * Authoritative Entitlement Contract 1 reader models. + * + * These types describe what **Mosaic** says a Billing Customer may access, projected server-side + * from validated provider facts. They are deliberately a separate namespace from the frozen + * provider-observed commerce types ([MosaicEntitlement], [MosaicActiveEntitlementsResult]): a + * provider-observed entitlement is what a store told this device a moment ago, an authoritative + * entitlement is what Mosaic has validated and is willing to be held to. Neither replaces the + * other in Phase 9B, and no existing symbol changes meaning. + * + * Two rules from the contract are structural here rather than documented: + * + * 1. **There is no boolean convenience API anywhere.** `unknown` and `unavailable` are real + * answers, and a `Boolean` has nowhere to put them; every `Boolean` accessor a reader might + * add would collapse "Mosaic could not answer" into "you do not have it". + * 2. **A reason is present whenever the state is not [MosaicCustomerEntitlementState.Active].** + * Inactive carries the explanation that justifies it; unknown and unavailable additionally + * carry the uncertainty that says why the answer is not definitive. + */ + +/** Closed explanation vocabulary. A reader may render its own copy for a code, never invent one. */ +enum class MosaicCustomerEntitlementExplanationCode(val wireName: String) { + ACTIVE_SUBSCRIPTION_PERIOD("active_subscription_period"), + ACTIVE_TRIAL_PERIOD("active_trial_period"), + ACTIVE_GRACE_PERIOD("active_grace_period"), + ACTIVE_BILLING_RETRY_ALLOWANCE("active_billing_retry_allowance"), + PERMANENT_ONE_TIME_PURCHASE("permanent_one_time_purchase"), + FAMILY_SHARED_SOURCE("family_shared_source"), + SCHEDULED_PAUSE_NOT_YET_EFFECTIVE("scheduled_pause_not_yet_effective"), + SUBSCRIPTION_CANCELLED_ACCESS_UNTIL_PERIOD_END("subscription_cancelled_access_until_period_end"), + SUBSCRIPTION_EXPIRED("subscription_expired"), + SUBSCRIPTION_PAUSED("subscription_paused"), + SUBSCRIPTION_REVOKED("subscription_revoked"), + SUBSCRIPTION_REFUNDED("subscription_refunded"), + SUBSCRIPTION_SUPERSEDED("subscription_superseded"), + GRANT_VERSION_ENDED("grant_version_ended"), + NO_QUALIFYING_SOURCE("no_qualifying_source"), + IDENTITY_UNRESOLVED("identity_unresolved"), + PRODUCT_UNRESOLVED("product_unresolved"), + CONFLICTING_FACTS("conflicting_facts"), + PROJECTION_FAILED("projection_failed"), + PROVIDER_EVIDENCE_STALE("provider_evidence_stale"), + PROVIDER_UNAVAILABLE("provider_unavailable"), + BILLING_DISABLED("billing_disabled"), + UNSUPPORTED_PROVIDER_STATE("unsupported_provider_state"), + ; + + internal companion object { + private val byWireName = entries.associateBy(MosaicCustomerEntitlementExplanationCode::wireName) + fun from(value: String): MosaicCustomerEntitlementExplanationCode? = byWireName[value] + } +} + +enum class MosaicCustomerUncertaintyReason(val wireName: String) { + NONE("none"), + PROVIDER_UNAVAILABLE("provider_unavailable"), + MISSING_FACT("missing_fact"), + IDENTITY_UNRESOLVED("identity_unresolved"), + PRODUCT_UNRESOLVED("product_unresolved"), + CONFLICTING_FACTS("conflicting_facts"), + PROJECTION_FAILED("projection_failed"), + STALE_VALIDATION("stale_validation"), + UNSUPPORTED_PROVIDER_STATE("unsupported_provider_state"), + ; + + internal companion object { + private val byWireName = entries.associateBy(MosaicCustomerUncertaintyReason::wireName) + fun from(value: String): MosaicCustomerUncertaintyReason? = byWireName[value] + } +} + +enum class MosaicCustomerExpectedResolution(val wireName: String) { + AUTOMATIC_RETRY("automatic_retry"), + NEXT_PROVIDER_NOTIFICATION("next_provider_notification"), + NEXT_PROJECTION_RUN("next_projection_run"), + OPERATOR_ACTION("operator_action"), + CUSTOMER_ACTION("customer_action"), + NONE_EXPECTED("none_expected"), + ; + + internal companion object { + private val byWireName = entries.associateBy(MosaicCustomerExpectedResolution::wireName) + fun from(value: String): MosaicCustomerExpectedResolution? = byWireName[value] + } +} + +/** Why a state is not definitive. A definitive state carries [MosaicCustomerUncertaintyReason.NONE]. */ +data class MosaicCustomerUncertainty( + val reason: MosaicCustomerUncertaintyReason, + val since: String? = null, + val expectedResolution: MosaicCustomerExpectedResolution? = null, + val diagnosticCode: String? = null, +) { + internal companion object { + val Definite = MosaicCustomerUncertainty(MosaicCustomerUncertaintyReason.NONE) + } +} + +data class MosaicCustomerEntitlementExplanation( + val code: MosaicCustomerEntitlementExplanationCode, + val sourceId: String? = null, + val safeSummary: String? = null, +) + +/** + * The authoritative state of one Entitlement. + * + * [Unavailable] says Mosaic could not answer — billing disabled for the Environment, a projection + * failure, an outage. It is a service state and can never appear inside an accepted snapshot; it is + * produced only at read time. [Unknown] says Mosaic looked and is not confident. Neither is ever + * reported as [Inactive], which is a claim about a person. + */ +sealed interface MosaicCustomerEntitlementState { + val explanation: MosaicCustomerEntitlementExplanation + + data class Active( + override val explanation: MosaicCustomerEntitlementExplanation, + val effectiveStart: String?, + /** + * Present only when [endKnown] is true. `endKnown == true` with a null end means the + * Entitlement is permanent, which is why a reader must branch on both members and never + * render "expires" from a null end. + */ + val effectiveEnd: String?, + val endKnown: Boolean, + /** True while access is being served from a cache past `validUntil` under bounded grace. */ + val isStale: Boolean = false, + ) : MosaicCustomerEntitlementState + + data class Inactive( + override val explanation: MosaicCustomerEntitlementExplanation, + ) : MosaicCustomerEntitlementState + + data class Unknown( + override val explanation: MosaicCustomerEntitlementExplanation, + val uncertainty: MosaicCustomerUncertainty, + ) : MosaicCustomerEntitlementState + + data class Unavailable( + override val explanation: MosaicCustomerEntitlementExplanation, + val uncertainty: MosaicCustomerUncertainty, + ) : MosaicCustomerEntitlementState +} + +enum class MosaicCustomerSourceType(val wireName: String) { + ACTIVE_SUBSCRIPTION("active_subscription"), + TRIAL("trial"), + GRACE_PERIOD("grace_period"), + BILLING_RETRY("billing_retry"), + ONE_TIME_NON_CONSUMABLE("one_time_non_consumable"), + FAMILY_SHARED("family_shared"), + ; + + internal companion object { + private val byWireName = entries.associateBy(MosaicCustomerSourceType::wireName) + fun from(value: String): MosaicCustomerSourceType? = byWireName[value] + } +} + +enum class MosaicCustomerSourceState(val wireName: String) { + GRANTING("granting"), + NOT_GRANTING("not_granting"), + UNKNOWN("unknown"), + ; + + internal companion object { + private val byWireName = entries.associateBy(MosaicCustomerSourceState::wireName) + fun from(value: String): MosaicCustomerSourceState? = byWireName[value] + } +} + +enum class MosaicCustomerStorePlatform(val wireName: String) { + APPLE_APP_STORE("apple_app_store"), + GOOGLE_PLAY("google_play"), + ; + + internal companion object { + private val byWireName = entries.associateBy(MosaicCustomerStorePlatform::wireName) + fun from(value: String): MosaicCustomerStorePlatform? = byWireName[value] + } +} + +/** + * One reason a Billing Customer holds, or may hold, access. + * + * Mosaic Product and Subscription Instance identity live here and nowhere else. Duplicating them + * onto the entry would create two places that can disagree when several sources grant one + * Entitlement, and the entry is the one a reader trusts. + */ +data class MosaicCustomerEntitlementSource( + val sourceId: String, + val sourceType: MosaicCustomerSourceType, + val subscriptionInstanceId: String?, + val oneTimePurchaseInstanceId: String?, + val mosaicProductId: String, + val grantVersionId: String, + val sourceSnapshotId: String, + val storePlatform: MosaicCustomerStorePlatform?, + val start: String, + val end: String?, + val sourceState: MosaicCustomerSourceState, + val uncertainty: MosaicCustomerUncertainty, + val explanationCode: MosaicCustomerEntitlementExplanationCode, + /** + * True for an Apple sandbox transaction or a Google Play license-tester purchase. On Google this + * flag is the only thing separating a test grant from a paid one, so every surface reports it. + */ + val isTestSource: Boolean, +) + +data class MosaicCustomerEntitlementEntry( + val entitlementId: String, + val entitlementKey: String, + val state: MosaicCustomerEntitlementState, + val sourceIds: List, + val sourceCount: Int, + val refreshRecommendedAt: String?, +) + +enum class MosaicCustomerProjectionState(val wireName: String) { + CURRENT("current"), + PENDING("pending"), + STALE("stale"), + DEGRADED("degraded"), + FAILED("failed"), + ; + + internal companion object { + private val byWireName = entries.associateBy(MosaicCustomerProjectionState::wireName) + fun from(value: String): MosaicCustomerProjectionState? = byWireName[value] + } +} + +data class MosaicCustomerProjectionStatus( + val state: MosaicCustomerProjectionState, + val lastProjectedAt: String, + val pendingFactCount: Int? = null, + val diagnosticCode: String? = null, +) + +enum class MosaicCustomerSnapshotChangeReason(val wireName: String) { + INITIAL_PROJECTION("initial_projection"), + SUBSCRIPTION_STATE_CHANGED("subscription_state_changed"), + SUBSCRIPTION_PERIOD_CHANGED("subscription_period_changed"), + RENEWAL_INTENT_CHANGED("renewal_intent_changed"), + SOURCE_ADDED("source_added"), + SOURCE_ENDED("source_ended"), + REFUND_APPLIED("refund_applied"), + REVOCATION_APPLIED("revocation_applied"), + GRANT_VERSION_CHANGED("grant_version_changed"), + IDENTITY_CHANGED("identity_changed"), + IDENTITY_CONFLICT_OPENED("identity_conflict_opened"), + IDENTITY_CONFLICT_RESOLVED("identity_conflict_resolved"), + PROJECTION_REPLAYED("projection_replayed"), + PROJECTION_RULE_UPGRADED("projection_rule_upgraded"), + PROJECTION_RECOVERED("projection_recovered"), + PROJECTION_FAILED("projection_failed"), + MANUAL_REPROJECTION("manual_reprojection"), + ; + + internal companion object { + private val byWireName = entries.associateBy(MosaicCustomerSnapshotChangeReason::wireName) + fun from(value: String): MosaicCustomerSnapshotChangeReason? = byWireName[value] + } +} + +data class MosaicCustomerEntitlementDiagnostic( + val code: String, + val safeMessage: String, + val severity: String, + val retryable: Boolean, + val correlationId: String, + val retryAfterSeconds: Int? = null, + val recoveryAction: String? = null, +) + +/** + * The freshness window a reader evaluates the cache against. + * + * It is held separately from the snapshot because a `snapshotUnchanged` response slides the window + * without producing a new snapshot: a confirmed-current snapshot must not expire merely because it + * was confirmed instead of resent. + */ +data class MosaicCustomerEntitlementFreshnessWindow( + val issuedAt: String, + val refreshAfter: String, + val validUntil: String, + val staleGraceSeconds: Int, +) { + internal val issuedAtEpochMillis: Long = mosaicContractInstantMillis(issuedAt) + internal val refreshAfterEpochMillis: Long = mosaicContractInstantMillis(refreshAfter) + internal val validUntilEpochMillis: Long = mosaicContractInstantMillis(validUntil) +} + +/** The immutable authoritative view of one Billing Customer's access at one snapshot version. */ +data class MosaicCustomerEntitlementSnapshot( + val snapshotId: String, + val billingCustomerId: String, + val projectId: String, + val environmentId: String, + val snapshotVersion: Long, + val previousSnapshotVersion: Long?, + val projectionRuleVersion: Int, + val asOf: String, + val freshness: MosaicCustomerEntitlementFreshnessWindow, + val entityTag: String, + val contentDigest: String, + val entries: List, + val sources: List, + val projectionStatus: MosaicCustomerProjectionStatus, + val changeReason: MosaicCustomerSnapshotChangeReason, + val correlationId: String, + val diagnostics: List, +) { + internal val asOfEpochMillis: Long = mosaicContractInstantMillis(asOf) + + fun entry(entitlementKey: String): MosaicCustomerEntitlementEntry? = + entries.firstOrNull { it.entitlementKey == entitlementKey } + + fun source(sourceId: String): MosaicCustomerEntitlementSource? = + sources.firstOrNull { it.sourceId == sourceId } +} + +/** The conditional-request answer confirming a cached snapshot is still current. */ +internal data class MosaicCustomerSnapshotUnchanged( + val billingCustomerId: String, + val projectId: String, + val environmentId: String, + val snapshotVersion: Long, + val entityTag: String, + val asOf: String, + val freshness: MosaicCustomerEntitlementFreshnessWindow, + val projectionStatus: MosaicCustomerProjectionStatus, + val correlationId: String, +) + +/** + * How a cached snapshot stands against the device clock. + * + * Clock unreliability is deliberately **not** a member. An unreliable clock forces + * expired-equivalent behaviour and is surfaced through diagnostics, because it describes the device + * rather than the cache and adding it here would make every reader branch on a fifth case whose + * correct handling is identical to [EXPIRED]. + */ +enum class MosaicCustomerEntitlementCacheState(val wireName: String) { + FRESH("fresh"), + REFRESH_RECOMMENDED("refreshRecommended"), + STALE_WITHIN_GRACE("staleWithinGrace"), + EXPIRED("expired"), + MISSING("missing"), + INVALID("invalid"), + DIFFERENT_CUSTOMER("differentCustomer"), +} + +/** Why the authoritative surface cannot state a customer's access. Never "inactive". */ +enum class MosaicCustomerEntitlementUnavailableReason(val wireName: String) { + /** No `customerAccessTokenProvider` was configured, so the feature is inert. */ + NOT_CONFIGURED("customer.entitlements.notConfigured"), + TOKEN_UNAVAILABLE("customer.entitlements.tokenUnavailable"), + UNAUTHORIZED("customer.entitlements.unauthorized"), + TRANSPORT_UNAVAILABLE("customer.entitlements.transportUnavailable"), + SNAPSHOT_REJECTED("customer.entitlements.snapshotRejected"), + CACHE_EXPIRED("customer.entitlements.cacheExpired"), + CLOCK_UNRELIABLE("customer.entitlements.clockUnreliable"), + NEVER_SYNCHRONIZED("customer.entitlements.neverSynchronized"), +} + +/** The observable authoritative state. Identity transitions are visible without stale grants. */ +sealed interface MosaicCustomerEntitlementSnapshotState { + /** No answer yet, or an identity mutation is in progress. Never serves a previous customer. */ + data object Loading : MosaicCustomerEntitlementSnapshotState + + data object SignedOut : MosaicCustomerEntitlementSnapshotState + + data class Available( + val snapshot: MosaicCustomerEntitlementSnapshot, + val cacheState: MosaicCustomerEntitlementCacheState, + ) : MosaicCustomerEntitlementSnapshotState + + data class Unavailable( + val reason: MosaicCustomerEntitlementUnavailableReason, + val lastKnown: MosaicCustomerEntitlementSnapshot? = null, + ) : MosaicCustomerEntitlementSnapshotState +} + +/** Why a document was refused. Every member yields `unknown`, never `inactive`. */ +enum class MosaicCustomerSnapshotRejection(val wireName: String) { + UNSUPPORTED_CONTRACT_VERSION("unsupported_contract_version"), + CUSTOMER_MISMATCH("customer_mismatch"), + PROJECT_MISMATCH("project_mismatch"), + ENVIRONMENT_MISMATCH("environment_mismatch"), + CONTENT_DIGEST_MISMATCH("content_digest_mismatch"), + SNAPSHOT_VERSION_NOT_NEWER("snapshot_version_not_newer"), + AS_OF_REGRESSION("as_of_regression"), + MALFORMED_RECORD("malformed_record"), + WEAK_ENTITY_TAG("weak_entity_tag"), + ; + + /** The one rejection that clears rather than preserves the cache. */ + val clearsCache: Boolean + get() = this == CUSTOMER_MISMATCH || this == PROJECT_MISMATCH || this == ENVIRONMENT_MISMATCH +} + +/** The outcome of one authoritative sync attempt. */ +sealed interface MosaicCustomerEntitlementSyncResult { + data class Updated( + val snapshot: MosaicCustomerEntitlementSnapshot, + val cacheState: MosaicCustomerEntitlementCacheState, + ) : MosaicCustomerEntitlementSyncResult + + /** The server confirmed the cached snapshot; freshness slid, nothing was re-accepted. */ + data class Unchanged( + val snapshot: MosaicCustomerEntitlementSnapshot, + val cacheState: MosaicCustomerEntitlementCacheState, + ) : MosaicCustomerEntitlementSyncResult + + data class Rejected( + val rejection: MosaicCustomerSnapshotRejection, + val lastKnown: MosaicCustomerEntitlementSnapshot?, + ) : MosaicCustomerEntitlementSyncResult + + /** The customer token was refused after exactly one forced-refresh retry. */ + data class Unauthorized(val diagnosticCode: String) : MosaicCustomerEntitlementSyncResult + + data object SignedOut : MosaicCustomerEntitlementSyncResult + + data class Unavailable( + val reason: MosaicCustomerEntitlementUnavailableReason, + val retryAfterSeconds: Int? = null, + ) : MosaicCustomerEntitlementSyncResult +} + +/** The answer to one focused access question. Never a bare boolean. */ +data class MosaicCustomerEntitlementCheck( + val entitlementKey: String, + val state: MosaicCustomerEntitlementState, + val sourceCount: Int, + val snapshotVersion: Long?, + val asOf: String?, + val cacheState: MosaicCustomerEntitlementCacheState, + val isTestSource: Boolean = false, +) + +/** Bounded, secret-free operational view of the authoritative entitlement runtime. */ +data class MosaicCustomerEntitlementDiagnostics( + val configured: Boolean, + val cacheState: MosaicCustomerEntitlementCacheState, + val snapshotVersion: Long?, + val asOf: String?, + val clockUnreliable: Boolean, + val lastRejection: MosaicCustomerSnapshotRejection?, + val lastUnavailableReason: MosaicCustomerEntitlementUnavailableReason?, + val acceptedSnapshotCount: Long, + val rejectedSnapshotCount: Long, +) diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementRuntime.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementRuntime.kt new file mode 100644 index 00000000..8969f790 --- /dev/null +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementRuntime.kt @@ -0,0 +1,938 @@ +package dev.mosaic.sdk + +import com.google.gson.GsonBuilder +import com.google.gson.JsonParser +import java.text.ParseException +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Authoritative Entitlement Contract 1 reader policy. + * + * Everything above the runtime class in this file is a pure function of its inputs, because both + * policies are pinned by cross-implementation reference vectors that Go, Dart, Swift, and Kotlin + * must all satisfy identically. Keeping them free of clocks, files, and coroutines is what lets the + * JVM suite drive the vector tables directly rather than through a simulated device. + */ + +/** RFC 3339 UTC with exactly three fractional digits and a literal Z. Nothing else is admissible. */ +private val MOSAIC_CONTRACT_TIMESTAMP = + Regex("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$") + +/** + * Parses a contract timestamp, rejecting any other precision. + * + * The precision is not cosmetic: `contentDigest` is computed over the canonical serialization, so + * the same instant written with two-digit or six-digit fractions digests differently and would make + * two producers of identical state disagree. + */ +internal fun mosaicContractInstantMillis(value: String): Long { + require(MOSAIC_CONTRACT_TIMESTAMP.matches(value)) { + "A Mosaic contract timestamp must have exactly three fractional digits and a literal Z." + } + // Parsed with the same `SimpleDateFormat` the rest of the SDK uses rather than `java.time`, + // which needs API 26 or desugaring; Mosaic supports API 24 without either. The shape is already + // fixed by the pattern above, so this call only has to reject impossible dates. + return try { + mosaicAnalyticsTimestampMillis(value) + } catch (cause: ParseException) { + throw IllegalArgumentException("A Mosaic contract timestamp must be a valid UTC instant.", cause) + } +} + +/** The binding and ordering members the acceptance gate compares. Nothing else participates. */ +internal data class MosaicCustomerSnapshotBinding( + val contractVersion: String, + val billingCustomerId: String, + val projectId: String, + val environmentId: String, + val snapshotVersion: Long, + val asOfEpochMillis: Long, + val contentDigestValid: Boolean, +) + +internal enum class MosaicCustomerCacheAction { REPLACE, PRESERVE, CLEAR } + +internal data class MosaicCustomerCacheDecision( + val accepted: Boolean, + /** Exactly the reason vocabulary of `entitlement-cache-decision-vectors.json`. */ + val reason: String, + val action: MosaicCustomerCacheAction, + val rejection: MosaicCustomerSnapshotRejection?, +) + +/** + * The cache acceptance gate. + * + * The check order is normative and the vectors pin it. Binding is compared **before** version for a + * specific reason: snapshot versions are monotonic per customer *per Environment*, so a staging + * snapshot legitimately starts at 1. Diagnosing that as a version regression would be the wrong + * diagnosis and would preserve a production cache under a staging identity. A binding mismatch is + * also the one rejection that clears rather than preserves, because continuing to serve the previous + * customer's access after an identity change is precisely the leak this rule exists to prevent. + * + * Acceptance is atomic. A rejected document contributes nothing: the reader never keeps the entries + * it understood from a record it refused. + */ +internal object MosaicCustomerEntitlementAcceptance { + const val SUPPORTED_CONTRACT_VERSION: String = "1" + + fun decide( + cached: MosaicCustomerSnapshotBinding?, + incoming: MosaicCustomerSnapshotBinding, + ): MosaicCustomerCacheDecision { + // 1. Exact-match reading. A "2" document is as unreadable to a "1" reader as "9.9" would be; + // numeric ordering never implies support. This runs first because a document in an + // unknown version cannot be trusted to have interpretable binding fields. + if (incoming.contractVersion != SUPPORTED_CONTRACT_VERSION) { + return reject( + "unsupported_contract_version", + MosaicCustomerCacheAction.PRESERVE, + MosaicCustomerSnapshotRejection.UNSUPPORTED_CONTRACT_VERSION, + ) + } + + // 2. Customer binding, across all three members. Each clears the cache. + if (cached != null) { + if (cached.billingCustomerId != incoming.billingCustomerId) { + return reject( + "customer_mismatch", + MosaicCustomerCacheAction.CLEAR, + MosaicCustomerSnapshotRejection.CUSTOMER_MISMATCH, + ) + } + if (cached.projectId != incoming.projectId) { + return reject( + "project_mismatch", + MosaicCustomerCacheAction.CLEAR, + MosaicCustomerSnapshotRejection.PROJECT_MISMATCH, + ) + } + if (cached.environmentId != incoming.environmentId) { + return reject( + "environment_mismatch", + MosaicCustomerCacheAction.CLEAR, + MosaicCustomerSnapshotRejection.ENVIRONMENT_MISMATCH, + ) + } + } + + // 3. Corruption in transit or at rest. The snapshot is discarded whole, never partially + // applied, and the last good cache stands. + if (!incoming.contentDigestValid) { + return reject( + "content_digest_mismatch", + MosaicCustomerCacheAction.PRESERVE, + MosaicCustomerSnapshotRejection.CONTENT_DIGEST_MISMATCH, + ) + } + + if (cached == null) { + return MosaicCustomerCacheDecision( + accepted = true, + reason = "no_cached_snapshot", + action = MosaicCustomerCacheAction.REPLACE, + rejection = null, + ) + } + + // 4. Equal is not newer. A snapshotUnchanged record confirms the current snapshot and slides + // freshness without re-accepting anything, so "accepted" always means the state advanced. + if (incoming.snapshotVersion <= cached.snapshotVersion) { + return reject( + "snapshot_version_not_newer", + MosaicCustomerCacheAction.PRESERVE, + MosaicCustomerSnapshotRejection.SNAPSHOT_VERSION_NOT_NEWER, + ) + } + + // 5. A higher version evaluated at an earlier instant means the server projected from a + // stale read: the version would move forward while the evidence moved backward. + if (incoming.asOfEpochMillis < cached.asOfEpochMillis) { + return reject( + "as_of_regression", + MosaicCustomerCacheAction.PRESERVE, + MosaicCustomerSnapshotRejection.AS_OF_REGRESSION, + ) + } + + return MosaicCustomerCacheDecision( + accepted = true, + reason = "newer_snapshot_version", + action = MosaicCustomerCacheAction.REPLACE, + rejection = null, + ) + } + + private fun reject( + reason: String, + action: MosaicCustomerCacheAction, + rejection: MosaicCustomerSnapshotRejection, + ) = MosaicCustomerCacheDecision(accepted = false, reason = reason, action = action, rejection = rejection) +} + +/** How a cached snapshot stands against the device clock, plus whether that clock can be trusted. */ +internal data class MosaicCustomerFreshnessEvaluation( + val state: MosaicCustomerEntitlementCacheState, + val clockUnreliable: Boolean, +) + +/** + * The offline access policy. + * + * One interface with one shipped implementation, so the policy is a single named object rather than + * a decision scattered across the runtime. Bounded grace is the shipped policy (OD-5); a strict + * policy is the same fields with a grace window of zero, which is why there is no separate mode. + */ +internal fun interface MosaicCustomerOfflinePolicy { + fun evaluate( + window: MosaicCustomerEntitlementFreshnessWindow, + deviceNowEpochMillis: Long, + ): MosaicCustomerFreshnessEvaluation +} + +internal object MosaicCustomerBoundedGracePolicy : MosaicCustomerOfflinePolicy { + /** + * Applied in the direction that favours the user: a boundary is crossed only once the device + * clock exceeds it by more than the tolerance. An implementation comparing boundaries exactly + * flaps between two states for every device whose clock is a few seconds fast. + */ + const val CLOCK_SKEW_TOLERANCE_SECONDS: Long = 60 + + private const val TOLERANCE_MILLIS = CLOCK_SKEW_TOLERANCE_SECONDS * 1_000 + + override fun evaluate( + window: MosaicCustomerEntitlementFreshnessWindow, + deviceNowEpochMillis: Long, + ): MosaicCustomerFreshnessEvaluation { + // A device claiming a time meaningfully before issuance cannot measure this cache's age. A + // naive implementation computes a negative age, concludes "fresh", and hands unlimited + // offline access to anyone willing to move their clock back. Unreliability is not a fifth + // state: it forces expired-equivalent behaviour and is reported as a diagnostic. + if (deviceNowEpochMillis < window.issuedAtEpochMillis - TOLERANCE_MILLIS) { + return MosaicCustomerFreshnessEvaluation( + MosaicCustomerEntitlementCacheState.EXPIRED, + clockUnreliable = true, + ) + } + + val graceMillis = window.staleGraceSeconds.toLong() * 1_000 + val state = when { + deviceNowEpochMillis <= window.refreshAfterEpochMillis + TOLERANCE_MILLIS -> + MosaicCustomerEntitlementCacheState.FRESH + deviceNowEpochMillis <= window.validUntilEpochMillis + TOLERANCE_MILLIS -> + MosaicCustomerEntitlementCacheState.REFRESH_RECOMMENDED + // With a grace window of zero this band is empty, so validUntil is a hard edge. + deviceNowEpochMillis <= window.validUntilEpochMillis + graceMillis + TOLERANCE_MILLIS -> + MosaicCustomerEntitlementCacheState.STALE_WITHIN_GRACE + else -> MosaicCustomerEntitlementCacheState.EXPIRED + } + return MosaicCustomerFreshnessEvaluation(state, clockUnreliable = false) + } +} + +/** Device time Mosaic is willing to measure a cache age against. Null means "cannot be trusted". */ +internal fun interface MosaicCustomerTrustedTime { + fun nowEpochMillis(): Long? +} + +/** + * The authoritative entitlement runtime. + * + * It owns exactly one piece of state — the accepted snapshot and the window governing it — and + * publishes it as a [StateFlow] so a Compose host observes identity transitions rather than + * polling. `Loading` and `SignedOut` are explicit states for that reason: an identity change must be + * *observable* without ever emitting the previous customer's grants, and a nullable snapshot cannot + * express the difference between "no answer yet" and "no customer". + * + * Nothing here reports `inactive` from a failure. A rejection, a transport error, an unreadable + * clock, and an expired cache all produce `unknown`, because a reader that collapses "I could not + * find out" into "you do not have it" turns every outage into a mass revocation experienced by + * paying customers. + */ +class MosaicCustomerEntitlementRuntime internal constructor( + private val transport: MosaicCustomerEntitlementTransport, + private val cache: MosaicCustomerEntitlementCache, + private val session: MosaicCustomerTokenSession, + private val trustedTime: MosaicCustomerTrustedTime, + private val diagnostics: MosaicDiagnosticSink = MosaicDiagnosticSink.None, + private val policy: MosaicCustomerOfflinePolicy = MosaicCustomerBoundedGracePolicy, + private val correlationId: () -> String = { "customer-sync-" + UUID.randomUUID() }, +) { + private val state = MutableStateFlow( + MosaicCustomerEntitlementSnapshotState.Loading, + ) + + /** The observable authoritative state, with the current value replayed to every new collector. */ + val customerEntitlements: StateFlow = state.asStateFlow() + + private val stateMutex = Mutex() + private val identityMutationLock = Mutex() + + private var accepted: MosaicCachedCustomerEntitlements? = null + private var bindingDigest: String? = null + private var identityGeneration = 0 + private var inFlight: CompletableDeferred? = null + + private var acceptedCount = 0L + private var rejectedCount = 0L + private var lastRejection: MosaicCustomerSnapshotRejection? = null + private var lastUnavailableReason: MosaicCustomerEntitlementUnavailableReason? = null + private var clockUnreliable = false + + // ------------------------------------------------------------------------------------------ + // Reading + // ------------------------------------------------------------------------------------------ + + /** + * Answers one focused access question, never as a boolean. + * + * An absent entry in an accepted snapshot is a real `inactive`: Mosaic looked and found no + * qualifying source. An absent *snapshot* is not, and yields `unknown`. + */ + suspend fun checkCustomerEntitlement(entitlementKey: String): MosaicCustomerEntitlementCheck { + loadCacheIfNeeded() + return stateMutex.withLock { currentCheck(entitlementKey) } + } + + suspend fun customerEntitlementDiagnostics(): MosaicCustomerEntitlementDiagnostics { + loadCacheIfNeeded() + return stateMutex.withLock { + val cached = accepted + val evaluation = cached?.let(::evaluate) + MosaicCustomerEntitlementDiagnostics( + configured = true, + cacheState = evaluation?.state ?: MosaicCustomerEntitlementCacheState.MISSING, + snapshotVersion = cached?.snapshot?.snapshotVersion, + asOf = cached?.snapshot?.asOf, + clockUnreliable = evaluation?.clockUnreliable ?: clockUnreliable, + lastRejection = lastRejection, + lastUnavailableReason = lastUnavailableReason, + acceptedSnapshotCount = acceptedCount, + rejectedSnapshotCount = rejectedCount, + ) + } + } + + // ------------------------------------------------------------------------------------------ + // Synchronizing + // ------------------------------------------------------------------------------------------ + + /** + * Synchronizes with Mosaic, collapsing concurrent callers onto one request. + * + * The deduplication is the same shape as the token session's, and for the same reason: a + * foreground transition, a purchase completion, and a host-initiated refresh routinely coincide, + * and three identical conditional reads help nobody. + */ + suspend fun refreshCustomerEntitlements(): MosaicCustomerEntitlementSyncResult { + loadCacheIfNeeded() + var owned: CompletableDeferred? = null + val deferred = stateMutex.withLock { + inFlight ?: CompletableDeferred() + .also { inFlight = it; owned = it } + } + val own = owned ?: return deferred.await() + + val generation = stateMutex.withLock { identityGeneration } + val result = runCatching { sync(generation) }.getOrElse { + MosaicCustomerEntitlementSyncResult.Unavailable( + MosaicCustomerEntitlementUnavailableReason.TRANSPORT_UNAVAILABLE, + ) + } + stateMutex.withLock { if (inFlight === own) inFlight = null } + own.complete(result) + return result + } + + private suspend fun sync(generation: Int): MosaicCustomerEntitlementSyncResult { + val issued = when (val token = session.token()) { + is MosaicCustomerAccessTokenResult.Issued -> token + MosaicCustomerAccessTokenResult.SignedOut -> return signedOut(generation) + is MosaicCustomerAccessTokenResult.Unavailable -> return unavailable( + generation, + MosaicCustomerEntitlementUnavailableReason.TOKEN_UNAVAILABLE, + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_TOKEN_UNAVAILABLE, + token.retryAfterSeconds, + ) + } + + val cached = stateMutex.withLock { accepted } + val body = MosaicCustomerEntitlementCodec.encodeSyncRequest( + correlationId = correlationId(), + knownSnapshotVersion = cached?.snapshot?.snapshotVersion, + entityTag = cached?.snapshot?.entityTag, + requestedEntitlementKeys = emptyList(), + ) + + var response = transport.sync(issued.token, body) + if (response is MosaicCustomerEntitlementTransportResult.Unauthorized) { + // Exactly one retry per attempt, after a forced refresh. More would turn a revoked + // token into a retry storm against the host's backend; fewer would make every ordinary + // token expiry look like a sign-out. + session.invalidate(issued.token) + response = when (val refreshed = session.token(forceRefresh = true)) { + is MosaicCustomerAccessTokenResult.Issued -> transport.sync(refreshed.token, body) + MosaicCustomerAccessTokenResult.SignedOut -> return signedOut(generation) + is MosaicCustomerAccessTokenResult.Unavailable -> return unavailable( + generation, + MosaicCustomerEntitlementUnavailableReason.TOKEN_UNAVAILABLE, + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_TOKEN_UNAVAILABLE, + refreshed.retryAfterSeconds, + ) + } + if (response is MosaicCustomerEntitlementTransportResult.Unauthorized) { + // A second refusal is a real authorization answer. The state is published as + // unavailable — never inactive — and the customer is neither switched nor cleared: + // the host's backend, not this SDK, decides who this device is. + unavailable( + generation, + MosaicCustomerEntitlementUnavailableReason.UNAUTHORIZED, + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_UNAUTHORIZED, + ) + return MosaicCustomerEntitlementSyncResult.Unauthorized( + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_UNAUTHORIZED.wireName, + ) + } + } + + return when (response) { + is MosaicCustomerEntitlementTransportResult.Record -> acceptRecord(generation, response) + // Preserve, slide nothing. The cache keeps running out its own clock. + is MosaicCustomerEntitlementTransportResult.NotModified -> preserveCache(generation) + is MosaicCustomerEntitlementTransportResult.Unauthorized -> unavailable( + generation, + MosaicCustomerEntitlementUnavailableReason.UNAUTHORIZED, + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_UNAUTHORIZED, + ) + is MosaicCustomerEntitlementTransportResult.Unavailable -> unavailable( + generation, + MosaicCustomerEntitlementUnavailableReason.TRANSPORT_UNAVAILABLE, + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_TRANSPORT_FAILED, + response.retryAfterSeconds, + ) + } + } + + private suspend fun acceptRecord( + generation: Int, + response: MosaicCustomerEntitlementTransportResult.Record, + ): MosaicCustomerEntitlementSyncResult { + when (val decoded = MosaicCustomerEntitlementCodec.decodeRecord(response.body)) { + is MosaicCustomerRecordDecoding.Unreadable -> return reject(generation, decoded.rejection) + // The unchanged answer is a contract record, not an HTTP status. It carries its own + // refreshed window, so the confirmation and the freshness it grants are one document + // that the content digest and the schema both cover. + is MosaicCustomerRecordDecoding.Unchanged -> return confirmCache(generation, decoded.unchanged) + is MosaicCustomerRecordDecoding.Snapshot -> { + val snapshot = decoded.snapshot + // The HTTP validator must identify the record it accompanies. A weak validator is + // discarded by the transport, so a missing tag here means the response could not be + // conditionally revalidated and its identity is unproven. + if (response.entityTag == null || response.entityTag != snapshot.entityTag) { + return reject(generation, MosaicCustomerSnapshotRejection.WEAK_ENTITY_TAG) + } + val cached = stateMutex.withLock { accepted } + val decision = MosaicCustomerEntitlementAcceptance.decide( + cached = cached?.let(::binding), + incoming = MosaicCustomerSnapshotBinding( + contractVersion = MosaicCustomerEntitlementCodec.CONTRACT_VERSION, + billingCustomerId = snapshot.billingCustomerId, + projectId = snapshot.projectId, + environmentId = snapshot.environmentId, + snapshotVersion = snapshot.snapshotVersion, + asOfEpochMillis = snapshot.asOfEpochMillis, + contentDigestValid = decoded.contentDigestValid, + ), + ) + if (!decision.accepted) { + return reject(generation, decision.rejection ?: MosaicCustomerSnapshotRejection.MALFORMED_RECORD) + } + + val entry = MosaicCachedCustomerEntitlements(snapshot, snapshot.freshness) + return stateMutex.withLock { + // A snapshot that arrived for the identity we were signed in as when the request + // started is discarded after a logout or an identity change: it is not this + // customer's, and emitting it is precisely the leak the binding rules exist for. + if (generation != identityGeneration) { + return@withLock MosaicCustomerEntitlementSyncResult.Rejected( + MosaicCustomerSnapshotRejection.CUSTOMER_MISMATCH, + null, + ) + } + persist(entry, response.body) + accepted = entry + acceptedCount += 1 + val evaluation = evaluate(entry) + publish(entry, evaluation) + MosaicCustomerEntitlementSyncResult.Updated(entry.snapshot, evaluation.state) + } + } + } + } + + /** + * Keeps the cache exactly as it is. + * + * The window is untouched, so a device answered only by intermediaries still expires on + * schedule: staying offline-valid requires an answer Mosaic actually produced. + */ + private suspend fun preserveCache(generation: Int): MosaicCustomerEntitlementSyncResult = + stateMutex.withLock { + if (generation != identityGeneration) { + return@withLock MosaicCustomerEntitlementSyncResult.Rejected( + MosaicCustomerSnapshotRejection.CUSTOMER_MISMATCH, + null, + ) + } + val cached = accepted ?: return@withLock unavailableLocked( + MosaicCustomerEntitlementUnavailableReason.NEVER_SYNCHRONIZED, + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_TRANSPORT_FAILED, + ) + val evaluation = evaluate(cached) + publish(cached, evaluation) + MosaicCustomerEntitlementSyncResult.Unchanged(cached.snapshot, evaluation.state) + } + + /** + * A confirmed-current snapshot: freshness slides, nothing is re-accepted, nothing new is emitted. + * + * The confirmation is checked against the cache it claims to confirm. An unchanged record for a + * different customer, Project, Environment, or version is not a confirmation of anything this + * device holds, and sliding a window on its say-so would extend offline access on the strength + * of a record about somebody else. + */ + private suspend fun confirmCache( + generation: Int, + unchanged: MosaicCustomerSnapshotUnchanged, + ): MosaicCustomerEntitlementSyncResult = stateMutex.withLock { + if (generation != identityGeneration) { + return@withLock MosaicCustomerEntitlementSyncResult.Rejected( + MosaicCustomerSnapshotRejection.CUSTOMER_MISMATCH, + null, + ) + } + val cached = accepted + // A 304 without a cache is a protocol violation, not evidence of anything about a + // customer, so it reports "never synchronized" rather than a state. + ?: return@withLock unavailableLocked( + MosaicCustomerEntitlementUnavailableReason.NEVER_SYNCHRONIZED, + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_TRANSPORT_FAILED, + ) + val confirmsCache = unchanged.billingCustomerId == cached.snapshot.billingCustomerId && + unchanged.projectId == cached.snapshot.projectId && + unchanged.environmentId == cached.snapshot.environmentId && + unchanged.snapshotVersion == cached.snapshot.snapshotVersion && + unchanged.entityTag == cached.snapshot.entityTag + if (!confirmsCache) { + rejectedCount += 1 + lastRejection = MosaicCustomerSnapshotRejection.CUSTOMER_MISMATCH + diagnostics.record( + MosaicDiagnostic( + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_SNAPSHOT_REJECTED, + "An unchanged record did not identify the cached snapshot; freshness was not slid.", + ), + ) + publish(cached, evaluate(cached)) + return@withLock MosaicCustomerEntitlementSyncResult.Rejected( + MosaicCustomerSnapshotRejection.CUSTOMER_MISMATCH, + cached.snapshot, + ) + } + val slid = MosaicCachedCustomerEntitlements( + cached.snapshot, + MosaicCustomerEntitlementFreshnessWindow( + // Issuance stays the cached snapshot's own: the confirmation extends how long the + // snapshot may be served, it does not restate when the snapshot was produced. + issuedAt = cached.window.issuedAt, + refreshAfter = unchanged.freshness.refreshAfter, + validUntil = unchanged.freshness.validUntil, + staleGraceSeconds = unchanged.freshness.staleGraceSeconds, + ), + ) + accepted = slid + persist(slid, null) + val evaluation = evaluate(slid) + publish(slid, evaluation) + MosaicCustomerEntitlementSyncResult.Unchanged(slid.snapshot, evaluation.state) + } + + private suspend fun reject( + generation: Int, + rejection: MosaicCustomerSnapshotRejection, + ): MosaicCustomerEntitlementSyncResult = stateMutex.withLock { + rejectedCount += 1 + lastRejection = rejection + if (rejection.clearsCache) { + // The one rejection that clears. Continuing to serve the previous customer's access + // after an identity change is the leak this rule exists to prevent, and it is severe + // enough to be reported as an error rather than a note. + diagnostics.record( + MosaicDiagnostic( + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_BINDING_MISMATCH, + "A Customer Entitlement Snapshot was bound to a different customer, Project, or " + + "Environment. The cached snapshot was cleared and access is reported as unknown.", + ), + ) + bindingDigest?.let(cache::clear) + cache.clearAll() + accepted = null + lastUnavailableReason = MosaicCustomerEntitlementUnavailableReason.SNAPSHOT_REJECTED + state.value = MosaicCustomerEntitlementSnapshotState.Unavailable( + MosaicCustomerEntitlementUnavailableReason.SNAPSHOT_REJECTED, + ) + return@withLock MosaicCustomerEntitlementSyncResult.Rejected(rejection, null) + } + diagnostics.record( + MosaicDiagnostic( + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_SNAPSHOT_REJECTED, + "A Customer Entitlement Snapshot was rejected (${rejection.wireName}). The previously " + + "accepted snapshot is preserved and access is never reported as inactive.", + ), + ) + // A rejected record contributes nothing: not one entry, not one field. The cache stands. + val cached = accepted + if (cached == null) { + lastUnavailableReason = MosaicCustomerEntitlementUnavailableReason.SNAPSHOT_REJECTED + state.value = MosaicCustomerEntitlementSnapshotState.Unavailable( + MosaicCustomerEntitlementUnavailableReason.SNAPSHOT_REJECTED, + ) + } else { + publish(cached, evaluate(cached)) + } + MosaicCustomerEntitlementSyncResult.Rejected(rejection, cached?.snapshot) + } + + private suspend fun signedOut(generation: Int): MosaicCustomerEntitlementSyncResult = + stateMutex.withLock { + if (generation == identityGeneration) { + accepted = null + state.value = MosaicCustomerEntitlementSnapshotState.SignedOut + } + MosaicCustomerEntitlementSyncResult.SignedOut + } + + private suspend fun unavailable( + generation: Int, + reason: MosaicCustomerEntitlementUnavailableReason, + code: MosaicDiagnosticCode, + retryAfterSeconds: Int? = null, + ): MosaicCustomerEntitlementSyncResult = stateMutex.withLock { + if (generation != identityGeneration) { + return@withLock MosaicCustomerEntitlementSyncResult.Unavailable(reason, retryAfterSeconds) + } + unavailableLocked(reason, code, retryAfterSeconds) + } + + private fun unavailableLocked( + reason: MosaicCustomerEntitlementUnavailableReason, + code: MosaicDiagnosticCode, + retryAfterSeconds: Int? = null, + ): MosaicCustomerEntitlementSyncResult { + lastUnavailableReason = reason + diagnostics.record( + MosaicDiagnostic(code, "Mosaic could not confirm authoritative entitlements (${reason.wireName})."), + ) + // A failure to reach Mosaic never revokes a valid cache: the cached snapshot keeps serving + // for as long as its own window says it may. + val cached = accepted + if (cached != null) { + publish(cached, evaluate(cached)) + } else { + state.value = MosaicCustomerEntitlementSnapshotState.Unavailable(reason) + } + return MosaicCustomerEntitlementSyncResult.Unavailable(reason, retryAfterSeconds) + } + + // ------------------------------------------------------------------------------------------ + // Identity + // ------------------------------------------------------------------------------------------ + + /** + * Binds this device to a Billing Customer the host's backend has authenticated. + * + * The order is the whole safety argument: bump the generation so anything in flight is orphaned, + * publish `Loading` **before** reading anything so no stale grant is ever observable across the + * transition, swap the token, isolate the on-device directory, and only then sync. The Phase 6 + * installation identity is untouched — a person signing in is not a new installation. + */ + suspend fun identifyCustomer(billingCustomerId: String): MosaicCustomerEntitlementSyncResult { + require(billingCustomerId.isNotBlank()) { "A Billing Customer identifier must not be blank." } + identityMutationLock.withLock { + val digest = MosaicCustomerEntitlementCache.bindingDigest(billingCustomerId) + stateMutex.withLock { + identityGeneration += 1 + inFlight?.complete(MosaicCustomerEntitlementSyncResult.SignedOut) + inFlight = null + accepted = null + state.value = MosaicCustomerEntitlementSnapshotState.Loading + if (bindingDigest != digest) { + cache.retainOnly(digest) + cache.writePointer(digest) + } + bindingDigest = digest + cacheLoaded = false + } + session.reset(billingCustomerId) + } + return refreshCustomerEntitlements() + } + + /** + * Signs the customer out. + * + * Everything the previous customer's snapshot could tell a subsequent user is deleted, not + * merely hidden: "unreachable but present" is still a readable record of what somebody paid for + * on a device they may have handed to someone else. + */ + suspend fun signOutCustomer() { + identityMutationLock.withLock { + stateMutex.withLock { + identityGeneration += 1 + inFlight?.complete(MosaicCustomerEntitlementSyncResult.SignedOut) + inFlight = null + accepted = null + bindingDigest = null + cacheLoaded = true + state.value = MosaicCustomerEntitlementSnapshotState.SignedOut + cache.clearAll() + } + session.clear() + } + } + + // ------------------------------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------------------------------ + + private var cacheLoaded = false + + private suspend fun loadCacheIfNeeded() { + stateMutex.withLock { + if (cacheLoaded) return@withLock + cacheLoaded = true + val digest = bindingDigest ?: cache.pointer() ?: return@withLock + bindingDigest = digest + // No cache for this identity yet. The state stays `Loading` rather than becoming + // unavailable: nothing has failed, Mosaic simply has not answered about this person + // before, and publishing a failure here would make every first sign-in look like one. + val stored = cache.read(digest) ?: return@withLock + val decoded = MosaicCustomerEntitlementCodec.decodeCacheRecord(stored) + if (decoded == null) { + // Truncated or tampered. It is discarded whole rather than partially read, and the + // result is unknown rather than inactive. + rejectedCount += 1 + diagnostics.record( + MosaicDiagnostic( + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_CACHE_INVALID, + "The cached Customer Entitlement Snapshot could not be read and was discarded.", + ), + ) + cache.clear(digest) + state.value = MosaicCustomerEntitlementSnapshotState.Unavailable( + MosaicCustomerEntitlementUnavailableReason.SNAPSHOT_REJECTED, + ) + return@withLock + } + accepted = decoded + publish(decoded, evaluate(decoded)) + } + } + + private fun persist(entry: MosaicCachedCustomerEntitlements, record: String?) { + val digest = bindingDigest + ?: MosaicCustomerEntitlementCache.bindingDigest(entry.snapshot.billingCustomerId) + .also { bindingDigest = it; runCatching { cache.writePointer(it) } } + // A 304 slides the window without carrying a record, so the stored document is re-encoded + // from what is already on disk rather than reconstructed from the decoded model — a + // reconstruction could not reproduce the exact bytes the contentDigest was computed over. + val source = record ?: storedRecord(digest) ?: return + runCatching { + cache.write(digest, MosaicCustomerEntitlementCodec.encodeCacheRecord(source, entry.window)) + } + } + + private fun storedRecord(digest: String): String? = cache.read(digest)?.let { stored -> + runCatching { + val root = JsonParser.parseString(stored).asJsonObject + GsonBuilder().disableHtmlEscaping().create().toJson(root.getAsJsonObject("body").get("record")) + }.getOrNull() + } + + private fun binding(entry: MosaicCachedCustomerEntitlements) = MosaicCustomerSnapshotBinding( + contractVersion = MosaicCustomerEntitlementCodec.CONTRACT_VERSION, + billingCustomerId = entry.snapshot.billingCustomerId, + projectId = entry.snapshot.projectId, + environmentId = entry.snapshot.environmentId, + snapshotVersion = entry.snapshot.snapshotVersion, + asOfEpochMillis = entry.snapshot.asOfEpochMillis, + contentDigestValid = true, + ) + + private fun evaluate(entry: MosaicCachedCustomerEntitlements): MosaicCustomerFreshnessEvaluation { + // No trusted instant means the cache's age cannot be measured at all, which is the same + // safety position as an expired cache: unknown, never inactive, never silently served. + val now = trustedTime.nowEpochMillis() + ?: return MosaicCustomerFreshnessEvaluation( + MosaicCustomerEntitlementCacheState.EXPIRED, + clockUnreliable = true, + ) + return policy.evaluate(entry.window, now) + } + + private fun publish( + entry: MosaicCachedCustomerEntitlements, + evaluation: MosaicCustomerFreshnessEvaluation, + ) { + clockUnreliable = evaluation.clockUnreliable + if (evaluation.clockUnreliable) { + diagnostics.record( + MosaicDiagnostic( + MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_CLOCK_UNRELIABLE, + "The device clock cannot measure the age of the cached snapshot; access is unknown.", + ), + ) + } + state.value = when (evaluation.state) { + MosaicCustomerEntitlementCacheState.FRESH, + MosaicCustomerEntitlementCacheState.REFRESH_RECOMMENDED, + -> MosaicCustomerEntitlementSnapshotState.Available(entry.snapshot, evaluation.state) + MosaicCustomerEntitlementCacheState.STALE_WITHIN_GRACE -> + MosaicCustomerEntitlementSnapshotState.Available( + // Previously active Entitlements stay active inside the grace window and must be + // surfaced as stale, so the flag travels with the entry rather than beside it. + entry.snapshot.markedStale(), + evaluation.state, + ) + else -> { + val reason = if (evaluation.clockUnreliable) { + MosaicCustomerEntitlementUnavailableReason.CLOCK_UNRELIABLE + } else { + MosaicCustomerEntitlementUnavailableReason.CACHE_EXPIRED + } + lastUnavailableReason = reason + MosaicCustomerEntitlementSnapshotState.Unavailable(reason, entry.snapshot) + } + } + } + + private fun currentCheck(entitlementKey: String): MosaicCustomerEntitlementCheck { + // Freshness is re-evaluated against the clock at the moment of the question, not at the + // moment of the last sync. A cache that was fresh when it arrived and has since crossed + // `validUntil` must answer as stale or unknown even though nothing has been fetched since, + // and republishing keeps the observable state and the answer from ever disagreeing. + accepted?.let { publish(it, evaluate(it)) } + val current = state.value + return when (current) { + MosaicCustomerEntitlementSnapshotState.Loading -> unknownCheck( + entitlementKey, + MosaicCustomerEntitlementExplanationCode.PROVIDER_UNAVAILABLE, + MosaicCustomerUncertaintyReason.STALE_VALIDATION, + MosaicCustomerEntitlementCacheState.MISSING, + ) + MosaicCustomerEntitlementSnapshotState.SignedOut -> MosaicCustomerEntitlementCheck( + entitlementKey = entitlementKey, + state = MosaicCustomerEntitlementState.Unavailable( + MosaicCustomerEntitlementExplanation( + MosaicCustomerEntitlementExplanationCode.IDENTITY_UNRESOLVED, + ), + MosaicCustomerUncertainty( + MosaicCustomerUncertaintyReason.IDENTITY_UNRESOLVED, + since = null, + ).definite(), + ), + sourceCount = 0, + snapshotVersion = null, + asOf = null, + cacheState = MosaicCustomerEntitlementCacheState.MISSING, + ) + is MosaicCustomerEntitlementSnapshotState.Unavailable -> unknownCheck( + entitlementKey, + MosaicCustomerEntitlementExplanationCode.PROVIDER_EVIDENCE_STALE, + MosaicCustomerUncertaintyReason.STALE_VALIDATION, + when (current.reason) { + MosaicCustomerEntitlementUnavailableReason.CACHE_EXPIRED, + MosaicCustomerEntitlementUnavailableReason.CLOCK_UNRELIABLE, + -> MosaicCustomerEntitlementCacheState.EXPIRED + MosaicCustomerEntitlementUnavailableReason.SNAPSHOT_REJECTED -> + MosaicCustomerEntitlementCacheState.INVALID + else -> MosaicCustomerEntitlementCacheState.MISSING + }, + snapshotVersion = current.lastKnown?.snapshotVersion, + asOf = current.lastKnown?.asOf, + ) + is MosaicCustomerEntitlementSnapshotState.Available -> { + // The snapshot published under a grace window already carries its staleness on the + // entries, so a check never has to recompute it and the two can never disagree. + val entry = current.snapshot.entry(entitlementKey) + MosaicCustomerEntitlementCheck( + entitlementKey = entitlementKey, + // An absent entry is not a decision. A sync may have been narrowed with + // `requestedEntitlementKeys`, an Entitlement may have been defined after this + // snapshot was projected, or the key may simply be misspelled — and none of + // those is Mosaic saying the customer does not have it. `inactive` is reported + // only when a snapshot carries an entry that says so, so that a typo in a key + // can never silently revoke a paying customer's access. + state = entry?.state ?: MosaicCustomerEntitlementState.Unknown( + MosaicCustomerEntitlementExplanation( + MosaicCustomerEntitlementExplanationCode.NO_QUALIFYING_SOURCE, + ), + MosaicCustomerUncertainty(MosaicCustomerUncertaintyReason.MISSING_FACT).definite(), + ), + sourceCount = entry?.sourceCount ?: 0, + snapshotVersion = current.snapshot.snapshotVersion, + asOf = current.snapshot.asOf, + cacheState = current.cacheState, + isTestSource = entry?.sourceIds.orEmpty() + .mapNotNull(current.snapshot::source) + .any { it.isTestSource }, + ) + } + } + } + + private fun unknownCheck( + entitlementKey: String, + explanation: MosaicCustomerEntitlementExplanationCode, + reason: MosaicCustomerUncertaintyReason, + cacheState: MosaicCustomerEntitlementCacheState, + snapshotVersion: Long? = null, + asOf: String? = null, + ) = MosaicCustomerEntitlementCheck( + entitlementKey = entitlementKey, + state = MosaicCustomerEntitlementState.Unknown( + MosaicCustomerEntitlementExplanation(explanation), + MosaicCustomerUncertainty(reason).definite(), + ), + sourceCount = 0, + snapshotVersion = snapshotVersion, + asOf = asOf, + cacheState = cacheState, + ) +} + +/** A non-definite state must remain explainable, so a locally produced uncertainty states when. */ +private fun MosaicCustomerUncertainty.definite(): MosaicCustomerUncertainty = + if (since != null) this else copy(since = mosaicAnalyticsTimestamp(System.currentTimeMillis())) + +/** + * Marks previously active Entitlements as stale. + * + * A grace-window grant is still a grant, and the contract requires it to be surfaced as stale rather + * than silently served: the host is entitled to decide that an irreversible action needs a + * server-confirmed answer instead. + */ +private fun MosaicCustomerEntitlementSnapshot.markedStale(): MosaicCustomerEntitlementSnapshot = + copy( + entries = entries.map { entry -> + when (val current = entry.state) { + is MosaicCustomerEntitlementState.Active -> entry.copy(state = current.copy(isStale = true)) + else -> entry + } + }, + ) diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementTransport.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementTransport.kt new file mode 100644 index 00000000..52c64822 --- /dev/null +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerEntitlementTransport.kt @@ -0,0 +1,158 @@ +package dev.mosaic.sdk + +import java.io.IOException +import java.net.URI +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +internal sealed interface MosaicCustomerEntitlementTransportResult { + /** + * A contract record: either a `customerEntitlementSnapshot` or a `snapshotUnchanged`. + * + * There is deliberately no separate not-modified member. Conditional revalidation is expressed + * **inside the contract** — the request states `knownSnapshotVersion` and `entityTag`, and the + * server answers with a `snapshotUnchanged` record over `200` — rather than through HTTP + * status codes and side-band headers. One path decodes one document, and freshness always comes + * from the record that carries it, so a proxy that strips or rewrites a header cannot change how + * long a device believes its cache is valid. + */ + data class Record( + val body: String, + val entityTag: String?, + ) : MosaicCustomerEntitlementTransportResult + + /** + * A bare `304` with no body. + * + * Nothing in this SDK asks for one — conditional revalidation lives in the request body — so it + * comes from an intermediary rather than from Mosaic. It is honoured to the extent it can be: + * the cache is preserved, because a 304 is not evidence of a change. It slides **nothing**. Only + * the `snapshotUnchanged` record, which carries refreshed windows Mosaic actually vouched for, + * can extend how long a device serves access offline; letting a cache-layer 304 do it would let + * a proxy grant unconfirmed offline access indefinitely. + */ + data object NotModified : MosaicCustomerEntitlementTransportResult + + /** The customer token was refused. The caller retries exactly once, after a forced refresh. */ + data object Unauthorized : MosaicCustomerEntitlementTransportResult + + data class Unavailable( + val safeCode: String, + val retryAfterSeconds: Int? = null, + ) : MosaicCustomerEntitlementTransportResult +} + +internal fun interface MosaicCustomerEntitlementTransport { + suspend fun sync( + token: MosaicCustomerAccessToken, + requestBody: String, + ): MosaicCustomerEntitlementTransportResult +} + +/** + * The one authenticated read the SDK makes on a customer's behalf. + * + * Two credentials travel together and mean different things: the customer token in `Authorization` + * says *who*, and the public SDK key in `Mosaic-SDK-Key` says *which application build*. The public + * key alone can never select a customer, which is the whole reason Mosaic Billing requires the host + * to run a backend. + */ +internal class MosaicHTTPCustomerEntitlementTransport( + private val configuration: MosaicConfiguration, + private val client: OkHttpClient = OkHttpClient.Builder().callTimeout(15, TimeUnit.SECONDS).build(), +) : MosaicCustomerEntitlementTransport { + @Volatile + private var activeCall: okhttp3.Call? = null + + override suspend fun sync( + token: MosaicCustomerAccessToken, + requestBody: String, + ): MosaicCustomerEntitlementTransportResult = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(configuration.customerEntitlementsURL().toString()) + // The token is a bearer credential. It exists in this request and in memory, and + // nowhere else: it is never persisted, never logged, and never put in a query string + // where proxies and server access logs would record it. + .header("Authorization", "Bearer ${token.value}") + .header("Mosaic-SDK-Key", configuration.apiKey) + .header("Accept", "application/json") + .header("Mosaic-SDK-Platform", "android") + .header("Mosaic-SDK-Version", MOSAIC_ANDROID_SDK_VERSION) + // The sync request is a contract record, so it always has a body and is always a POST. + // Conditional revalidation travels in that body rather than in `If-None-Match`. + .post(requestBody.toRequestBody("application/json".toMediaType())) + .build() + + try { + val call = client.newCall(request).also { activeCall = it } + call.execute().use { response -> + val retryAfter = response.header("Retry-After")?.toLongOrNull()?.coerceIn(1, 300)?.toInt() + val tag = response.header("ETag") + ?.takeIf(::mosaicIsStrongETag) + ?.trim('"') + when { + response.code == 304 -> MosaicCustomerEntitlementTransportResult.NotModified + response.code in 200..299 -> { + val contentType = response.body?.contentType() + if (contentType?.type != "application" || contentType.subtype != "json") { + // A captive portal or a misrouted proxy answers 200 with HTML. Parsing + // that would produce a malformed-record rejection and an alarming + // diagnostic; naming it as a transport failure is the honest report. + return@use MosaicCustomerEntitlementTransportResult.Unavailable( + "customer.entitlements.unexpectedContentType", + ) + } + val body = response.body?.source()?.let { source -> + // Bounded read: an unbounded response body is a memory attack on a + // client that must keep working on a low-end device. + source.request((MosaicCustomerEntitlementCodec.MAX_RECORD_BYTES + 1).toLong()) + source.buffer.snapshot().utf8() + } + if (body == null || + body.toByteArray(Charsets.UTF_8).size > MosaicCustomerEntitlementCodec.MAX_RECORD_BYTES + ) { + MosaicCustomerEntitlementTransportResult.Unavailable( + "customer.entitlements.responseTooLarge", + ) + } else { + MosaicCustomerEntitlementTransportResult.Record(body, tag) + } + } + response.code == 401 || response.code == 403 -> + MosaicCustomerEntitlementTransportResult.Unauthorized + response.code == 429 -> MosaicCustomerEntitlementTransportResult.Unavailable( + "customer.entitlements.rateLimited", + retryAfter, + ) + // A 4xx cannot be fixed by resending the identical request, but it is still + // "Mosaic did not answer", never "this customer has no access". + response.code in 400..499 -> MosaicCustomerEntitlementTransportResult.Unavailable( + "customer.entitlements.requestRejected", + ) + else -> MosaicCustomerEntitlementTransportResult.Unavailable( + "customer.entitlements.serviceUnavailable", + retryAfter, + ) + } + } + } catch (_: IOException) { + MosaicCustomerEntitlementTransportResult.Unavailable("customer.entitlements.serviceUnavailable") + } finally { + activeCall = null + } + } + + fun cancel() { + activeCall?.cancel() + } +} + +private fun MosaicConfiguration.customerEntitlementsURL(): URI { + val base = endpoint ?: URI("https://api.mosaic.dev") + return URI("${base.toString().trimEnd('/')}/v1/sdk/billing/entitlements") +} diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerRestoreSync.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerRestoreSync.kt new file mode 100644 index 00000000..2290df1b --- /dev/null +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/CustomerRestoreSync.kt @@ -0,0 +1,240 @@ +package dev.mosaic.sdk + +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.launch + +/** + * What the native provider restore itself did, independently of what Mosaic could conclude from it. + * + * The two axes are separate because they answer different questions and routinely disagree: a + * native restore can complete perfectly while Mosaic has not yet validated the recovered purchases, + * and reporting that as success would promise an authoritative answer that does not exist yet. + */ +enum class MosaicCustomerRestoreProviderOutcome { + COMPLETED, + NO_PURCHASES_FOUND, + CANCELLED, + FAILED, + UNSUPPORTED, + NOT_ATTEMPTED, +} + +/** The outcome of a restore composed with an authoritative synchronization. */ +sealed interface MosaicCustomerSyncResult { + val providerOutcome: MosaicCustomerRestoreProviderOutcome + + /** + * The only success. It is produced when — and only when — Mosaic accepted a snapshot that + * reflects the recovery, which is what makes the outcome authoritative rather than hopeful. + */ + data class AuthoritativeEntitlementsUpdated( + override val providerOutcome: MosaicCustomerRestoreProviderOutcome, + val snapshot: MosaicCustomerEntitlementSnapshot, + ) : MosaicCustomerSyncResult + + /** + * The device recovered purchases and Mosaic has not confirmed them yet. + * + * This is not a failure and must not be presented as one: validation is asynchronous, and the + * snapshot will advance on its own. It is reported honestly so a host does not tell someone + * their purchase was restored when Mosaic would still answer `unknown`. + */ + data class NativeRecoveryCompleted( + override val providerOutcome: MosaicCustomerRestoreProviderOutcome, + val validationPending: Boolean = true, + ) : MosaicCustomerSyncResult + + data class NoAdditionalPurchases( + override val providerOutcome: MosaicCustomerRestoreProviderOutcome, + ) : MosaicCustomerSyncResult + + data object Cancelled : MosaicCustomerSyncResult { + override val providerOutcome = MosaicCustomerRestoreProviderOutcome.CANCELLED + } + + data class ProviderUnavailable( + override val providerOutcome: MosaicCustomerRestoreProviderOutcome, + val diagnosticCode: String, + ) : MosaicCustomerSyncResult + + data class Failed( + override val providerOutcome: MosaicCustomerRestoreProviderOutcome, + val diagnosticCode: String, + ) : MosaicCustomerSyncResult + + /** The store recovered purchases but Mosaic cannot say whose they are. Never "not entitled". */ + data class CustomerUnavailable( + override val providerOutcome: MosaicCustomerRestoreProviderOutcome, + val reason: MosaicCustomerEntitlementUnavailableReason, + ) : MosaicCustomerSyncResult + + data object SignedOut : MosaicCustomerSyncResult { + override val providerOutcome = MosaicCustomerRestoreProviderOutcome.NOT_ATTEMPTED + } +} + +/** Cross-platform poll bound: three attempts inside roughly six seconds. */ +internal const val MOSAIC_CUSTOMER_RESTORE_POLL_ATTEMPTS = 3 +internal const val MOSAIC_CUSTOMER_RESTORE_POLL_INTERVAL_MILLIS = 2_000L + +/** + * Restores through the provider, then waits briefly for Mosaic to catch up. + * + * The recovery itself is the existing Commerce Provider Contract path — on Google Play, the + * `queryPurchases` recovery — and it is untouched here. Acknowledgement in particular is not + * involved: Google's refund window is governed by the adapter, and nothing in this function delays + * or re-runs it. Observations reach Mosaic through the Transaction Observation runtime that already + * exists, so this function adds a wait, not a second submission path. + * + * The poll is bounded rather than open-ended because validation is genuinely asynchronous. Waiting + * longer would make a restore button feel broken; not waiting at all would report + * `validationPending` for every restore that was about to succeed a second later. + */ +internal suspend fun mosaicRestoreAndSyncCustomerEntitlements( + runtime: MosaicCustomerEntitlementRuntime, + provider: MosaicPurchaseProvider, + pollAttempts: Int = MOSAIC_CUSTOMER_RESTORE_POLL_ATTEMPTS, + pollIntervalMillis: Long = MOSAIC_CUSTOMER_RESTORE_POLL_INTERVAL_MILLIS, +): MosaicCustomerSyncResult { + val versionBefore = (runtime.customerEntitlements.value as? MosaicCustomerEntitlementSnapshotState.Available) + ?.snapshot?.snapshotVersion + + val restore = runCatching { provider.restore() }.getOrElse { + return MosaicCustomerSyncResult.Failed( + MosaicCustomerRestoreProviderOutcome.FAILED, + MosaicDiagnosticCode.RESTORE_FAILED.wireName, + ) + } + + when (val outcome = restore.providerOutcome()) { + MosaicCustomerRestoreProviderOutcome.CANCELLED -> return MosaicCustomerSyncResult.Cancelled + MosaicCustomerRestoreProviderOutcome.FAILED -> return MosaicCustomerSyncResult.Failed( + outcome, + restore.diagnosticCode(), + ) + MosaicCustomerRestoreProviderOutcome.UNSUPPORTED, + MosaicCustomerRestoreProviderOutcome.NOT_ATTEMPTED, + -> return MosaicCustomerSyncResult.ProviderUnavailable(outcome, restore.diagnosticCode()) + MosaicCustomerRestoreProviderOutcome.NO_PURCHASES_FOUND -> { + // Still worth one sync: the store found nothing new on this device, but Mosaic may hold + // a newer projection from another device or from a server-side notification. + val synced = runtime.refreshCustomerEntitlements() + return synced.asUpdated(outcome, versionBefore) + ?: MosaicCustomerSyncResult.NoAdditionalPurchases(outcome) + } + MosaicCustomerRestoreProviderOutcome.COMPLETED -> Unit + } + + repeat(pollAttempts) { attempt -> + val result = runtime.refreshCustomerEntitlements() + when (result) { + is MosaicCustomerEntitlementSyncResult.SignedOut -> return MosaicCustomerSyncResult.SignedOut + is MosaicCustomerEntitlementSyncResult.Unauthorized -> return MosaicCustomerSyncResult.CustomerUnavailable( + MosaicCustomerRestoreProviderOutcome.COMPLETED, + MosaicCustomerEntitlementUnavailableReason.UNAUTHORIZED, + ) + else -> Unit + } + result.asUpdated(MosaicCustomerRestoreProviderOutcome.COMPLETED, versionBefore)?.let { return it } + if (attempt < pollAttempts - 1) delay(pollIntervalMillis) + } + + // The device recovered purchases Mosaic has not validated yet. Saying so is the honest answer; + // claiming a restore would promise an entitlement the SDK has no evidence for. + return MosaicCustomerSyncResult.NativeRecoveryCompleted( + MosaicCustomerRestoreProviderOutcome.COMPLETED, + validationPending = true, + ) +} + +/** An accepted snapshot counts only when it actually advanced past the version the restore started at. */ +private fun MosaicCustomerEntitlementSyncResult.asUpdated( + providerOutcome: MosaicCustomerRestoreProviderOutcome, + versionBefore: Long?, +): MosaicCustomerSyncResult.AuthoritativeEntitlementsUpdated? { + val snapshot = (this as? MosaicCustomerEntitlementSyncResult.Updated)?.snapshot ?: return null + if (versionBefore != null && snapshot.snapshotVersion <= versionBefore) return null + return MosaicCustomerSyncResult.AuthoritativeEntitlementsUpdated(providerOutcome, snapshot) +} + +private fun MosaicRestoreResult.providerOutcome(): MosaicCustomerRestoreProviderOutcome = when (this) { + is MosaicRestoreResult.Detailed -> when (outcome) { + MosaicCommerceRecoveryOutcome.RESTORED -> MosaicCustomerRestoreProviderOutcome.COMPLETED + MosaicCommerceRecoveryOutcome.NOTHING_TO_RESTORE -> MosaicCustomerRestoreProviderOutcome.NO_PURCHASES_FOUND + MosaicCommerceRecoveryOutcome.CANCELLED -> MosaicCustomerRestoreProviderOutcome.CANCELLED + MosaicCommerceRecoveryOutcome.PROVIDER_UNAVAILABLE -> MosaicCustomerRestoreProviderOutcome.UNSUPPORTED + MosaicCommerceRecoveryOutcome.FAILED -> MosaicCustomerRestoreProviderOutcome.FAILED + } + is MosaicRestoreResult.Restored -> MosaicCustomerRestoreProviderOutcome.COMPLETED + MosaicRestoreResult.NothingToRestore -> MosaicCustomerRestoreProviderOutcome.NO_PURCHASES_FOUND + MosaicRestoreResult.Cancelled -> MosaicCustomerRestoreProviderOutcome.CANCELLED + is MosaicRestoreResult.ProviderUnavailable -> MosaicCustomerRestoreProviderOutcome.UNSUPPORTED + is MosaicRestoreResult.Failed -> MosaicCustomerRestoreProviderOutcome.FAILED +} + +private fun MosaicRestoreResult.diagnosticCode(): String = when (this) { + is MosaicRestoreResult.ProviderUnavailable -> diagnosticCode + is MosaicRestoreResult.Failed -> diagnosticCode + is MosaicRestoreResult.Detailed -> metadata.diagnostics.firstOrNull()?.code + ?: MosaicDiagnosticCode.RESTORE_FAILED.wireName + else -> MosaicDiagnosticCode.RESTORE_FAILED.wireName +} + +/** + * Refreshes authoritative entitlements shortly after a purchase completes. + * + * Three properties are load-bearing, and they are the same ones the Transaction Observation runtime + * protects: + * + * 1. **It never blocks or delays a purchase.** This is a *subscriber* to the adapter's update flow + * with its own bounded buffer, so a hung, slow, or hostile endpoint degrades to a missed refresh + * rather than to a stalled purchase. Nothing on the purchase path ever awaits it. + * 2. **It coalesces.** A purchase commonly produces several updates, and a foreground transition + * frequently coincides; one refresh answers all of them. + * 3. **It changes nothing about entitlement.** The refresh either produces an accepted snapshot or + * it does not; a failure here is invisible to the purchase result. + */ +internal class MosaicCustomerPurchaseRefresh( + private val runtime: MosaicCustomerEntitlementRuntime, + private val debounceMillis: Long = 1_500, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) { + private val pending = AtomicBoolean(false) + + fun collect(updates: Flow) { + scope.launch { + updates + // An explicit subscriber buffer: the emitting adapter is never suspended by this + // collector, and the oldest update is dropped rather than the newest, because the + // newest is the one worth refreshing for. + .buffer(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) + .collect { update -> observe(update) } + } + } + + private fun observe(update: MosaicCommerceUpdate) { + if (update.outcome != MosaicCommerceUpdateOutcome.PURCHASED && + update.outcome != MosaicCommerceUpdateOutcome.ENTITLEMENTS_CHANGED + ) { + return + } + if (!pending.compareAndSet(false, true)) return + scope.launch { + delay(debounceMillis) + pending.set(false) + runCatching { runtime.refreshCustomerEntitlements() } + } + } + + fun close() { + scope.cancel() + } +} diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/DiagnosticsAndLoading.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/DiagnosticsAndLoading.kt index 6f6b6731..913e49d1 100644 --- a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/DiagnosticsAndLoading.kt +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/DiagnosticsAndLoading.kt @@ -40,6 +40,17 @@ enum class MosaicDiagnosticCode(val wireName: String) { TRANSACTION_OBSERVATION_REJECTED("transaction.observation.rejected"), TRANSACTION_OBSERVATION_DROPPED("transaction.observation.dropped"), TRANSACTION_OBSERVATION_REFERENCE_UNAVAILABLE("transaction.observation.referenceUnavailable"), + + // Authoritative entitlements. Every code below describes Mosaic's ability to answer, never a + // customer's access: none of them may ever be read as "this person is not entitled". + CUSTOMER_ENTITLEMENTS_SNAPSHOT_REJECTED("customer.entitlements.snapshotRejected"), + CUSTOMER_ENTITLEMENTS_BINDING_MISMATCH("customer.entitlements.bindingMismatch"), + CUSTOMER_ENTITLEMENTS_CACHE_INVALID("customer.entitlements.cacheInvalid"), + CUSTOMER_ENTITLEMENTS_CLOCK_UNRELIABLE("customer.entitlements.clockUnreliable"), + CUSTOMER_ENTITLEMENTS_TOKEN_UNAVAILABLE("customer.entitlements.tokenUnavailable"), + CUSTOMER_ENTITLEMENTS_UNAUTHORIZED("customer.entitlements.unauthorized"), + CUSTOMER_ENTITLEMENTS_TRANSPORT_FAILED("customer.entitlements.transportFailed"), + CUSTOMER_ENTITLEMENTS_RESTORE_VALIDATION_PENDING("customer.entitlements.restoreValidationPending"), } data class MosaicDiagnostic( diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/ForegroundRefresh.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/ForegroundRefresh.kt index e123a3ff..df112d4e 100644 --- a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/ForegroundRefresh.kt +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/ForegroundRefresh.kt @@ -28,7 +28,17 @@ internal object MosaicForegroundRefreshRegistry { init { application.registerActivityLifecycleCallbacks(this) } override fun onActivityStarted(activity: Activity) { started += 1 - if (started == 1) client.get()?.let { current -> scope.launch { runCatching { current.refresh() } } } + if (started != 1) return + // One callback refreshes both surfaces. Authoritative entitlements deliberately reuse + // this registration rather than adding a second lifecycle observer: a returning app has + // exactly one "came back to the foreground" moment, and two callbacks racing to notice + // it would produce two requests for one event. + client.get()?.let { current -> + scope.launch { + runCatching { current.refresh() } + runCatching { current.refreshCustomerEntitlements() } + } + } } override fun onActivityStopped(activity: Activity) { started = (started - 1).coerceAtLeast(0) } override fun onActivityCreated(activity: Activity, state: Bundle?) = Unit diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/HostedConfiguration.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/HostedConfiguration.kt index c71e5ac4..072a2134 100644 --- a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/HostedConfiguration.kt +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/HostedConfiguration.kt @@ -16,6 +16,9 @@ import java.util.TimeZone import java.util.concurrent.TimeUnit import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -408,7 +411,15 @@ class MosaicHostedConfigurationClient( internal val analyticsRuntime: MosaicAnalyticsRuntime? = null, private val experimentStore: MosaicExperimentAssignmentStore? = null, internal val transactionObservationRuntime: MosaicTransactionObservationRuntime? = null, + internal val customerEntitlementRuntime: MosaicCustomerEntitlementRuntime? = null, ) { + /** + * Held so the purchase-triggered refresh outlives the call that created it. It is a property + * rather than a constructor parameter because this constructor is public API and the refresher + * is an internal implementation detail no host should be able to supply. + */ + internal var customerPurchaseRefresh: MosaicCustomerPurchaseRefresh? = null + private val refreshLock = Mutex() @Volatile private var accepted: MosaicAcceptedConfiguration? = null private val identityMutationLock = Mutex() @@ -626,6 +637,104 @@ class MosaicHostedConfigurationClient( suspend fun transactionObservationDiagnostics(): MosaicTransactionObservationDiagnostics = transactionObservationRuntime?.diagnostics() ?: MOSAIC_TRANSACTION_OBSERVATIONS_UNAVAILABLE + // ------------------------------------------------------------------------------------------ + // Authoritative entitlements + // + // Every member below is inert unless the host supplied a `customerAccessTokenProvider`, and + // every one of them reports `unavailable` rather than `inactive` when it cannot answer. None of + // them changes what the provider-observed commerce API does, and Placement targeting continues + // to read provider-observed entitlements exactly as before. + // ------------------------------------------------------------------------------------------ + + /** + * What Mosaic has validated about this customer's access. + * + * `Loading` until the first answer, `SignedOut` when there is no customer, and `Unavailable` + * when Mosaic could not answer. A host that needs a single value observes this flow; a host that + * needs one Entitlement calls [checkCustomerEntitlement]. + */ + val customerEntitlements: StateFlow + get() = customerEntitlementRuntime?.customerEntitlements + ?: MutableStateFlow( + MosaicCustomerEntitlementSnapshotState.Unavailable( + MosaicCustomerEntitlementUnavailableReason.NOT_CONFIGURED, + ), + ).asStateFlow() + + suspend fun checkCustomerEntitlement(entitlementKey: String): MosaicCustomerEntitlementCheck = + customerEntitlementRuntime?.checkCustomerEntitlement(entitlementKey) + ?: MosaicCustomerEntitlementCheck( + entitlementKey = entitlementKey, + state = MosaicCustomerEntitlementState.Unavailable( + MosaicCustomerEntitlementExplanation( + MosaicCustomerEntitlementExplanationCode.BILLING_DISABLED, + ), + MosaicCustomerUncertainty( + MosaicCustomerUncertaintyReason.PROVIDER_UNAVAILABLE, + since = mosaicAnalyticsTimestamp(System.currentTimeMillis()), + ), + ), + sourceCount = 0, + snapshotVersion = null, + asOf = null, + cacheState = MosaicCustomerEntitlementCacheState.MISSING, + ) + + suspend fun refreshCustomerEntitlements(): MosaicCustomerEntitlementSyncResult = + customerEntitlementRuntime?.refreshCustomerEntitlements() + ?: MosaicCustomerEntitlementSyncResult.Unavailable( + MosaicCustomerEntitlementUnavailableReason.NOT_CONFIGURED, + ) + + /** + * Binds this device to a Billing Customer the application's backend has authenticated. + * + * The Phase 6 installation identity is untouched: a person signing in is not a new installation, + * and conflating the two would reset analytics identity on every login. + */ + suspend fun identifyCustomer(billingCustomerId: String): MosaicCustomerEntitlementSyncResult = + customerEntitlementRuntime?.identifyCustomer(billingCustomerId) + ?: MosaicCustomerEntitlementSyncResult.Unavailable( + MosaicCustomerEntitlementUnavailableReason.NOT_CONFIGURED, + ) + + suspend fun signOutCustomer() { + customerEntitlementRuntime?.signOutCustomer() + } + + /** + * Restores through the purchase provider, then waits briefly for Mosaic to validate the result. + * + * The provider's own recovery path is unchanged, including acknowledgement. + */ + suspend fun restoreAndSyncCustomerEntitlements(): MosaicCustomerSyncResult { + val runtime = customerEntitlementRuntime + ?: return MosaicCustomerSyncResult.CustomerUnavailable( + MosaicCustomerRestoreProviderOutcome.NOT_ATTEMPTED, + MosaicCustomerEntitlementUnavailableReason.NOT_CONFIGURED, + ) + val provider = purchaseProvider + ?: return MosaicCustomerSyncResult.ProviderUnavailable( + MosaicCustomerRestoreProviderOutcome.NOT_ATTEMPTED, + MosaicDiagnosticCode.COMMERCE_PROVIDER_UNAVAILABLE.wireName, + ) + return mosaicRestoreAndSyncCustomerEntitlements(runtime, provider) + } + + suspend fun customerEntitlementDiagnostics(): MosaicCustomerEntitlementDiagnostics = + customerEntitlementRuntime?.customerEntitlementDiagnostics() + ?: MosaicCustomerEntitlementDiagnostics( + configured = false, + cacheState = MosaicCustomerEntitlementCacheState.MISSING, + snapshotVersion = null, + asOf = null, + clockUnreliable = false, + lastRejection = null, + lastUnavailableReason = MosaicCustomerEntitlementUnavailableReason.NOT_CONFIGURED, + acceptedSnapshotCount = 0, + rejectedSnapshotCount = 0, + ) + suspend fun experimentDiagnostics(): List = experimentStore?.diagnostics().orEmpty() diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/Mosaic.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/Mosaic.kt index 78ec1867..2ed7c87c 100644 --- a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/Mosaic.kt +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/Mosaic.kt @@ -17,6 +17,19 @@ data class MosaicConfiguration( * and an observation is never evidence that a transaction is authentic. False is the default. */ val transactionObservationEnabled: Boolean = false, + /** + * Opt in to authoritative entitlements by supplying a way to mint Customer Access Tokens. + * + * `null` — the default — leaves the whole feature inert: no request is made, no file is written, + * and every authoritative surface reports `unavailable`. It is null by default because Mosaic + * Billing structurally requires an application backend: a public SDK key can never select a + * Billing Customer, so an SDK that tried to enable this on its own could only guess at identity. + * + * Authoritative entitlements are additive. The provider-observed commerce API is unchanged and + * still drives Placement targeting; this answers the different question of what **Mosaic** has + * validated, which is the answer worth trusting after a refund, a revocation, or a reinstall. + */ + val customerAccessTokenProvider: MosaicCustomerAccessTokenProvider? = null, ) { init { require(apiKey.isNotBlank()) { "apiKey must not be blank." } @@ -76,6 +89,41 @@ class Mosaic private constructor( ) } } + // Authoritative entitlements exist only when the host supplied a token provider. The + // reference is resolved lazily because the trusted time anchor the freshness policy measures + // against lives on the accepted configuration, which the client below owns. + val clientReference = java.util.concurrent.atomic.AtomicReference() + // One session for both consumers. Sharing it is what makes an observation's attribution + // consistent with the entitlement state the same app is reading, and it keeps a single + // single-flight boundary in front of the host's token backend rather than two competing ones. + val customerTokenSession = configuration.customerAccessTokenProvider?.let(::MosaicCustomerTokenSession) + val customerEntitlements = customerTokenSession?.let { session -> + MosaicCustomerEntitlementRuntime( + transport = MosaicHTTPCustomerEntitlementTransport(configuration), + cache = MosaicCustomerEntitlementCache(context, namespace), + session = session, + trustedTime = { + clientReference.get()?.acceptedConfiguration?.trustedTimeAnchor?.nowEpochMillis() + }, + diagnostics = diagnostics, + ) + } + val customerPurchaseRefresh = customerEntitlements?.let { runtime -> + commerceUpdates?.let { updates -> + MosaicCustomerPurchaseRefresh(runtime).also { it.collect(updates) } + } + } + // Attribution for the optional observation handoff. Without it a validated purchase anchors + // anonymously to its store lineage; with it, Mosaic can bind the purchase to the Billing + // Customer the host has already authenticated. The observation record itself is unchanged — + // this is a transport header, not a contract field. + if (customerTokenSession != null && observations != null) { + // Cached-only, and never a mint. Attribution is a bonus on a fire-and-forget path, so a + // background flush must not initiate network work against the host's backend — least of + // all on a cold start, before the app has any reason to believe anyone is signed in. If + // no token is already held, the submission goes out anonymously. + observations.bindCustomerTokenSource { customerTokenSession.heldToken() } + } return MosaicHostedConfigurationClient( transport = MosaicHTTPConfigurationTransport(configuration), commerceTransport = configuration.applicationId?.let { @@ -92,7 +140,10 @@ class Mosaic private constructor( applicationVersion = configuration.applicationVersion, experimentStore = experimentStore, transactionObservationRuntime = observations, + customerEntitlementRuntime = customerEntitlements, ).also { client -> + client.customerPurchaseRefresh = customerPurchaseRefresh + clientReference.set(client) (context.applicationContext as? android.app.Application)?.let { application -> MosaicForegroundRefreshRegistry.register(application, namespace, client) } @@ -108,6 +159,7 @@ class Mosaic private constructor( applicationId: String? = null, analyticsCollectionEnabled: Boolean = false, transactionObservationEnabled: Boolean = false, + customerAccessTokenProvider: MosaicCustomerAccessTokenProvider? = null, ): Mosaic = Mosaic( configuration = MosaicConfiguration( apiKey = apiKey, @@ -116,6 +168,7 @@ class Mosaic private constructor( applicationId = applicationId, analyticsCollectionEnabled = analyticsCollectionEnabled, transactionObservationEnabled = transactionObservationEnabled, + customerAccessTokenProvider = customerAccessTokenProvider, ).normalized(), purchaseProvider = purchaseProvider, ) diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/TransactionObservationRuntime.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/TransactionObservationRuntime.kt index d4ba7ceb..0c7da211 100644 --- a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/TransactionObservationRuntime.kt +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/TransactionObservationRuntime.kt @@ -96,6 +96,18 @@ class MosaicTransactionObservationRuntime internal constructor( fun flushBestEffort() { if (enabled) scope.launch { runCatching { flush() } } } + /** + * Binds the Customer Access Token source used to attribute submissions. + * + * The token is never held by the queue, never written beside a queued observation, and never + * read until a request is actually being built — so a queued observation carries no credential + * at rest, and one enqueued before sign-in is still attributed correctly when it is delivered + * after sign-in. + */ + internal fun bindCustomerTokenSource(source: MosaicCustomerTokenSource) { + transport.bindCustomerTokenSource(source) + } + /** Applies a changed host opt-in without reconstructing the runtime. */ internal fun reconcileEnabled(value: Boolean) { if (enabled == value) return diff --git a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/TransactionObservationTransport.kt b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/TransactionObservationTransport.kt index d9bf2158..96a3fa05 100644 --- a/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/TransactionObservationTransport.kt +++ b/sdk/android/mosaic/src/main/kotlin/dev/mosaic/sdk/TransactionObservationTransport.kt @@ -18,9 +18,50 @@ internal sealed interface MosaicTransactionObservationTransportResult { MosaicTransactionObservationTransportResult } +/** + * Supplies the current Customer Access Token, if there is one, at the moment of a request. + * + * It is a *source* rather than a value because the token is read at send time, never at enqueue + * time. An observation may sit in the durable queue across a sign-in, an app restart, or several + * days offline, so a token captured when the purchase completed would be expired or simply wrong by + * the time the observation is delivered. + */ +internal fun interface MosaicCustomerTokenSource { + suspend fun currentCustomerToken(): MosaicCustomerAccessToken? +} + +/** Header carrying the Customer Access Token that binds a submission to a Billing Customer. */ +internal const val MOSAIC_CUSTOMER_TOKEN_HEADER = "Mosaic-Customer-Token" + +/** + * Builds the observation request headers. + * + * Extracted so the binding rule is testable without a live socket. Two credentials with different + * meanings travel here: the public SDK key says which application build is reporting, and the + * customer token says whose purchase it is. The customer token is **optional** — an anonymous + * submission is valid and is what an unidentified user produces — so a missing token omits the + * header rather than failing or delaying the submission. + */ +internal fun mosaicObservationHeaders( + apiKey: String, + customerToken: MosaicCustomerAccessToken?, +): Map = buildMap { + put("Authorization", "Bearer $apiKey") + put("Accept", "application/json") + put("Mosaic-SDK-Platform", "android") + put("Mosaic-SDK-Version", MOSAIC_ANDROID_SDK_VERSION) + customerToken?.let { put(MOSAIC_CUSTOMER_TOKEN_HEADER, it.value) } +} + internal interface MosaicTransactionObservationTransport { suspend fun submit(observation: MosaicTransactionObservation): MosaicTransactionObservationTransportResult fun cancel() = Unit + + /** + * Binds a customer-token source. Optional by default: a transport that never talks to Mosaic's + * observation endpoint has nothing to bind, and an unbound transport submits anonymously. + */ + fun bindCustomerTokenSource(source: MosaicCustomerTokenSource) = Unit } /** @@ -33,17 +74,23 @@ internal class MosaicHTTPTransactionObservationTransport( private val client: OkHttpClient = OkHttpClient.Builder().callTimeout(15, TimeUnit.SECONDS).build(), ) : MosaicTransactionObservationTransport { @Volatile private var activeCall: okhttp3.Call? = null + @Volatile private var customerTokenSource: MosaicCustomerTokenSource? = null + + override fun bindCustomerTokenSource(source: MosaicCustomerTokenSource) { + customerTokenSource = source + } override suspend fun submit( observation: MosaicTransactionObservation, ): MosaicTransactionObservationTransportResult = withContext(Dispatchers.IO) { val body = MosaicTransactionObservationCodec.encode(observation) + // Read at send time, and never allowed to fail the submission. This path is fire and + // forget: a token backend that is down, slow, or absent must cost the binding, not the + // observation, so the request proceeds anonymously rather than being dropped or retried. + val customerToken = runCatching { customerTokenSource?.currentCustomerToken() }.getOrNull() val request = Request.Builder() .url(configuration.transactionObservationURL().toString()) - .header("Authorization", "Bearer ${configuration.apiKey}") - .header("Accept", "application/json") - .header("Mosaic-SDK-Platform", "android") - .header("Mosaic-SDK-Version", MOSAIC_ANDROID_SDK_VERSION) + .apply { mosaicObservationHeaders(configuration.apiKey, customerToken).forEach(::header) } .post(body.toRequestBody("application/json".toMediaType())) .build() try { diff --git a/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerAuthenticationTest.kt b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerAuthenticationTest.kt new file mode 100644 index 00000000..1ead5ef6 --- /dev/null +++ b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerAuthenticationTest.kt @@ -0,0 +1,154 @@ +package dev.mosaic.sdk + +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class CustomerAuthenticationTest { + private fun token(value: String) = MosaicCustomerAccessToken(value) + + /** A credential must not be printable by accident, which interpolation does constantly. */ + @Test + fun tokenNeverPrintsItsValue() { + val secret = "mosaic-customer-token-abcdefghijkl" + assertFalse(token(secret).toString().contains(secret)) + assertFalse("holder: ${token(secret)}".contains(secret)) + } + + /** + * N concurrent callers collapse onto one provider call. + * + * The failure this protects against is a cold start or a mass 401 turning into one token request + * per concurrent entitlement read, against a host backend that may rate limit per user. + */ + @Test + fun concurrentCallersProduceExactlyOneProviderCall() = runTest { + val gate = CompletableDeferred() + val calls = AtomicInteger() + val session = MosaicCustomerTokenSession( + { _ -> + calls.incrementAndGet() + gate.await() + MosaicCustomerAccessTokenResult.Issued(token("mosaic-customer-token-000001")) + }, + ) + + val waiters = List(16) { async { session.token() } } + gate.complete(Unit) + val results = waiters.awaitAll() + + assertEquals(1, calls.get()) + assertTrue(results.all { it is MosaicCustomerAccessTokenResult.Issued }) + // The cached token satisfies later readers without touching the provider at all. + session.token() + assertEquals(1, calls.get()) + } + + /** A forced refresh is a distinct call: the joined token is the one that was just refused. */ + @Test + fun forcedRefreshDoesNotReuseTheRefusedToken() = runTest { + val issued = mutableListOf() + val session = MosaicCustomerTokenSession( + { forced -> + val value = if (forced) "mosaic-customer-token-refreshed" else "mosaic-customer-token-original" + issued += value + MosaicCustomerAccessTokenResult.Issued(token(value)) + }, + ) + + val first = session.token() as MosaicCustomerAccessTokenResult.Issued + session.invalidate(first.token) + val second = session.token(forceRefresh = true) as MosaicCustomerAccessTokenResult.Issued + + assertEquals(listOf("mosaic-customer-token-original", "mosaic-customer-token-refreshed"), issued) + assertEquals("mosaic-customer-token-refreshed", second.token.value) + } + + /** + * Invalidation is compare-and-set on the token identity. + * + * Without the CAS, a 401 carrying an already-replaced token discards the good replacement and + * starts an invalidate-refresh loop that never converges. + */ + @Test + fun invalidatingAStaleTokenLeavesTheReplacementInPlace() = runTest { + val calls = AtomicInteger() + val session = MosaicCustomerTokenSession( + { _ -> + MosaicCustomerAccessTokenResult.Issued(token("mosaic-customer-token-${calls.incrementAndGet()}")) + }, + ) + val stale = token("mosaic-customer-token-stale-000") + val current = session.token() as MosaicCustomerAccessTokenResult.Issued + + session.invalidate(stale) + + val again = session.token() as MosaicCustomerAccessTokenResult.Issued + assertEquals(current.token.value, again.token.value) + assertEquals(1, calls.get()) + } + + /** A provider that throws degrades to "cannot answer" and never propagates into the host. */ + @Test + fun throwingProviderBecomesUnavailableRatherThanAFailure() = runTest { + val session = MosaicCustomerTokenSession({ _ -> throw IllegalStateException("host backend defect") }) + val result = session.token() + assertEquals( + "customer.token.providerFailed", + (result as MosaicCustomerAccessTokenResult.Unavailable).diagnosticCode, + ) + } + + /** An unavailable provider is not re-asked once per read; the cooldown is observable. */ + @Test + fun unavailableProviderIsNotRetriedUntilTheCooldownElapses() = runTest { + val calls = AtomicInteger() + var clock = 1_000L + val session = MosaicCustomerTokenSession( + provider = { _ -> + calls.incrementAndGet() + MosaicCustomerAccessTokenResult.Unavailable(retryAfterSeconds = 5) + }, + now = { clock }, + ) + + session.token() + session.token() + assertEquals(1, calls.get()) + + clock += 6_000 + session.token() + assertEquals(2, calls.get()) + } + + /** + * A token minted for the previous identity is discarded, not cached under the new one. + * + * This is the leak the generation counter exists for: the provider call was already in flight + * when the user logged out, so its result describes somebody who is no longer signed in. + */ + @Test + fun tokenIssuedAcrossALogoutIsDiscarded() = runTest { + val gate = CompletableDeferred() + val session = MosaicCustomerTokenSession( + { _ -> + gate.await() + MosaicCustomerAccessTokenResult.Issued(token("mosaic-customer-token-previous")) + }, + ) + + val inFlight = async { session.token() } + session.clear() + gate.complete(Unit) + inFlight.await() + + assertSame(MosaicCustomerAccessTokenResult.SignedOut, session.token()) + } +} diff --git a/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementCacheTest.kt b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementCacheTest.kt new file mode 100644 index 00000000..ea4114b4 --- /dev/null +++ b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementCacheTest.kt @@ -0,0 +1,126 @@ +package dev.mosaic.sdk + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * The store is exercised through its `File` constructor so these run on the JVM. + * + * The behaviour under test is filesystem behaviour, not Android behaviour: an instrumented run + * would prove the same isolation far more slowly, and the one thing only a device can prove — that + * `noBackupFilesDir` is where the store lives — is fixed by the `Context` constructor rather than by + * anything a test could vary. + */ +class CustomerEntitlementCacheTest { + @get:Rule + val folder = TemporaryFolder() + + private fun cache(): MosaicCustomerEntitlementCache = MosaicCustomerEntitlementCache(folder.root) + + /** Two identities never share a file, so clearing one cannot disturb the other. */ + @Test + fun customersAreIsolatedByDirectory() { + val store = cache() + val first = MosaicCustomerEntitlementCache.bindingDigest("fixture-customer-0001") + val second = MosaicCustomerEntitlementCache.bindingDigest("fixture-customer-0002") + assertFalse(first == second) + + store.write(first, "{\"a\":1}") + store.write(second, "{\"a\":2}") + store.clear(first) + + assertNull(store.read(first)) + assertEquals("{\"a\":2}", store.read(second)) + } + + /** The Billing Customer identifier itself is never written to disk in the clear. */ + @Test + fun customerIdentifierNeverAppearsOnDisk() { + val store = cache() + val digest = MosaicCustomerEntitlementCache.bindingDigest("customer-with-a-recognisable-id") + store.write(digest, "{\"a\":1}") + store.writePointer(digest) + + val allText = folder.root.walkTopDown().filter(File::isFile).joinToString("\n") { it.path + "\n" + it.readText() } + assertFalse(allText.contains("customer-with-a-recognisable-id")) + } + + /** + * Signing out leaves nothing behind. + * + * "Unreachable but present" is not good enough: the file is a readable record of what somebody + * paid for, on a device they may have handed to someone else. + */ + @Test + fun logoutRemovesEverySnapshotAndThePointer() { + val store = cache() + val digest = MosaicCustomerEntitlementCache.bindingDigest("fixture-customer-0001") + store.write(digest, "{\"a\":1}") + store.writePointer(digest) + + store.clearAll() + + assertNull(store.read(digest)) + assertNull(store.pointer()) + assertFalse(folder.root.exists()) + } + + /** An identity change keeps the new customer and removes every previous one. */ + @Test + fun retainingOneIdentityRemovesThePrevious() { + val store = cache() + val previous = MosaicCustomerEntitlementCache.bindingDigest("fixture-customer-0001") + val current = MosaicCustomerEntitlementCache.bindingDigest("fixture-customer-0002") + store.write(previous, "{\"a\":1}") + store.write(current, "{\"a\":2}") + + store.retainOnly(current) + + assertNull(store.read(previous)) + assertEquals("{\"a\":2}", store.read(current)) + } + + /** A write leaves exactly one file behind: no temporary file survives a completed write. */ + @Test + fun atomicWriteLeavesNoTemporaryBehind() { + val store = cache() + val digest = MosaicCustomerEntitlementCache.bindingDigest("fixture-customer-0001") + store.write(digest, "{\"a\":1}") + store.write(digest, "{\"a\":2}") + + val files = File(folder.root, digest).listFiles().orEmpty() + assertEquals(1, files.size) + assertEquals("snapshot.json", files.single().name) + assertEquals("{\"a\":2}", store.read(digest)) + } + + /** A foreign or hand-edited pointer is ignored rather than trusted. */ + @Test + fun pointerRejectsAnUnknownFormat() { + val store = cache() + val digest = MosaicCustomerEntitlementCache.bindingDigest("fixture-customer-0001") + store.writePointer(digest) + assertEquals(digest, store.pointer()) + + File(folder.root, "current.json").writeText("{\"cacheFormatVersion\":\"9\",\"bindingDigest\":\"$digest\"}") + assertNull(store.pointer()) + + File(folder.root, "current.json").writeText("{\"bindingDigest\":\"$digest\"}") + assertNull(store.pointer()) + } + + /** A digest is the only admissible directory name; a caller cannot escape the namespace. */ + @Test + fun onlyDigestNamesAreAccepted() { + val store = cache() + assertTrue( + runCatching { store.write("../escape", "{}") }.exceptionOrNull() is IllegalArgumentException, + ) + } +} diff --git a/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementCodecTest.kt b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementCodecTest.kt new file mode 100644 index 00000000..ed384d8d --- /dev/null +++ b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementCodecTest.kt @@ -0,0 +1,253 @@ +package dev.mosaic.sdk + +import com.google.gson.JsonParser +import java.nio.file.Files +import java.nio.file.Path +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Protocol conformance for the Authoritative Entitlement Contract 1 reader. + * + * The canonical fixtures and the digest vectors are read from the repository rather than copied, so + * a contract change fails here instead of drifting one platform away from the other three. + */ +class CustomerEntitlementCodecTest { + private fun fixture(relative: String): String = + Files.readAllBytes(repositoryFile("protocol/fixtures/authoritative-entitlement/v1/$relative")) + .toString(Charsets.UTF_8) + + private fun fixtures(directory: String): List = + Files.list(repositoryFile("protocol/fixtures/authoritative-entitlement/v1/$directory")) + .filter { it.fileName.toString().endsWith(".json") } + .sorted() + .toList() + + /** + * The canonical serialization, byte for byte. + * + * Asserting the serialization as well as the digest is deliberate: when a digest disagrees, the + * serialization says *why*, and the non-ASCII and sorting rows are the ones that actually catch + * a broken serializer. + */ + @Test + fun snapshotDigestVectorTableIsSatisfied() { + val table = JsonParser.parseString( + Files.readAllBytes(repositoryFile("packages/test-fixtures/src/entitlement-snapshot-digest-vectors.json")) + .toString(Charsets.UTF_8), + ).asJsonObject + + var rows = 0 + table.getAsJsonArray("vectors").forEach { element -> + val vector = element.asJsonObject + val id = vector.get("id").asString + val payload = vector.get("payload") + val canonical = MosaicCustomerEntitlementCodec.canonicalJson(payload) + assertEquals(id, vector.get("canonicalSerialization").asString, canonical) + assertEquals(id, vector.get("canonicalByteLength").asInt, canonical.toByteArray(Charsets.UTF_8).size) + assertEquals(id, vector.get("digest").asString, MosaicCustomerEntitlementCodec.digest(payload)) + rows += 1 + } + assertEquals(9, rows) + } + + /** Every canonical snapshot fixture decodes, and its own contentDigest verifies. */ + @Test + fun everyCanonicalSnapshotFixtureIsAccepted() { + val decoded = fixtures("snapshots").map { path -> + path.fileName.toString() to + MosaicCustomerEntitlementCodec.decodeRecord(Files.readAllBytes(path).toString(Charsets.UTF_8)) + } + assertEquals(15, decoded.size) + decoded.forEach { (name, record) -> + when (name) { + "snapshot-unchanged.json" -> assertTrue(name, record is MosaicCustomerRecordDecoding.Unchanged) + else -> { + assertTrue(name, record is MosaicCustomerRecordDecoding.Snapshot) + assertTrue(name, (record as MosaicCustomerRecordDecoding.Snapshot).contentDigestValid) + } + } + } + } + + /** Version zero is the constrained pending placeholder and can be stated on the next sync. */ + @Test + fun neverProjectedPlaceholderIsAcceptedAndSentAsKnownVersion() { + val decoded = MosaicCustomerEntitlementCodec.decodeRecord( + fixture("snapshots/never-projected-placeholder.json"), + ) as MosaicCustomerRecordDecoding.Snapshot + + assertEquals(0L, decoded.snapshot.snapshotVersion) + assertTrue(decoded.snapshot.entries.isEmpty()) + assertEquals(MosaicCustomerProjectionState.PENDING, decoded.snapshot.projectionStatus.state) + + val request = JsonParser.parseString( + MosaicCustomerEntitlementCodec.encodeSyncRequest( + correlationId = "fixture-correlation-placeholder-sync", + knownSnapshotVersion = decoded.snapshot.snapshotVersion, + entityTag = decoded.snapshot.entityTag, + requestedEntitlementKeys = emptyList(), + ), + ).asJsonObject.getAsJsonObject("payload") + assertEquals(0L, request.get("knownSnapshotVersion").asLong) + } + + @Test + fun versionZeroCannotCarryProjectedEntitlementState() { + val record = JsonParser.parseString(fixture("snapshots/active-subscription.json")).asJsonObject + record.getAsJsonObject("payload").addProperty("snapshotVersion", 0) + + assertTrue( + MosaicCustomerEntitlementCodec.decodeRecord(record.toString()) is + MosaicCustomerRecordDecoding.Unreadable, + ) + } + + /** + * A permanent source must never be reported as expiring. + * + * `endKnown == true` with an absent `effectiveEnd` means permanent; a reader that renders a null + * end as "no expiry known" or borrows the subscription's end tells a lifetime purchaser their + * access ends next month. + */ + @Test + fun permanentSourceCarriesNoFiniteExpiry() { + val record = MosaicCustomerEntitlementCodec.decodeRecord( + fixture("snapshots/permanent-source-no-finite-expiry.json"), + ) + val entry = (record as MosaicCustomerRecordDecoding.Snapshot).snapshot.entries.single() + val state = entry.state as MosaicCustomerEntitlementState.Active + assertTrue(state.endKnown) + assertNull(state.effectiveEnd) + } + + /** A test-derived grant is reported as such on every surface; on Google nothing else marks it. */ + @Test + fun testSourceGrantIsCarriedThroughDecoding() { + val record = MosaicCustomerEntitlementCodec.decodeRecord(fixture("snapshots/test-source-sandbox-grant.json")) + val snapshot = (record as MosaicCustomerRecordDecoding.Snapshot).snapshot + assertTrue(snapshot.sources.any { it.isTestSource }) + } + + /** + * Every invalid snapshot-layer fixture is refused, at the layer `rejection-layers.json` names. + * + * Two of them are structurally valid documents that only a cache-acceptance decision can refuse, + * which is why they are driven through the acceptance gate rather than the decoder. Splitting + * them by the recorded layer keeps the test honest about which mechanism does the work. + */ + @Test + fun everyInvalidSnapshotFixtureIsRefused() { + val cacheLayerRejections = mapOf( + "older-snapshot-version-rejected.json" to MosaicCustomerSnapshotRejection.SNAPSHOT_VERSION_NOT_NEWER, + "different-customer-rejected.json" to MosaicCustomerSnapshotRejection.CONTENT_DIGEST_MISMATCH, + ) + // Classified producer-side: the semantic validator guards what Mosaic emits, and the reader + // accepts it because the document is fully interpretable. See the fixture assertion below. + val producerSideOnly = setOf("snapshot-carries-signed-payload-value.json") + var documentRejections = 0 + fixtures("invalid").forEach { path -> + val name = path.fileName.toString() + if (name == "rejection-layers.json") return@forEach + if (name in producerSideOnly) return@forEach + val source = Files.readAllBytes(path).toString(Charsets.UTF_8) + val recordType = JsonParser.parseString(source).asJsonObject.get("recordType")?.asString + // The SDK reads only the two sync-surface record types; subscription snapshots, check + // results, and restore results reach a host through its own backend, not through here. + if (recordType != null && + recordType !in setOf("customerEntitlementSnapshot", "snapshotUnchanged") && + name != "unknown-record-type.json" && name != "unknown-contract-version.json" + ) { + return@forEach + } + val decoded = MosaicCustomerEntitlementCodec.decodeRecord(source) + val expectedCacheRejection = cacheLayerRejections[name] + if (expectedCacheRejection == null) { + assertTrue("$name should not decode", decoded is MosaicCustomerRecordDecoding.Unreadable) + documentRejections += 1 + return@forEach + } + // A structurally valid document the cache gate must still refuse. + val snapshot = decoded as MosaicCustomerRecordDecoding.Snapshot + val decision = MosaicCustomerEntitlementAcceptance.decide( + cached = MosaicCustomerSnapshotBinding( + contractVersion = "1", + billingCustomerId = "fixture-customer-0001", + projectId = "fixture-project-mosaic", + environmentId = "fixture-environment-production", + snapshotVersion = 4, + asOfEpochMillis = mosaicContractInstantMillis("2026-07-28T11:59:58.000Z"), + contentDigestValid = true, + ), + incoming = MosaicCustomerSnapshotBinding( + contractVersion = "1", + billingCustomerId = snapshot.snapshot.billingCustomerId, + projectId = snapshot.snapshot.projectId, + environmentId = snapshot.snapshot.environmentId, + snapshotVersion = snapshot.snapshot.snapshotVersion, + asOfEpochMillis = snapshot.snapshot.asOfEpochMillis, + contentDigestValid = snapshot.contentDigestValid, + ), + ) + assertEquals(name, expectedCacheRejection, decision.rejection) + } + assertEquals(15, documentRejections) + } + + /** + * The JWS-shaped `correlationId` fixture is accepted by the reader, on purpose. + * + * It is classified as a **producer-side** rejection: the semantic validator stops Mosaic from + * emitting it. A reader that refused it would drop a customer to `unknown` because a server put + * an odd-looking string into a field the SDK only passes through — a strictly worse outcome than + * carrying the value. The document is fully interpretable, and interpretability is what reader + * rejection is for. + */ + @Test + fun aSignedPayloadValueInAnIdentifierIsAProducerConcernNotAReaderRejection() { + val decoded = MosaicCustomerEntitlementCodec.decodeRecord( + fixture("invalid/snapshot-carries-signed-payload-value.json"), + ) + val snapshot = (decoded as MosaicCustomerRecordDecoding.Snapshot).snapshot + assertTrue(snapshot.correlationId.startsWith("eyJ")) + // It is carried, never interpreted: the SDK does not parse it and never treats it as proof + // of anything. This contract is not a bearer credential in any of its fields. + assertEquals("fixture-customer-0001", snapshot.billingCustomerId) + } + + /** The cache record survives a round trip and refuses truncation and tampering. */ + @Test + fun cacheRecordDetectsTruncationAndTampering() { + val source = fixture("snapshots/bounded-offline-cache.json") + val snapshot = (MosaicCustomerEntitlementCodec.decodeRecord(source) as MosaicCustomerRecordDecoding.Snapshot) + .snapshot + val encoded = MosaicCustomerEntitlementCodec.encodeCacheRecord(source, snapshot.freshness) + + val restored = MosaicCustomerEntitlementCodec.decodeCacheRecord(encoded) + assertEquals(snapshot.snapshotVersion, restored?.snapshot?.snapshotVersion) + assertEquals(86_400, restored?.window?.staleGraceSeconds) + + assertNull(MosaicCustomerEntitlementCodec.decodeCacheRecord(encoded.substring(0, encoded.length / 2))) + assertNull( + MosaicCustomerEntitlementCodec.decodeCacheRecord( + encoded.replace("\"snapshotVersion\":13", "\"snapshotVersion\":99"), + ), + ) + } + + /** + * A `snapshotUnchanged` record slides freshness and carries no entries. + * + * That absence is the point: a confirmed-current snapshot must not expire merely because it was + * confirmed instead of resent, and equal-version re-acceptance is refused elsewhere. + */ + @Test + fun unchangedRecordCarriesFreshnessOnly() { + val record = MosaicCustomerEntitlementCodec.decodeRecord(fixture("snapshots/snapshot-unchanged.json")) + val unchanged = (record as MosaicCustomerRecordDecoding.Unchanged).unchanged + assertEquals("fixture-customer-0001", unchanged.billingCustomerId) + assertTrue(unchanged.freshness.validUntilEpochMillis > unchanged.freshness.refreshAfterEpochMillis) + } +} diff --git a/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementRuntimeTest.kt b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementRuntimeTest.kt new file mode 100644 index 00000000..49c9c1b0 --- /dev/null +++ b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementRuntimeTest.kt @@ -0,0 +1,654 @@ +package dev.mosaic.sdk + +import com.google.gson.JsonParser +import java.nio.file.Files +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Runtime behaviour, driven by canonical fixtures through a fake transport. + * + * Everything here runs on the JVM: the store is constructed on a temporary directory and the device + * clock is an explicit input, so the states that matter most — an expired cache, a manipulated + * clock, an identity change mid-flight — are reachable deterministically instead of by waiting. + */ +class CustomerEntitlementRuntimeTest { + @get:Rule + val folder = TemporaryFolder() + + private val issuedAt = mosaicContractInstantMillis("2026-07-28T12:00:00.000Z") + private var deviceNow: Long? = issuedAt + 60_000 + + private fun fixture(name: String): String = + Files.readAllBytes( + repositoryFile("protocol/fixtures/authoritative-entitlement/v1/snapshots/$name.json"), + ).toString(Charsets.UTF_8) + + private fun entityTag(record: String): String = JsonParser.parseString(record) + .asJsonObject.getAsJsonObject("payload").get("entityTag").asString + + private fun record(name: String) = MosaicCustomerEntitlementTransportResult.Record( + body = fixture(name), + entityTag = entityTag(fixture(name)), + ) + + private fun runtime( + transport: MosaicCustomerEntitlementTransport, + provider: MosaicCustomerAccessTokenProvider = MosaicCustomerAccessTokenProvider { + MosaicCustomerAccessTokenResult.Issued(MosaicCustomerAccessToken("mosaic-customer-token-0001")) + }, + diagnostics: MosaicDiagnosticSink = MosaicDiagnosticSink.None, + ) = MosaicCustomerEntitlementRuntime( + transport = transport, + cache = MosaicCustomerEntitlementCache(folder.root), + session = MosaicCustomerTokenSession(provider), + trustedTime = { deviceNow }, + diagnostics = diagnostics, + ) + + // ------------------------------------------------------------------------------------------ + // Acceptance gate + // ------------------------------------------------------------------------------------------ + + /** An older snapshot never rolls state backwards, and never lands the reader on inactive. */ + @Test + fun anOlderSnapshotIsRejectedAndThePreviousOneKeepsServing() = runTest { + val responses = ArrayDeque(listOf(record("newer-snapshot"), record("bounded-offline-cache"))) + val runtime = runtime({ _, _ -> responses.removeFirst() }) + // Inside the newer snapshot's own window, so freshness is not what this row is testing. + deviceNow = mosaicContractInstantMillis("2026-07-28T14:01:00.000Z") + + val first = runtime.refreshCustomerEntitlements() + assertEquals(14L, (first as MosaicCustomerEntitlementSyncResult.Updated).snapshot.snapshotVersion) + + val second = runtime.refreshCustomerEntitlements() + assertEquals( + MosaicCustomerSnapshotRejection.SNAPSHOT_VERSION_NOT_NEWER, + (second as MosaicCustomerEntitlementSyncResult.Rejected).rejection, + ) + val state = runtime.customerEntitlements.value as MosaicCustomerEntitlementSnapshotState.Available + assertEquals(14L, state.snapshot.snapshotVersion) + } + + /** A snapshot whose HTTP validator does not identify it is refused rather than trusted. */ + @Test + fun aSnapshotWithoutAStrongMatchingEntityTagIsRejected() = runTest { + val runtime = runtime({ _, _ -> + MosaicCustomerEntitlementTransportResult.Record(fixture("active-subscription"), null) + }) + val result = runtime.refreshCustomerEntitlements() + assertEquals( + MosaicCustomerSnapshotRejection.WEAK_ENTITY_TAG, + (result as MosaicCustomerEntitlementSyncResult.Rejected).rejection, + ) + assertTrue(runtime.customerEntitlements.value is MosaicCustomerEntitlementSnapshotState.Unavailable) + } + + /** + * A snapshot bound to another customer clears the cache and is never observable. + * + * This is the leak the binding check exists for. The assertion that matters is not only that the + * foreign snapshot was refused, but that the *previous* customer's grants stopped being served + * the moment a different identity appeared. + */ + @Test + fun aDifferentCustomerClearsTheCacheAndIsNeverObservable() = runTest { + val observed = mutableListOf() + val responses = ArrayDeque(listOf(record("bounded-offline-cache"), record("test-source-sandbox-grant"))) + val diagnostics = mutableListOf() + val runtime = runtime({ _, _ -> responses.removeFirst() }, diagnostics = { diagnostics += it }) + + runtime.refreshCustomerEntitlements() + observed += runtime.customerEntitlements.value + val second = runtime.refreshCustomerEntitlements() + observed += runtime.customerEntitlements.value + + assertEquals( + MosaicCustomerSnapshotRejection.CUSTOMER_MISMATCH, + (second as MosaicCustomerEntitlementSyncResult.Rejected).rejection, + ) + assertNull(second.lastKnown) + // Neither the foreign snapshot nor the previous customer's snapshot is served afterwards. + assertTrue(observed.last() is MosaicCustomerEntitlementSnapshotState.Unavailable) + assertFalse( + observed.any { + it is MosaicCustomerEntitlementSnapshotState.Available && + it.snapshot.billingCustomerId == "fixture-customer-0002" + }, + ) + assertTrue( + diagnostics.any { it.code == MosaicDiagnosticCode.CUSTOMER_ENTITLEMENTS_BINDING_MISMATCH }, + ) + } + + // ------------------------------------------------------------------------------------------ + // Freshness + // ------------------------------------------------------------------------------------------ + + /** Inside the grace window access continues, and every active entry is marked stale. */ + @Test + fun aGraceWindowKeepsAccessAndMarksItStale() = runTest { + val runtime = runtime({ _, _ -> record("bounded-offline-cache") }) + runtime.refreshCustomerEntitlements() + + // validUntil + 5 minutes: inside the 24-hour bounded-grace band. + deviceNow = mosaicContractInstantMillis("2026-08-04T12:05:00.000Z") + val check = runtime.checkCustomerEntitlement("pro") + + assertEquals(MosaicCustomerEntitlementCacheState.STALE_WITHIN_GRACE, check.cacheState) + assertTrue((check.state as MosaicCustomerEntitlementState.Active).isStale) + } + + /** + * Past the grace window, and with an unreadable clock, the answer is unknown. + * + * Both directions matter. A cache Mosaic has not confirmed says nothing about whether the person + * still pays, so reporting `inactive` would revoke a paying customer on a bad network day, and a + * clock that cannot measure the cache's age is the same situation with a different cause. + */ + @Test + fun anExpiredCacheAndAnUnreadableClockBothYieldUnknownNeverInactive() = runTest { + val runtime = runtime({ _, _ -> record("bounded-offline-cache") }) + runtime.refreshCustomerEntitlements() + + deviceNow = mosaicContractInstantMillis("2026-08-06T12:05:00.000Z") + val expired = runtime.checkCustomerEntitlement("pro") + assertTrue(expired.state is MosaicCustomerEntitlementState.Unknown) + assertEquals(MosaicCustomerEntitlementCacheState.EXPIRED, expired.cacheState) + + deviceNow = null + runtime.refreshCustomerEntitlements() + val unreadable = runtime.checkCustomerEntitlement("pro") + assertTrue(unreadable.state is MosaicCustomerEntitlementState.Unknown) + val state = runtime.customerEntitlements.value as MosaicCustomerEntitlementSnapshotState.Unavailable + assertEquals(MosaicCustomerEntitlementUnavailableReason.CLOCK_UNRELIABLE, state.reason) + // The snapshot is still carried as last-known so a host can explain itself; it is simply + // not served as an answer. + assertEquals(13L, state.lastKnown?.snapshotVersion) + } + + /** + * A clock moved backwards past issuance does not become "fresh". + * + * The naive implementation computes a negative cache age, concludes fresh, and hands unlimited + * offline access to anyone willing to change their device time. + */ + @Test + fun aBackdatedClockDoesNotExtendAccess() = runTest { + val runtime = runtime({ _, _ -> record("bounded-offline-cache") }) + runtime.refreshCustomerEntitlements() + assertTrue(runtime.customerEntitlements.value is MosaicCustomerEntitlementSnapshotState.Available) + + deviceNow = mosaicContractInstantMillis("2026-07-28T09:00:00.000Z") + val check = runtime.checkCustomerEntitlement("pro") + assertTrue(check.state is MosaicCustomerEntitlementState.Unknown) + assertTrue((runtime.customerEntitlementDiagnostics()).clockUnreliable) + } + + // ------------------------------------------------------------------------------------------ + // Never inactive + // ------------------------------------------------------------------------------------------ + + /** An unknown entry stays unknown; only an accepted snapshot can produce inactive. */ + @Test + fun anUnknownEntryIsNeverReportedAsInactive() = runTest { + val runtime = runtime({ _, _ -> record("unknown-state-identity-unresolved") }) + runtime.refreshCustomerEntitlements() + + val check = runtime.checkCustomerEntitlement("pro") + val state = check.state as MosaicCustomerEntitlementState.Unknown + assertEquals(MosaicCustomerUncertaintyReason.IDENTITY_UNRESOLVED, state.uncertainty.reason) + } + + /** + * Billing withheld during a retry is a real, accepted `inactive`. + * + * It is the counterpart to the rule above: `inactive` must remain available for the case Mosaic + * genuinely decided, or the distinction would collapse in the other direction. + */ + @Test + fun anAcceptedSnapshotCanReportInactive() = runTest { + val runtime = runtime({ _, _ -> record("billing-retry-access-withheld") }) + runtime.refreshCustomerEntitlements() + + val check = runtime.checkCustomerEntitlement("pro") + assertTrue(check.state is MosaicCustomerEntitlementState.Inactive) + } + + /** A transport outage never revokes a cache that is still inside its own window. */ + @Test + fun aTransportOutageKeepsServingAValidCache() = runTest { + val responses = ArrayDeque( + listOf( + record("bounded-offline-cache"), + MosaicCustomerEntitlementTransportResult.Unavailable("customer.entitlements.serviceUnavailable"), + ), + ) + val runtime = runtime({ _, _ -> responses.removeFirst() }) + runtime.refreshCustomerEntitlements() + + val result = runtime.refreshCustomerEntitlements() + assertTrue(result is MosaicCustomerEntitlementSyncResult.Unavailable) + val check = runtime.checkCustomerEntitlement("pro") + assertTrue(check.state is MosaicCustomerEntitlementState.Active) + } + + // ------------------------------------------------------------------------------------------ + // The unchanged record + // ------------------------------------------------------------------------------------------ + + /** + * Builds a `snapshotUnchanged` record from the canonical fixture, retargeted at a given cached + * snapshot and window. The shape stays the fixture's; only the identity and freshness move. + */ + private fun unchangedRecord( + entityTag: String, + snapshotVersion: Long, + refreshAfter: String, + validUntil: String, + billingCustomerId: String = "fixture-customer-0001", + ): MosaicCustomerEntitlementTransportResult.Record { + val root = JsonParser.parseString(fixture("snapshot-unchanged")).asJsonObject + val payload = root.getAsJsonObject("payload") + payload.addProperty("billingCustomerId", billingCustomerId) + payload.addProperty("entityTag", entityTag) + payload.addProperty("snapshotVersion", snapshotVersion) + payload.addProperty("refreshAfter", refreshAfter) + payload.addProperty("validUntil", validUntil) + payload.addProperty("staleGraceSeconds", 86_400) + return MosaicCustomerEntitlementTransportResult.Record(root.toString(), entityTag) + } + + /** + * The unchanged answer is a contract record over `200`, not an HTTP status. + * + * It preserves the snapshot, slides its window, and accepts nothing new. Without the slide a + * device that keeps confirming the same version expires while demonstrably in contact with the + * server — the failure conditional revalidation exists to remove. Carrying the confirmation in + * the record rather than in headers means a proxy cannot rewrite how long a cache lives. + */ + @Test + fun anUnchangedRecordPreservesTheSnapshotAndSlidesFreshness() = runTest { + val responses = ArrayDeque( + listOf( + record("bounded-offline-cache"), + unchangedRecord( + entityTag = "cs-0011-v13", + snapshotVersion = 13, + refreshAfter = "2026-08-05T13:00:00.000Z", + validUntil = "2026-08-11T12:00:00.000Z", + ), + ), + ) + val runtime = runtime({ _, _ -> responses.removeFirst() }) + runtime.refreshCustomerEntitlements() + + // Past the original validUntil, so without the slide this would be expired. + deviceNow = mosaicContractInstantMillis("2026-08-05T12:00:00.000Z") + val result = runtime.refreshCustomerEntitlements() + + val unchanged = result as MosaicCustomerEntitlementSyncResult.Unchanged + assertEquals(13L, unchanged.snapshot.snapshotVersion) + assertEquals(MosaicCustomerEntitlementCacheState.FRESH, unchanged.cacheState) + val check = runtime.checkCustomerEntitlement("pro") + assertTrue(check.state is MosaicCustomerEntitlementState.Active) + assertFalse((check.state as MosaicCustomerEntitlementState.Active).isStale) + } + + /** + * A confirmation that does not identify the cached snapshot slides nothing. + * + * Otherwise an unchanged record about another customer — or about a version this device never + * held — would extend offline access on the strength of a statement about somebody else. + */ + @Test + fun anUnchangedRecordForAnotherSnapshotDoesNotSlideFreshness() = runTest { + val responses = ArrayDeque( + listOf( + record("bounded-offline-cache"), + unchangedRecord( + entityTag = "cs-9999-v99", + snapshotVersion = 99, + refreshAfter = "2026-08-05T13:00:00.000Z", + validUntil = "2026-08-11T12:00:00.000Z", + billingCustomerId = "fixture-customer-0002", + ), + ), + ) + val runtime = runtime({ _, _ -> responses.removeFirst() }) + runtime.refreshCustomerEntitlements() + + // Past the cached snapshot's own grace window, but well inside the window the rejected + // record claimed — so this instant separates "slid" from "not slid". + deviceNow = mosaicContractInstantMillis("2026-08-06T12:00:00.000Z") + val result = runtime.refreshCustomerEntitlements() + + assertTrue(result is MosaicCustomerEntitlementSyncResult.Rejected) + // The window was not extended, so the cache is past its own validity and reads unknown. + val check = runtime.checkCustomerEntitlement("pro") + assertTrue(check.state is MosaicCustomerEntitlementState.Unknown) + } + + /** + * A bare `304` preserves the cache but slides nothing. + * + * Nothing in the SDK asks for one, so it comes from an intermediary. Honouring it as a freshness + * extension would let a caching proxy grant unconfirmed offline access indefinitely; honouring + * it as a revocation would be worse. It keeps the cache and lets it run out its own clock. + */ + @Test + fun aBare304PreservesTheCacheWithoutSlidingFreshness() = runTest { + val responses = ArrayDeque( + listOf( + record("bounded-offline-cache"), + MosaicCustomerEntitlementTransportResult.NotModified, + ), + ) + val runtime = runtime({ _, _ -> responses.removeFirst() }) + runtime.refreshCustomerEntitlements() + + // Inside the original grace band, so the cache is preserved and marked stale... + deviceNow = mosaicContractInstantMillis("2026-08-04T12:05:00.000Z") + val result = runtime.refreshCustomerEntitlements() + assertEquals( + MosaicCustomerEntitlementCacheState.STALE_WITHIN_GRACE, + (result as MosaicCustomerEntitlementSyncResult.Unchanged).cacheState, + ) + + // ...and it still expires on its original schedule rather than an extended one. + deviceNow = mosaicContractInstantMillis("2026-08-06T12:05:00.000Z") + assertTrue(runtime.checkCustomerEntitlement("pro").state is MosaicCustomerEntitlementState.Unknown) + } + + /** The token is the sole customer selector, so no identifier is ever asserted in the request. */ + @Test + fun theSyncRequestNeverAssertsACustomerIdentifier() = runTest { + val bodies = mutableListOf() + val runtime = runtime({ _, body -> + bodies += body + record("bounded-offline-cache") + }) + runtime.identifyCustomer("fixture-customer-0001") + runtime.refreshCustomerEntitlements() + + assertTrue(bodies.isNotEmpty()) + // Even after identifyCustomer supplied one, it never reaches the wire: a caller that could + // assert a customer identifier is a caller that could try to read somebody else's access. + assertTrue(bodies.none { it.contains("billingCustomerId") }) + assertTrue(bodies.none { it.contains("fixture-customer-0001") }) + } + + /** An absent entitlement key is not a decision, so it reads unknown rather than inactive. */ + @Test + fun anAbsentEntitlementKeyReadsUnknown() = runTest { + val runtime = runtime({ _, _ -> record("bounded-offline-cache") }) + runtime.refreshCustomerEntitlements() + + val check = runtime.checkCustomerEntitlement("pro_lifetime") + + assertTrue(check.state is MosaicCustomerEntitlementState.Unknown) + assertEquals(0, check.sourceCount) + // The snapshot that could not answer is still identified, so a host can explain itself. + assertEquals(13L, check.snapshotVersion) + } + + // ------------------------------------------------------------------------------------------ + // Concurrency and authorization + // ------------------------------------------------------------------------------------------ + + /** Concurrent refreshes collapse onto one request; a foreground transition and a purchase coincide often. */ + @Test + fun concurrentRefreshesCollapseOntoOneRequest() = runTest { + val gate = CompletableDeferred() + val calls = AtomicInteger() + val tokenCalls = AtomicInteger() + val runtime = runtime( + transport = { _, _ -> + calls.incrementAndGet() + gate.await() + record("bounded-offline-cache") + }, + provider = { + tokenCalls.incrementAndGet() + MosaicCustomerAccessTokenResult.Issued(MosaicCustomerAccessToken("mosaic-customer-token-0001")) + }, + ) + + val refreshes = List(8) { async { runtime.refreshCustomerEntitlements() } } + gate.complete(Unit) + val results = refreshes.awaitAll() + + assertEquals(1, calls.get()) + assertEquals(1, tokenCalls.get()) + assertTrue(results.all { it is MosaicCustomerEntitlementSyncResult.Updated }) + } + + /** + * A 401 forces exactly one token refresh and exactly one retry. + * + * More retries would turn a revoked token into a storm against the host's own backend; fewer + * would make every ordinary token expiry look like a sign-out to the person using the app. + */ + @Test + fun oneRetryFollowsARefusedToken() = runTest { + val transportCalls = AtomicInteger() + val forced = mutableListOf() + val runtime = runtime( + transport = { _, _ -> + if (transportCalls.incrementAndGet() == 1) { + MosaicCustomerEntitlementTransportResult.Unauthorized + } else { + record("bounded-offline-cache") + } + }, + provider = { force -> + forced += force + MosaicCustomerAccessTokenResult.Issued( + MosaicCustomerAccessToken(if (force) "mosaic-customer-token-new1" else "mosaic-customer-token-old1"), + ) + }, + ) + + val result = runtime.refreshCustomerEntitlements() + + assertTrue(result is MosaicCustomerEntitlementSyncResult.Updated) + assertEquals(2, transportCalls.get()) + assertEquals(listOf(false, true), forced) + } + + /** A second refusal is authoritative: unavailable, no retry storm, and no customer switch. */ + @Test + fun aSecondRefusalReportsUnauthorizedWithoutSwitchingCustomer() = runTest { + val transportCalls = AtomicInteger() + val runtime = runtime( + transport = { _, _ -> + transportCalls.incrementAndGet() + MosaicCustomerEntitlementTransportResult.Unauthorized + }, + provider = { _ -> + MosaicCustomerAccessTokenResult.Issued(MosaicCustomerAccessToken("mosaic-customer-token-0001")) + }, + ) + + val result = runtime.refreshCustomerEntitlements() + + assertTrue(result is MosaicCustomerEntitlementSyncResult.Unauthorized) + assertEquals(2, transportCalls.get()) + val state = runtime.customerEntitlements.value as MosaicCustomerEntitlementSnapshotState.Unavailable + assertEquals(MosaicCustomerEntitlementUnavailableReason.UNAUTHORIZED, state.reason) + } + + // ------------------------------------------------------------------------------------------ + // Identity transitions + // ------------------------------------------------------------------------------------------ + + /** + * Signing out removes the previous customer's snapshot from memory and from disk. + * + * "Unreachable but present" is not sufficient: a snapshot file is a readable record of what + * somebody paid for, on a device they may have handed to someone else. + */ + @Test + fun signingOutClearsEverythingObservableAndPersisted() = runTest { + val runtime = runtime({ _, _ -> record("bounded-offline-cache") }) + runtime.refreshCustomerEntitlements() + assertTrue(folder.root.walkTopDown().any { it.name == "snapshot.json" }) + + runtime.signOutCustomer() + + assertSame( + MosaicCustomerEntitlementSnapshotState.SignedOut, + runtime.customerEntitlements.value, + ) + assertFalse(folder.root.walkTopDown().any { it.name == "snapshot.json" }) + val check = runtime.checkCustomerEntitlement("pro") + // Signed out is not "not entitled": there is no customer to answer about. + assertTrue(check.state is MosaicCustomerEntitlementState.Unavailable) + } + + /** + * An identity change publishes `Loading` before it reads anything. + * + * If the previous customer's grants remained observable for even one frame after sign-in, the + * new user would see somebody else's subscription unlock the app. + */ + @Test + fun identifyingPublishesLoadingBeforeAnythingIsRead() = runTest { + val gate = CompletableDeferred() + val reached = CompletableDeferred() + val responses = ArrayDeque(listOf(record("bounded-offline-cache"), record("test-source-sandbox-grant"))) + val runtime = runtime({ _, _ -> + val next = responses.removeFirst() + if (responses.isEmpty()) { + reached.complete(Unit) + gate.await() + } + next + }) + runtime.refreshCustomerEntitlements() + assertTrue(runtime.customerEntitlements.value is MosaicCustomerEntitlementSnapshotState.Available) + + val identifying = async { runtime.identifyCustomer("fixture-customer-0002") } + // Asserted once the new identity's request is genuinely in flight, so the assertion cannot + // pass merely because the coroutine had not started yet. + reached.await() + assertSame(MosaicCustomerEntitlementSnapshotState.Loading, runtime.customerEntitlements.value) + gate.complete(Unit) + identifying.await() + + val state = runtime.customerEntitlements.value as MosaicCustomerEntitlementSnapshotState.Available + assertEquals("fixture-customer-0002", state.snapshot.billingCustomerId) + } + + /** A response that lands after a sign-out belongs to nobody and is never published. */ + @Test + fun aResponseArrivingAfterSignOutIsDiscarded() = runTest { + val gate = CompletableDeferred() + val reached = CompletableDeferred() + val runtime = runtime({ _, _ -> + reached.complete(Unit) + gate.await() + record("bounded-offline-cache") + }) + + val inFlight = async { runtime.refreshCustomerEntitlements() } + // The request is already on the wire when the user signs out; that is the case worth testing. + reached.await() + runtime.signOutCustomer() + gate.complete(Unit) + inFlight.await() + + assertSame(MosaicCustomerEntitlementSnapshotState.SignedOut, runtime.customerEntitlements.value) + assertFalse(folder.root.walkTopDown().any { it.name == "snapshot.json" }) + } + + // ------------------------------------------------------------------------------------------ + // Persistence + // ------------------------------------------------------------------------------------------ + + /** A tampered cache file is discarded whole, and the result is unknown rather than inactive. */ + @Test + fun aTamperedCacheIsDiscardedWholeAndReportsUnknown() = runTest { + val runtime = runtime({ _, _ -> record("bounded-offline-cache") }) + runtime.refreshCustomerEntitlements() + + val stored = folder.root.walkTopDown().first { it.name == "snapshot.json" } + stored.writeText(stored.readText().replace("\"snapshotVersion\":13", "\"snapshotVersion\":99")) + + val reopened = runtime({ _, _ -> error("A cold start must read the cache before syncing.") }) + val check = reopened.checkCustomerEntitlement("pro") + + assertTrue(check.state is MosaicCustomerEntitlementState.Unknown) + assertEquals(MosaicCustomerEntitlementCacheState.INVALID, check.cacheState) + } + + /** A cold start serves the persisted snapshot without any network at all. */ + @Test + fun aColdStartServesThePersistedSnapshot() = runTest { + runtime({ _, _ -> record("bounded-offline-cache") }).refreshCustomerEntitlements() + + val reopened = runtime({ _, _ -> error("A cold start must read the cache before syncing.") }) + val check = reopened.checkCustomerEntitlement("pro") + + assertTrue(check.state is MosaicCustomerEntitlementState.Active) + assertEquals(13L, check.snapshotVersion) + } +} + +/** + * The feature is off unless the host opted in, and being off is never an answer about a person. + * + * This is the row that protects every existing application: an app that never heard of authoritative + * entitlements must keep behaving exactly as it did, and must never have a customer's access + * silently reported as inactive because Mosaic Billing was not configured. + */ +class CustomerEntitlementWiringTest { + private val client = MosaicHostedConfigurationClient( + transport = MosaicConfigurationTransport { MosaicConfigurationResponse.NotModified }, + cache = object : MosaicConfigurationCache { + override suspend fun read(): MosaicCachedConfiguration? = null + override suspend fun write(value: MosaicCachedConfiguration) = Unit + }, + ) + + @Test + fun anUnconfiguredClientReportsUnavailableEverywhereAndNeverInactive() = runTest { + val state = client.customerEntitlements.value as MosaicCustomerEntitlementSnapshotState.Unavailable + assertEquals(MosaicCustomerEntitlementUnavailableReason.NOT_CONFIGURED, state.reason) + + val check = client.checkCustomerEntitlement("pro") + assertTrue(check.state is MosaicCustomerEntitlementState.Unavailable) + + val refreshed = client.refreshCustomerEntitlements() + assertEquals( + MosaicCustomerEntitlementUnavailableReason.NOT_CONFIGURED, + (refreshed as MosaicCustomerEntitlementSyncResult.Unavailable).reason, + ) + + assertFalse(client.customerEntitlementDiagnostics().configured) + assertTrue( + client.restoreAndSyncCustomerEntitlements() is MosaicCustomerSyncResult.CustomerUnavailable, + ) + // Signing out an unconfigured client is a no-op rather than an error. + client.signOutCustomer() + } + + @Test + fun theProviderObservedCommerceApiIsUnchanged() { + // Authoritative entitlements are purely additive: the frozen provider-observed result type + // still exists with its own vocabulary, and nothing above renamed or deprecated it. + val provider = MockMosaicPurchaseProvider(MockMosaicPurchaseProvider.phase1Products()) + assertTrue(provider is MosaicPurchaseProvider) + } +} diff --git a/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementVectorTest.kt b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementVectorTest.kt new file mode 100644 index 00000000..70f5c030 --- /dev/null +++ b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerEntitlementVectorTest.kt @@ -0,0 +1,133 @@ +package dev.mosaic.sdk + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Cross-implementation conformance against the shared reference vectors. + * + * These tables are the reason the four Mosaic implementations can be said to agree at all: Go, + * Dart, Swift, and Kotlin each drive the same rows and must produce the same answers. The vectors + * are read from `packages/test-fixtures` rather than copied here, so a protocol change that alters + * a row fails this suite instead of silently diverging one platform. + */ +class CustomerEntitlementVectorTest { + private fun vectors(name: String): JsonObject = + JsonParser.parseString( + Files.readAllBytes(repositoryFile("packages/test-fixtures/src/$name")).toString(Charsets.UTF_8), + ).asJsonObject + + /** Every cache-decision row, including its `cacheAction`, which is the leak-prevention half. */ + @Test + fun cacheDecisionVectorTableIsSatisfied() { + val table = vectors("entitlement-cache-decision-vectors.json") + assertEquals( + MosaicCustomerEntitlementAcceptance.SUPPORTED_CONTRACT_VERSION, + table.get("contractVersion").asString, + ) + + var rows = 0 + table.getAsJsonArray("vectors").forEach { element -> + val vector = element.asJsonObject + val id = vector.get("id").asString + val cached = vector.get("cached").takeIf { !it.isJsonNull }?.asJsonObject?.let(::binding) + val decision = MosaicCustomerEntitlementAcceptance.decide(cached, binding(vector.getAsJsonObject("incoming"))) + + assertEquals(id, vector.get("decision").asString == "accept", decision.accepted) + assertEquals(id, vector.get("reason").asString, decision.reason) + assertEquals( + id, + when (vector.get("cacheAction").asString) { + "replace" -> MosaicCustomerCacheAction.REPLACE + "preserve" -> MosaicCustomerCacheAction.PRESERVE + "clear" -> MosaicCustomerCacheAction.CLEAR + else -> error("Unknown cacheAction in vector $id.") + }, + decision.action, + ) + // The one rule that matters most: no rejection may ever resolve to inactive. + assertTrue(id, vector.get("resultingAccessState").asString != "inactive") + if (!decision.accepted) assertNotNull(id, decision.rejection) + rows += 1 + } + assertEquals(11, rows) + } + + /** Every freshness row, including both clock-manipulation directions. */ + @Test + fun freshnessVectorTableIsSatisfied() { + val table = vectors("entitlement-freshness-vectors.json") + val policy = table.getAsJsonObject("policy") + // Drift guard: the shipped Kotlin tolerance is the contract's tolerance, not a copy of it. + assertEquals( + MosaicCustomerBoundedGracePolicy.CLOCK_SKEW_TOLERANCE_SECONDS, + policy.get("clockSkewToleranceSeconds").asLong, + ) + assertEquals("boundedGrace", policy.get("name").asString) + + var rows = 0 + table.getAsJsonArray("vectors").forEach { element -> + val vector = element.asJsonObject + val id = vector.get("id").asString + val snapshot = vector.getAsJsonObject("snapshot") + val window = MosaicCustomerEntitlementFreshnessWindow( + issuedAt = snapshot.get("issuedAt").asString, + refreshAfter = snapshot.get("refreshAfter").asString, + validUntil = snapshot.get("validUntil").asString, + staleGraceSeconds = snapshot.get("staleGraceSeconds").asInt, + ) + val evaluation = MosaicCustomerBoundedGracePolicy.evaluate( + window, + mosaicContractInstantMillis(vector.get("deviceNow").asString), + ) + // The vector table names states in the protocol's snake_case; the SDK vocabulary is the + // ratified cross-platform camelCase. The mapping is written out rather than derived so + // a renamed member fails here instead of being silently transliterated. + val expected = when (vector.get("state").asString) { + "fresh" -> MosaicCustomerEntitlementCacheState.FRESH + "refresh_recommended" -> MosaicCustomerEntitlementCacheState.REFRESH_RECOMMENDED + "stale_within_grace" -> MosaicCustomerEntitlementCacheState.STALE_WITHIN_GRACE + "expired" -> MosaicCustomerEntitlementCacheState.EXPIRED + else -> error("Unknown freshness state in vector $id.") + } + assertEquals(id, expected, evaluation.state) + // Clock unreliability is a diagnostic that forces expired-equivalent behaviour; it is + // deliberately not a fifth cache state, so it is asserted separately from the state. + assertEquals(id, id == "backwards-clock-before-issued-at", evaluation.clockUnreliable) + rows += 1 + } + assertEquals(12, rows) + } + + /** An unreliable clock must never be reported as a distinct cache state to a caller. */ + @Test + fun clockUnreliabilityIsNotACacheStateMember() { + assertFalse( + MosaicCustomerEntitlementCacheState.entries.any { it.wireName.contains("clock") }, + ) + // The seven ratified members, in order, identical on all three SDKs. + assertEquals( + listOf( + "fresh", "refreshRecommended", "staleWithinGrace", + "expired", "missing", "invalid", "differentCustomer", + ), + MosaicCustomerEntitlementCacheState.entries.map { it.wireName }, + ) + } + + private fun binding(value: JsonObject) = MosaicCustomerSnapshotBinding( + contractVersion = value.get("contractVersion").asString, + billingCustomerId = value.get("billingCustomerId").asString, + projectId = value.get("projectId").asString, + environmentId = value.get("environmentId").asString, + snapshotVersion = value.get("snapshotVersion").asLong, + asOfEpochMillis = mosaicContractInstantMillis(value.get("asOf").asString), + contentDigestValid = value.get("contentDigestValid").asBoolean, + ) +} diff --git a/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerRestoreSyncTest.kt b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerRestoreSyncTest.kt new file mode 100644 index 00000000..57f3556e --- /dev/null +++ b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/CustomerRestoreSyncTest.kt @@ -0,0 +1,189 @@ +package dev.mosaic.sdk + +import com.google.gson.JsonParser +import java.nio.file.Files +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class CustomerRestoreSyncTest { + @get:Rule + val folder = TemporaryFolder() + + private var deviceNow: Long? = mosaicContractInstantMillis("2026-07-28T12:01:00.000Z") + + private fun fixture(name: String): String = + Files.readAllBytes( + repositoryFile("protocol/fixtures/authoritative-entitlement/v1/snapshots/$name.json"), + ).toString(Charsets.UTF_8) + + private fun record(name: String) = MosaicCustomerEntitlementTransportResult.Record( + body = fixture(name), + entityTag = JsonParser.parseString(fixture(name)) + .asJsonObject.getAsJsonObject("payload").get("entityTag").asString, + ) + + private fun runtime(transport: MosaicCustomerEntitlementTransport) = MosaicCustomerEntitlementRuntime( + transport = transport, + cache = MosaicCustomerEntitlementCache(folder.root), + session = MosaicCustomerTokenSession({ + MosaicCustomerAccessTokenResult.Issued(MosaicCustomerAccessToken("mosaic-customer-token-0001")) + }), + trustedTime = { deviceNow }, + ) + + private fun restoringProvider(result: MosaicRestoreResult) = object : MosaicPurchaseProvider { + override suspend fun loadProducts(productIds: List) = + MosaicProductLoadResult.Loaded(emptyList()) + override suspend fun purchase(productId: String) = MosaicPurchaseResult.Cancelled(productId) + override suspend fun restore(): MosaicRestoreResult = result + override suspend fun activeEntitlements() = + MosaicActiveEntitlementsResult.Available(emptySet()) + } + + private fun detailedRestore(outcome: MosaicCommerceRecoveryOutcome) = MosaicRestoreResult.Detailed( + outcome = outcome, + entitlements = emptySet(), + metadata = MosaicCommerceRecoveryMetadata( + operationId = "operation-0001", + providerId = "google_play", + recoveryMode = "query_purchases", + completedAt = "2026-07-28T12:01:00.000Z", + diagnostics = emptyList(), + ), + ) + + /** + * A native recovery that Mosaic has not validated is never reported as restored. + * + * Reporting success here is the failure mode worth preventing: the person is told their purchase + * came back, the app asks Mosaic a moment later, and Mosaic answers `unknown`. + */ + @Test + fun anUnvalidatedRecoveryReportsValidationPending() = runTest { + val syncs = AtomicInteger() + val runtime = runtime({ _, _ -> + syncs.incrementAndGet() + record("bounded-offline-cache") + }) + // The first accepted snapshot is the state the restore starts from. + runtime.refreshCustomerEntitlements() + + val result = mosaicRestoreAndSyncCustomerEntitlements( + runtime, + restoringProvider(detailedRestore(MosaicCommerceRecoveryOutcome.RESTORED)), + ) + + assertTrue(result is MosaicCustomerSyncResult.NativeRecoveryCompleted) + assertTrue((result as MosaicCustomerSyncResult.NativeRecoveryCompleted).validationPending) + // Bounded: three attempts, not an open-ended wait behind a restore button. + assertEquals(1 + MOSAIC_CUSTOMER_RESTORE_POLL_ATTEMPTS, syncs.get()) + } + + /** Success requires an accepted snapshot that actually advanced past the pre-restore version. */ + @Test + fun anAcceptedNewerSnapshotReportsAuthoritativeUpdate() = runTest { + val responses = ArrayDeque(listOf(record("bounded-offline-cache"), record("newer-snapshot"))) + val runtime = runtime({ _, _ -> responses.removeFirst() }) + runtime.refreshCustomerEntitlements() + deviceNow = mosaicContractInstantMillis("2026-07-28T14:01:00.000Z") + + val result = mosaicRestoreAndSyncCustomerEntitlements( + runtime, + restoringProvider(detailedRestore(MosaicCommerceRecoveryOutcome.RESTORED)), + ) + + val updated = result as MosaicCustomerSyncResult.AuthoritativeEntitlementsUpdated + assertEquals(14L, updated.snapshot.snapshotVersion) + assertEquals(MosaicCustomerRestoreProviderOutcome.COMPLETED, updated.providerOutcome) + } + + /** A cancelled restore neither syncs nor reports anything about entitlement. */ + @Test + fun aCancelledRestoreDoesNotSync() = runTest { + val runtime = runtime({ _, _ -> error("A cancelled restore must not reach the network.") }) + val result = mosaicRestoreAndSyncCustomerEntitlements( + runtime, + restoringProvider(detailedRestore(MosaicCommerceRecoveryOutcome.CANCELLED)), + ) + assertTrue(result is MosaicCustomerSyncResult.Cancelled) + } + + /** A provider that found nothing still gets one sync, because another device may have bought. */ + @Test + fun nothingToRestoreStillSynchronizesOnce() = runTest { + val syncs = AtomicInteger() + val runtime = runtime({ _, _ -> + syncs.incrementAndGet() + record("bounded-offline-cache") + }) + runtime.refreshCustomerEntitlements() + + val result = mosaicRestoreAndSyncCustomerEntitlements( + runtime, + restoringProvider(detailedRestore(MosaicCommerceRecoveryOutcome.NOTHING_TO_RESTORE)), + ) + + assertTrue(result is MosaicCustomerSyncResult.NoAdditionalPurchases) + assertEquals(2, syncs.get()) + } + + /** + * A hung entitlement endpoint must never back-pressure the purchase path. + * + * The adapter emits its commerce updates into this collector, so if the collector could suspend + * the emitter, a hung Mosaic endpoint would stall a purchase — the one thing an entitlement + * refresh is never allowed to cost. + */ + @Test + fun aHungTransportNeverBlocksPurchaseUpdates() = runTest { + val hang = CompletableDeferred() + val scope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val runtime = runtime({ _, _ -> + hang.await() + record("bounded-offline-cache") + }) + val refresher = MosaicCustomerPurchaseRefresh(runtime, debounceMillis = 10, scope = scope) + val updates = MutableSharedFlow() + refresher.collect(updates) + runCurrent() + + val emitting = async { + withTimeout(5_000) { + repeat(200) { index -> updates.emit(update(index)) } + } + } + scope.advanceTimeBy(1_000) + runCurrent() + + // Every emission completed while the refresh is still stuck inside the transport. + emitting.await() + assertTrue(hang.isActive || !hang.isCompleted) + hang.complete(Unit) + refresher.close() + } + + private fun update(index: Int) = MosaicCommerceUpdate( + updateId = "update-$index", + operationId = "operation-$index", + providerId = "google_play", + mosaicProductId = "product-pro-monthly", + configuration = MosaicCommerceConfigurationReference("configuration-0001", "revision-0001"), + outcome = MosaicCommerceUpdateOutcome.PURCHASED, + transactionReference = "sha256:${"0".repeat(64)}", + activeEntitlements = emptySet(), + occurredAt = "2026-07-28T12:01:00.000Z", + ) +} diff --git a/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/TransactionObservationQueueTest.kt b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/TransactionObservationQueueTest.kt index b8eb931f..281f9c0a 100644 --- a/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/TransactionObservationQueueTest.kt +++ b/sdk/android/mosaic/src/test/kotlin/dev/mosaic/sdk/TransactionObservationQueueTest.kt @@ -3,6 +3,7 @@ package dev.mosaic.sdk import com.google.gson.JsonParser import java.nio.file.Files import java.time.Instant +import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.test.runTest @@ -366,6 +367,195 @@ class TransactionObservationQueueTest { MosaicTransactionObservationTransportResult.Retryable("service_temporarily_unavailable") } + // ------------------------------------------------------------------------------------------ + // Customer attribution + // ------------------------------------------------------------------------------------------ + + /** + * Records the token the transport saw at send time, so the binding can be asserted without a + * live socket. It answers retryably, which keeps the observation queued and lets one test flush + * the same entry twice under different sign-in states. + */ + private class AttributingTransport : MosaicTransactionObservationTransport { + private var source: MosaicCustomerTokenSource? = null + val headersPerSubmission = mutableListOf>() + + override fun bindCustomerTokenSource(source: MosaicCustomerTokenSource) { + this.source = source + } + + override suspend fun submit(observation: MosaicTransactionObservation): + MosaicTransactionObservationTransportResult { + headersPerSubmission += mosaicObservationHeaders("mosaic_sdk_key", source?.currentCustomerToken()) + return MosaicTransactionObservationTransportResult.Retryable("service_temporarily_unavailable") + } + } + + /** + * An identified user's purchase is bound to their Billing Customer. + * + * Without the header the submission still validates, but it anchors only to the store lineage — + * so a purchase a signed-in person just made cannot be attributed to the customer the host has + * already authenticated. + */ + @Test + fun `a submission carries the customer token when one is available at send time`() = runTest { + val queue = MosaicTransactionObservationQueue(MemoryObservationStore()) { NOW } + val transport = AttributingTransport() + val runtime = runtimeWith(queue, transport) + val calls = AtomicInteger() + val session = MosaicCustomerTokenSession({ + calls.incrementAndGet() + MosaicCustomerAccessTokenResult.Issued(MosaicCustomerAccessToken(CUSTOMER_TOKEN)) + }) + runtime.bindCustomerTokenSource { session.heldToken() } + // The entitlement path has already minted a token; the observation path only reuses it. + session.token() + + // Enqueued directly so the assertion is about the send-time binding rather than about when + // the fire-and-forget observe coroutine happens to run. + queue.enqueue(observation()) + runtime.flush() + + assertTrue(transport.headersPerSubmission.isNotEmpty()) + assertEquals(CUSTOMER_TOKEN, transport.headersPerSubmission.last()[MOSAIC_CUSTOMER_TOKEN_HEADER]) + // Reused, not re-minted: the flush added no provider call of its own. + assertEquals(1, calls.get()) + runtime.close() + } + + /** + * A cold-start flush never mints a token. + * + * The observation path is fire and forget and nothing waits on it, so it must not initiate + * network work against the host's backend — least of all at launch, before the app has any + * reason to believe a customer is signed in. An empty session yields no token, the submission + * goes out anonymously, and the provider is never called. + */ + @Test + fun `a cold-start flush never initiates a token mint`() = runTest { + val queue = MosaicTransactionObservationQueue(MemoryObservationStore()) { NOW } + val transport = AttributingTransport() + val runtime = runtimeWith(queue, transport) + val calls = AtomicInteger() + val session = MosaicCustomerTokenSession({ + calls.incrementAndGet() + MosaicCustomerAccessTokenResult.Issued(MosaicCustomerAccessToken(CUSTOMER_TOKEN)) + }) + runtime.bindCustomerTokenSource { session.heldToken() } + + queue.enqueue(observation()) + runtime.flush() + + assertEquals(0, calls.get()) + assertEquals(0, session.providerCallCount) + assertFalse(transport.headersPerSubmission.last().containsKey(MOSAIC_CUSTOMER_TOKEN_HEADER)) + runtime.close() + } + + /** + * A signed-out submission is anonymous, not blocked. + * + * The observation is still worth sending: it starts server-side validation, which is the whole + * point of the handoff, and attribution can only ever be a bonus on top of that. + */ + @Test + fun `a submission omits the customer token when the user is signed out`() = runTest { + val queue = MosaicTransactionObservationQueue(MemoryObservationStore()) { NOW } + val transport = AttributingTransport() + val runtime = runtimeWith(queue, transport) + val session = MosaicCustomerTokenSession({ MosaicCustomerAccessTokenResult.SignedOut }) + runtime.bindCustomerTokenSource { session.heldToken() } + + queue.enqueue(observation()) + runtime.flush() + + val headers = transport.headersPerSubmission.last() + assertFalse(headers.containsKey(MOSAIC_CUSTOMER_TOKEN_HEADER)) + // The submission itself is unaffected: the public SDK key still identifies the build. + assertEquals("Bearer mosaic_sdk_key", headers["Authorization"]) + runtime.close() + } + + /** + * The token is read at send time, not at enqueue time. + * + * A purchase very often completes before the user signs in, and an observation can sit in the + * durable queue across restarts and days offline. Capturing the token when the purchase happened + * would attribute nothing in exactly the case attribution is most wanted. + */ + @Test + fun `a token that arrives between enqueue and flush is used`() = runTest { + var clock = NOW + val queue = MosaicTransactionObservationQueue(MemoryObservationStore()) { clock } + val transport = AttributingTransport() + val runtime = MosaicTransactionObservationRuntime( + queue = queue, + transport = transport, + enabled = true, + now = { clock }, + identity = { OBSERVATION_ID }, + ) + var signedIn = false + runtime.bindCustomerTokenSource { + if (signedIn) MosaicCustomerAccessToken(CUSTOMER_TOKEN) else null + } + + // Enqueued and flushed while signed out; the entry stays queued because the fake retries. + queue.enqueue(observation()) + runtime.flush() + assertFalse(transport.headersPerSubmission.first().containsKey(MOSAIC_CUSTOMER_TOKEN_HEADER)) + + // The user signs in, and the delivery is retried later — the ordinary sequence when a + // purchase completes before sign-in, or when the first attempt was offline. + signedIn = true + clock += 60 * 60 * 1000 + runtime.flush() + + assertEquals(2, transport.headersPerSubmission.size) + assertEquals(CUSTOMER_TOKEN, transport.headersPerSubmission.last()[MOSAIC_CUSTOMER_TOKEN_HEADER]) + runtime.close() + } + + /** + * The credential never comes to rest. + * + * The queue is a durable file that outlives the process; a token written beside an observation + * would be a bearer credential sitting on disk long after it expired, which is precisely what + * holding tokens in memory only is meant to prevent. + */ + @Test + fun `the customer token never reaches the persisted queue or the diagnostics`() = runTest { + val directory = Files.createTempDirectory("mosaic-observations").toFile() + val store = MosaicFileTransactionObservationStore(directory, "namespace") + val queue = MosaicTransactionObservationQueue(store) { NOW } + val transport = AttributingTransport() + val runtime = runtimeWith(queue, transport) + runtime.bindCustomerTokenSource { MosaicCustomerAccessToken(CUSTOMER_TOKEN) } + + queue.enqueue(observation()) + val diagnostics = runtime.flush() + + assertEquals(CUSTOMER_TOKEN, transport.headersPerSubmission.last()[MOSAIC_CUSTOMER_TOKEN_HEADER]) + val persisted = directory.walkTopDown().filter { it.isFile }.joinToString("\n") { it.readText() } + assertTrue(persisted.isNotBlank()) + assertFalse(persisted.contains(CUSTOMER_TOKEN)) + assertFalse(diagnostics.toString().contains(CUSTOMER_TOKEN)) + runtime.close() + directory.deleteRecursively() + } + + private fun runtimeWith( + queue: MosaicTransactionObservationQueue, + transport: MosaicTransactionObservationTransport, + ) = MosaicTransactionObservationRuntime( + queue = queue, + transport = transport, + enabled = true, + now = { NOW }, + identity = { OBSERVATION_ID }, + ) + private fun observation( observationId: String = OBSERVATION_ID, submissionId: String = "google_${DIGEST}_purchased", @@ -394,6 +584,7 @@ class TransactionObservationQueueTest { private companion object { val NOW: Long = Instant.parse("2026-07-27T12:00:00.000Z").toEpochMilli() const val OBSERVATION_ID = "observation_0f2b6c1a" + const val CUSTOMER_TOKEN = "mosaic-customer-token-attribution" /** * The canonical Google reference, read from the shared cross-SDK vectors rather than pinned diff --git a/sdk/flutter/CHANGELOG.md b/sdk/flutter/CHANGELOG.md index 730cdc39..acca27c1 100644 --- a/sdk/flutter/CHANGELOG.md +++ b/sdk/flutter/CHANGELOG.md @@ -2,6 +2,69 @@ ## Unreleased +- Add authoritative entitlements (Authoritative Entitlement Contract v1 and + Customer Access Token Contract v1) under a purely additive `MosaicCustomer…` + namespace. No provider-observed symbol changed, and Placement targeting + continues to read provider-observed entitlements. `Mosaic.configure` gains + `customerTokenProvider`, `customerEntitlementCache`, + `customerEntitlementTransport`, `customerEntitlementSettings`, and `clock`. + The sync request is a `POST` carrying the canonical `entitlementSyncRequest` + record, which is where contract negotiation lives. It never carries a + `billingCustomerId`: the Customer Access Token is the sole customer selector, + so a hint could only narrow the answer or fail the request. +- Mosaic Billing **requires an application backend**. There is no anonymous + mode: your server mints the Customer Access Token, and a client-generated + installation identifier can never create or select a Billing Customer. + Without a token provider the subsystem is never constructed and every + authoritative read reports `unavailable`, never `inactive`. +- Reading is closed and whole-document. Any unknown contract version, record + type, field, or enumeration member rejects the entire record; the one + exception is an unrecognized `entitlementKey`, which is Project data and is + carried. Every rejection yields `unknown` and preserves the cache, except a + customer, Project, or Environment binding mismatch, which clears it and emits + a high-severity diagnostic. +- Cache acceptance follows the normative order — contract version, customer + binding, content digest, snapshot-version monotonicity, `asOf` regression — + and is atomic: a reader never keeps the entries it understood from a document + it rejected. The cache-decision, freshness, and snapshot-digest reference + vectors in `packages/test-fixtures` are executed as conformance tables so + Dart cannot drift from Go, Swift, and Kotlin. +- Bounded grace is the shipped offline policy, driven by the server-issued + `refreshAfter`, `validUntil`, and `staleGraceSeconds` (strict is the same + fields with a zero grace window). The canonical `snapshotUnchanged` record is + the only thing that slides the freshness window; a bodyless `304` preserves + the cache and re-anchors trusted time but does not move the window, because + nothing in a bodyless response is a contract-pinned carrier of refreshed + windows. Clock-skew tolerance is 60 seconds and a backwards device clock + forces expired-equivalent behaviour. +- Customer Access Tokens are held in memory only, never persisted, never parsed, + and never present in a log or diagnostic — diagnostics carry `tokenId`. A + `401` forces exactly one refresh and one retry per token generation; a token + provider failure enters a 30-second cooldown and reports `unavailable`. +- The snapshot cache lives in the application **cache** directory, never the + support directory, so it cannot travel in a device backup. It is keyed per + customer in the path, written atomically, checksummed, decoded with strict + closed keys, and degrades to memory with an + `entitlements.cache_unavailable` diagnostic when no cache directory exists. + An identity change deletes sibling records. +- Transaction Observation submissions now carry the current Customer Access + Token in a `Mosaic-Customer-Token` header when one is held. This is the + evidence rung that binds an identified user's purchase to their Billing + Customer server-side; without it a validated purchase can only anchor to a + purchase-anchored customer. It is transport-level only — the Billing + Ingestion v1 observation record is unchanged. The token is read at send time + rather than enqueue time, so a token minted after the purchase still binds a + retry, it is never persisted with the queue, and it never appears in a + diagnostic. Reading it never mints: observation delivery is fire-and-forget, + so an absent or expired token simply omits the header, which is a valid + anonymous submission. +- Add `restorePurchasesAndSync()`, which reports the native provider outcome and + Mosaic's authoritative outcome separately. `restored` requires an accepted + snapshot at a higher version; otherwise it is `validationPending` within a + bound of 3 attempts over roughly 6 seconds. Purchase and restore paths are + never blocked: the authoritative refresh they trigger is unawaited and never + alters a presentation result. + - Add the opt-in Transaction Observation handoff (Billing Ingestion Contract v1). The SDK submits and persists the canonical `clientTransactionObservation` record — envelope, `sourceAuthority: client_observation`, typed diff --git a/sdk/flutter/README.md b/sdk/flutter/README.md index b4c0e5df..9e973026 100644 --- a/sdk/flutter/README.md +++ b/sdk/flutter/README.md @@ -207,6 +207,135 @@ How it behaves: path. - Every storage and network failure degrades to a stable safe code and never throws into the host application. +- When authoritative entitlements are configured and a Customer Access Token is + held, the submission carries it in a `Mosaic-Customer-Token` header so the + purchase binds to the identified Billing Customer rather than only to a + purchase-anchored one. The contract record is unchanged; this is transport + only. The token is read at send time, never stored with the queue, and never + logged, and reading it never mints one — a signed-out submission simply omits + the header. + +## Authoritative entitlements (Mosaic Billing) + +Off by default, and **it requires an application backend**. Mosaic Billing has +no anonymous mode: a Customer Access Token is minted by your own server, and a +client-generated installation identifier can never create or select a Billing +Customer. Without a `customerTokenProvider` the subsystem is never constructed +and every authoritative read reports `unavailable`. + +```dart +final mosaic = Mosaic.configure( + publicSdkKey: 'public_sdk_key', + baseUrl: Uri.parse('https://mosaic.example.com'), + purchaseProvider: provider, + customerTokenProvider: (request) async { + // Your backend mints the token. Return null when nobody is signed in. + final minted = await yourBackend.mintMosaicToken( + userId: request.userId, + forceRefresh: request.forceRefresh, + ); + if (minted == null) return null; + return MosaicCustomerToken( + value: minted.token, + tokenId: minted.tokenId, + expiresAt: minted.expiresAt, + ); + }, +); + +await mosaic.identify('user_1042'); +await mosaic.refreshCustomerEntitlements(); + +final pro = mosaic.checkCustomerEntitlement('pro'); +switch (pro.state) { + case MosaicCustomerAccessState.active: // grant, and show pro.isStale + case MosaicCustomerAccessState.inactive: // Mosaic looked and found nothing + case MosaicCustomerAccessState.unknown: // Mosaic could not find out + case MosaicCustomerAccessState.unavailable: // Mosaic could not answer +} +``` + +### Authoritative versus provider-observed + +The two coexist and answer different questions. Neither replaces the other, and +no provider-observed symbol changed. + +| | Provider-observed (`MosaicEntitlement`, `activeEntitlements()`) | Authoritative (`MosaicCustomer…`) | +| --- | --- | --- | +| Answers | What did the store just tell this device? | What has Mosaic validated, and why? | +| Source | StoreKit, Play Billing, or RevenueCat, on this device | Mosaic's projection of validated provider facts | +| Survives reinstall | Only after a native restore | Yes, it is server state | +| Placement targeting | Yes — unchanged | No, deliberately (Phase 9B keeps targeting on provider-observed state) | +| Result vocabulary | `MosaicEntitlement` set | Four access states plus an explanation | + +### The rule that matters most + +**Any rejection yields `unknown` and preserves the cache. Never `inactive`.** + +`inactive` means Mosaic looked, found no qualifying source, and is confident. It +is never inferred from a network failure, a timeout, an expired cache, an +unknown field, a digest mismatch, or a signed-out customer. Every one of those +is `unknown` or `unavailable`. A reader that collapsed them into "you do not +have it" would turn every outage into a mass revocation experienced by paying +customers. + +### Offline behaviour + +Bounded grace is the shipped policy, driven entirely by the server-issued +`refreshAfter`, `validUntil`, and `staleGraceSeconds`: + +| Window | `cacheState` | Behaviour | +| --- | --- | --- | +| before `refreshAfter` | `fresh` | Serve; do not refresh. | +| to `validUntil` | `refreshRecommended` | Fully valid; refresh opportunistically. | +| to `validUntil + staleGraceSeconds` | `staleWithinGrace` | Previously active Entitlements stay active and `isStale` is `true` — **surface it**. | +| after that | `expired` | Report `unknown`. Never `inactive`. | + +The window moves only when the server sends the canonical `snapshotUnchanged` +record, which carries the refreshed bounds. A bodyless `304` preserves the cache +and re-anchors trusted time but does not extend it. + +Clock-skew tolerance is 60 seconds and is applied in the direction that favours +the user. A device clock earlier than issuance by more than the tolerance is +unreliable, which forces expired-equivalent behaviour rather than becoming a +fifth state. + +### Tokens + +- Held in **memory only**. Never written to disk, preferences, or a keychain, + and never present in a log, diagnostic, crash report, or telemetry — + diagnostics carry `tokenId`. +- Never parsed. The token is opaque. +- Refreshed proactively 60 seconds before expiry, once per generation on a + `401`, and behind a single-flight so concurrent callers make one request. +- A provider failure enters a 30-second cooldown and reports `unavailable`. A + backend that cannot mint a token has not revoked anyone's subscription. +- Signing out discards the token **and** clears the entitlement cache. + +### Restore + +```dart +final result = await mosaic.restorePurchasesAndSync(); +``` + +It reports two independent axes. `MosaicCustomerEntitlementsRestored` exists +only once an accepted snapshot at a higher version reflects the restore; a +successful native restore Mosaic has not yet validated is +`MosaicCustomerRestoreValidationPending`, within a bound of 3 attempts over +roughly 6 seconds. The purchase and restore paths are never blocked by any of +this: authoritative refreshes triggered by a completed purchase are unawaited +and never alter a presentation result. + +### What it is not + +- **Not a bearer credential.** A snapshot is a read model. Possessing it + authorizes nothing, and your backend must never accept one presented by a + client as proof of access. +- **Not a replacement for server authorization.** The cache supports UI + continuity and feature gating; protected resources are authorized by your own + server. +- **Not placement targeting input.** Targeting continues to read + provider-observed state, unchanged. ## Requirements diff --git a/sdk/flutter/lib/mosaic_sdk.dart b/sdk/flutter/lib/mosaic_sdk.dart index 34e4a9c8..0319e4bd 100644 --- a/sdk/flutter/lib/mosaic_sdk.dart +++ b/sdk/flutter/lib/mosaic_sdk.dart @@ -10,6 +10,17 @@ export 'src/configuration_cache.dart'; export 'src/configuration_client.dart'; export 'src/configuration_delivery.dart'; export 'src/configuration_transport.dart'; +export 'src/customer_authentication.dart' + show + MosaicCustomerToken, + MosaicCustomerTokenDiagnostics, + MosaicCustomerTokenProvider, + MosaicCustomerTokenRequest; +export 'src/customer_entitlement_cache.dart'; +export 'src/customer_entitlement_runtime.dart'; +export 'src/customer_entitlement_transport.dart'; +export 'src/customer_entitlements.dart'; +export 'src/customer_restore_sync.dart'; export 'src/experiment_analytics.dart'; export 'src/experiment_assignment.dart'; export 'src/experiment_assignment_store.dart'; diff --git a/sdk/flutter/lib/src/configuration.dart b/sdk/flutter/lib/src/configuration.dart index cae9c827..62516b21 100644 --- a/sdk/flutter/lib/src/configuration.dart +++ b/sdk/flutter/lib/src/configuration.dart @@ -11,6 +11,12 @@ import 'commerce_configuration_transport.dart'; import 'configuration_cache.dart'; import 'configuration_client.dart'; import 'configuration_transport.dart'; +import 'customer_authentication.dart'; +import 'customer_entitlement_cache.dart'; +import 'customer_entitlement_runtime.dart'; +import 'customer_entitlement_transport.dart'; +import 'customer_entitlements.dart'; +import 'customer_restore_sync.dart'; import 'experiment_analytics.dart'; import 'experiment_assignment_store.dart'; import 'placement_decision.dart'; @@ -142,7 +148,9 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { MosaicExperimentAnalyticsSink? experimentAnalyticsSink, MosaicExperimentAssignmentStore? experimentAssignmentStore, MosaicTransactionObservationRuntime? transactionObservationRuntime, + MosaicCustomerEntitlementRuntime? customerEntitlementRuntime, }) : _configurationClient = configurationClient, + _customerEntitlements = customerEntitlementRuntime, _identityController = identityController, _analyticsRuntime = analyticsRuntime, _experimentAnalyticsSink = experimentAnalyticsSink, @@ -159,11 +167,24 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { void _observeCommerceUpdates() { final runtime = _transactionObservationRuntime; final router = _commerceProviderRouter; - if (runtime == null || router == null) return; + if (router == null) return; + if (runtime == null) { + final entitlements = _customerEntitlements; + if (entitlements == null) return; + _commerceUpdateSubscription = router.commerceUpdates.listen((update) { + if (update.outcome == MosaicCommerceUpdateOutcome.purchased) { + entitlements.refreshInBackground(); + } + }); + return; + } _commerceUpdateSubscription = router.commerceUpdates.listen((update) { // Phase 9A observes a completed purchase only. Every other outcome, // including pending and entitlement changes, stays on the device. if (update.outcome != MosaicCommerceUpdateOutcome.purchased) return; + // Authoritative state moves server-side once validation lands. The + // refresh is unawaited so it can never delay or alter a purchase. + _customerEntitlements?.refreshInBackground(); runtime.observeProviderUpdate( providerId: update.providerId, transactionReference: update.transactionReference, @@ -213,6 +234,12 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { MosaicTransactionObservationStorage transactionObservationStorage = const MosaicFileTransactionObservationStorage(), MosaicTransactionObservationTransport? transactionObservationTransport, + MosaicCustomerTokenProvider? customerTokenProvider, + MosaicCustomerEntitlementCache? customerEntitlementCache, + MosaicCustomerEntitlementTransport? customerEntitlementTransport, + MosaicCustomerEntitlementSettings customerEntitlementSettings = + const MosaicCustomerEntitlementSettings(), + DateTime Function() clock = _utcNow, }) { final factories = commerceProviderFactories.toList(growable: false); final router = factories.isEmpty @@ -286,6 +313,39 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { environmentEnabled: analyticsEnvironmentSettings.collectionEnabled, hostEnabled: analyticsHostEnabled, ); + // Authoritative entitlements require an application backend to mint a + // Customer Access Token. Without a token provider the subsystem is never + // constructed, and every authoritative read reports unavailable rather + // than guessing. + final customerEntitlements = + customerTokenProvider == null || resolvedBaseUrl == null + ? null + : MosaicCustomerEntitlementRuntime( + baseUrl: resolvedBaseUrl, + publicSdkKey: configuration.publicSdkKey, + transport: customerEntitlementTransport ?? + const MosaicIoCustomerEntitlementTransport(), + cache: customerEntitlementCache ?? + MosaicFileCustomerEntitlementCache(), + tokenProvider: customerTokenProvider, + settings: customerEntitlementSettings, + timeout: configuration.requestTimeout, + clock: clock, + onDiagnostic: onDiagnostic == null + ? null + : (code, {required bool severe}) => onDiagnostic( + MosaicDiagnostic( + code: code, + severity: severe + ? MosaicDiagnosticSeverity.error + : MosaicDiagnosticSeverity.warning, + message: severe + ? 'Authoritative entitlement state was cleared.' + : 'Authoritative entitlement state is ' + 'unconfirmed.', + ), + ), + ); // Off by default: absent opt-in means the subsystem is never constructed, // so nothing is observed, queued, persisted, or submitted. A Store Platform // is required because it determines the contract's reference kind; without @@ -298,6 +358,12 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { baseUrl: resolvedBaseUrl, publicSdkKey: configuration.publicSdkKey, timeout: configuration.requestTimeout, + // Read at send time, so a token minted after the purchase + // still binds it, and a signed-out submission simply omits the + // header rather than waiting for one. + customerToken: customerEntitlements == null + ? null + : customerEntitlements.currentCustomerTokenForSubmission, )); final observationRuntime = transactionObservation == null || resolvedStorePlatform == null || @@ -322,6 +388,7 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { return Mosaic._( configuration: configuration, purchaseProvider: resolvedPurchaseProvider, + customerEntitlementRuntime: customerEntitlements, transactionObservationRuntime: observationRuntime, identityController: identityController, analyticsRuntime: runtime, @@ -379,6 +446,8 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { final MosaicExperimentAssignmentStore? _experimentAssignmentStore; final MosaicCommerceProviderRouter? _commerceProviderRouter; final MosaicTransactionObservationRuntime? _transactionObservationRuntime; + final MosaicCustomerEntitlementRuntime? _customerEntitlements; + MosaicTransactionObservationSink? _purchaseSink; StreamSubscription? _commerceUpdateSubscription; bool _observingLifecycle = false; @@ -409,7 +478,11 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { MosaicAnalyticsCapabilityReport(); /// Loads or creates the stable app-install-scoped anonymous identity. - Future loadIdentity() => _identityController.load(); + Future loadIdentity() async { + final state = await _identityController.load(); + await _customerEntitlements?.bindIdentity(state); + return state; + } /// Sets the host application's user identity. This may intentionally change /// assignments for Rule Sets using an identified-user policy. @@ -419,6 +492,10 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { await _analyticsRuntime?.identityDidChange( effectiveUserChange: previous.userId != result.userId, ); + // Bumps the generation, cancels in-flight work, and clears authoritative + // state before any read can observe it. Installation identity is + // preserved: it is Phase 6 state and it is evidence, never an anchor. + await _customerEntitlements?.bindIdentity(result); notifyListeners(); return result; } @@ -459,6 +536,8 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { effectiveUserChange: previous.userId != null || previous.attributes.isNotEmpty, ); + // Signing out discards the token and the cached snapshot together. + await _customerEntitlements?.clearCustomer(); notifyListeners(); return result; } @@ -467,6 +546,7 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { Future resetInstallationIdentity() async { final result = await _identityController.rotateInstallation(); await _analyticsRuntime?.identityDidChange(effectiveUserChange: true); + await _customerEntitlements?.clearCustomer(); notifyListeners(); return result; } @@ -511,8 +591,18 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { /// The renderer's fire-and-forget observation sink, or `null` when the /// opt-in is absent. It exposes no way to read a validation outcome, because /// a Transaction Observation is a trigger and never proof. - MosaicTransactionObservationSink? get transactionObservations => - _transactionObservationRuntime; + MosaicTransactionObservationSink? get transactionObservations { + final runtime = _transactionObservationRuntime; + final entitlements = _customerEntitlements; + if (entitlements == null) return runtime; + // The renderer's purchase path is also where authoritative state becomes + // stale. Decorating the sink hooks it without the renderer knowing that + // authoritative entitlements exist. + return _purchaseSink ??= _MosaicPurchaseSignalSink( + delegate: runtime, + onPurchaseObserved: entitlements.refreshInBackground, + ); + } /// Host consent switch for the Transaction Observation handoff. Turning it /// off clears the queue and deletes the persisted document. @@ -547,6 +637,102 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { : await runtime.diagnostics(); } + // --------------------------------------------------------------------- + // Authoritative entitlements + // --------------------------------------------------------------------- + + /// Mosaic's authoritative view of what the signed-in Billing Customer may + /// access, or `null` when no Customer Access Token provider is configured. + /// + /// This is deliberately separate from the provider-observed entitlements a + /// Commerce Provider reports. Provider-observed state answers "what did the + /// store just tell this device"; authoritative state answers "what has + /// Mosaic validated, and why". + MosaicCustomerEntitlementRuntime? get customerEntitlements => + _customerEntitlements; + + /// Sealed transitions of authoritative state, including the `Cleared` events + /// an identity change produces. + Stream get customerEntitlementUpdates => + _customerEntitlements?.updates ?? + const Stream.empty(); + + /// Answers one access question from memory. It performs no I/O and never + /// returns a bare boolean. + MosaicCustomerEntitlementCheck checkCustomerEntitlement( + String entitlementKey, + ) { + final runtime = _customerEntitlements; + if (runtime == null) { + // Billing disabled maps to unavailable on every surface, never inactive. + return MosaicCustomerEntitlementCheck( + entitlementKey: entitlementKey, + state: MosaicCustomerAccessState.unavailable, + cacheState: MosaicEntitlementCacheState.missing, + sourceCount: 0, + endKnown: false, + isStale: false, + isTestSource: false, + reasonCode: 'entitlements.disabled', + ); + } + return runtime.checkCustomerEntitlement(entitlementKey); + } + + Future + refreshCustomerEntitlements() async { + final runtime = _customerEntitlements; + return runtime == null + ? const MosaicCustomerEntitlementUnavailable( + reasonCode: 'entitlements.disabled', + ) + : await runtime.refresh(); + } + + MosaicCustomerEntitlementDiagnostics get customerEntitlementDiagnostics => + _customerEntitlements?.diagnostics ?? + const MosaicCustomerEntitlementDiagnostics( + enabled: false, + cacheState: MosaicEntitlementCacheState.missing, + identityGeneration: 0, + staleGraceSeconds: 0, + token: MosaicCustomerTokenDiagnostics( + hasToken: false, + identityGeneration: 0, + ), + lastReasonCode: 'entitlements.disabled', + ); + + /// Restores through the Commerce Provider, hands observations to Mosaic for + /// validation, and reports what Mosaic can actually confirm. + /// + /// It reports `restored` only once an accepted snapshot at a higher version + /// reflects the restore. A successful native restore whose facts are still + /// being validated is `validationPending`, which is honest rather than + /// hopeful. + Future restorePurchasesAndSync() async { + final runtime = _customerEntitlements; + final requestedAt = DateTime.now().toUtc(); + if (runtime == null) { + return MosaicCustomerRestoreFailed( + providerOutcome: MosaicCustomerRestoreProviderOutcome.notAttempted, + requestedAt: requestedAt, + stages: const [], + uncertainty: MosaicCustomerUncertainty( + reason: MosaicCustomerUncertaintyReason.projectionFailed, + since: requestedAt, + expectedResolution: MosaicCustomerExpectedResolution.customerAction, + ), + reasonCode: 'entitlements.disabled', + ); + } + return MosaicCustomerRestoreCoordinator( + purchaseProvider: purchaseProvider, + entitlements: runtime, + observations: _transactionObservationRuntime, + ).restorePurchasesAndSync(); + } + MosaicConfigurationCapabilityRequest get capabilityRequest => MosaicConfigurationCapabilityRequest( applicationVersion: configuration.applicationVersion, @@ -599,6 +785,7 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { if (_transactionObservationRuntime case final runtime?) { unawaited(runtime.disposeRuntime().catchError((Object _) {})); } + _customerEntitlements?.dispose(); _commerceProviderRouter?.deactivate(); if (_analyticsRuntime case final runtime?) { // Disposal must never surface storage failures as uncaught zone errors. @@ -608,6 +795,44 @@ final class Mosaic extends ChangeNotifier with WidgetsBindingObserver { } } +DateTime _utcNow() => DateTime.now().toUtc(); + +/// Wraps the renderer's observation sink so a completed purchase also triggers +/// an unawaited authoritative refresh. It returns `void` for the same reason +/// the sink does: a billing handoff can never be awaited from, block, or alter +/// a purchase flow. +final class _MosaicPurchaseSignalSink + implements MosaicTransactionObservationSink { + const _MosaicPurchaseSignalSink({ + required this.delegate, + required this.onPurchaseObserved, + }); + + final MosaicTransactionObservationSink? delegate; + final void Function() onPurchaseObserved; + + @override + void observePurchaseResult({ + required String? providerId, + required String? transactionReference, + String? providerOrderReference, + String? mosaicProductId, + String? purchaseAttemptId, + }) { + try { + delegate?.observePurchaseResult( + providerId: providerId, + transactionReference: transactionReference, + providerOrderReference: providerOrderReference, + mosaicProductId: mosaicProductId, + purchaseAttemptId: purchaseAttemptId, + ); + } finally { + onPurchaseObserved(); + } + } +} + String get _analyticsPlatform => switch (defaultTargetPlatform) { TargetPlatform.iOS => 'ios', _ => 'android', diff --git a/sdk/flutter/lib/src/customer_authentication.dart b/sdk/flutter/lib/src/customer_authentication.dart new file mode 100644 index 00000000..25ffd549 --- /dev/null +++ b/sdk/flutter/lib/src/customer_authentication.dart @@ -0,0 +1,342 @@ +import 'dart:async'; + +/// Proactive refresh margin. A token within this margin of expiry is treated +/// as already expired, so a request is never sent with a credential that dies +/// in flight. +const Duration mosaicCustomerTokenExpiryMargin = Duration(seconds: 60); + +/// How long the holder waits after a token-provider failure before asking +/// again. Without it, an application backend outage becomes a request storm +/// from every device at once. +const Duration mosaicCustomerTokenFailureCooldown = Duration(seconds: 30); + +/// An opaque Customer Access Token minted by the host application's backend. +/// +/// The SDK never parses [value] and nothing may be inferred from it. It is held +/// in memory only: it is never written to disk, preferences, or a keychain, and +/// it never appears in a log, a diagnostic, a crash report, or telemetry. +/// Diagnostics carry [tokenId] instead. +final class MosaicCustomerToken { + MosaicCustomerToken({ + required this.value, + required this.tokenId, + required DateTime expiresAt, + }) : expiresAt = expiresAt.toUtc() { + if (value.isEmpty) { + throw ArgumentError.value( + '', + 'value', + 'A Customer Access Token must not be empty.', + ); + } + if (tokenId.isEmpty || tokenId.length > 128) { + throw ArgumentError.value(tokenId, 'tokenId', 'Invalid token handle.'); + } + } + + /// The opaque credential presented in `Authorization: Bearer`. + final String value; + + /// Safe handle for diagnostics and support. It is not the token. + final String tokenId; + final DateTime expiresAt; + + bool isUsableAt(DateTime now) => + now.toUtc().isBefore(expiresAt.subtract(mosaicCustomerTokenExpiryMargin)); + + /// Deliberately redacted. A token that can be printed will eventually be + /// printed. + @override + String toString() => 'MosaicCustomerToken(tokenId: $tokenId)'; +} + +/// What the SDK tells the host application when it needs a token. +final class MosaicCustomerTokenRequest { + const MosaicCustomerTokenRequest({ + required this.forceRefresh, + required this.identityGeneration, + this.userId, + this.installationId, + }); + + /// True when the previous token was refused by the server. The host must + /// mint a new token rather than return a cached one. + final bool forceRefresh; + + /// The Phase 6 identity generation this request belongs to. A token minted + /// for an older generation is discarded rather than used. + final int identityGeneration; + + /// The host's own user identifier, when one is set. `null` means signed out, + /// and a signed-out customer is `unavailable`, never `inactive`. + final String? userId; + + /// Installation identity, supplied as association evidence and a restore + /// hint. It can never create or select a Billing Customer. + final String? installationId; +} + +/// Mints a Customer Access Token through the host application's own backend. +/// +/// Mosaic Billing requires an application backend: there is no anonymous mode, +/// because a client-generated installation identifier is guessable and letting +/// it select a Billing Customer would let anyone read someone else's +/// entitlements. Returning `null` means "no customer is signed in", which the +/// SDK reports as `unavailable`. +typedef MosaicCustomerTokenProvider = Future Function( + MosaicCustomerTokenRequest request, +); + +/// Token state a host may safely display or log. +final class MosaicCustomerTokenDiagnostics { + const MosaicCustomerTokenDiagnostics({ + required this.hasToken, + required this.identityGeneration, + this.tokenId, + this.expiresAt, + this.lastSafeCode, + this.cooldownUntil, + }); + + final bool hasToken; + final int identityGeneration; + + /// The safe handle. The token value itself is structurally unavailable here. + final String? tokenId; + final DateTime? expiresAt; + final String? lastSafeCode; + final DateTime? cooldownUntil; +} + +/// The outcome of asking for a usable token. +sealed class MosaicCustomerTokenResolution { + const MosaicCustomerTokenResolution(); +} + +final class MosaicCustomerTokenResolved extends MosaicCustomerTokenResolution { + const MosaicCustomerTokenResolved(this.token, {required this.generation}); + + final MosaicCustomerToken token; + + /// The token generation this value belongs to. A caller that observes a + /// `401` reports this back, so a refresh another caller already performed is + /// not performed a second time. + final int generation; +} + +/// No token could be obtained. Never a claim about the customer's access. +final class MosaicCustomerTokenUnavailable + extends MosaicCustomerTokenResolution { + const MosaicCustomerTokenUnavailable(this.reasonCode); + + final String reasonCode; +} + +typedef MosaicCustomerTokenClock = DateTime Function(); + +DateTime _systemClock() => DateTime.now().toUtc(); + +/// Holds the in-memory Customer Access Token. +/// +/// This type is internal to the SDK and is deliberately not exported: hosts +/// supply a [MosaicCustomerTokenProvider] and never manage token lifetime +/// themselves. +/// +/// It guarantees four things the contract requires: one in-flight mint at a +/// time, tokens bound to the identity generation that requested them, a bounded +/// cooldown after a provider failure, and no path by which a token value +/// reaches persistence or diagnostics. +final class MosaicCustomerTokenHolder { + MosaicCustomerTokenHolder({ + required MosaicCustomerTokenProvider? provider, + MosaicCustomerTokenClock clock = _systemClock, + Duration cooldown = mosaicCustomerTokenFailureCooldown, + }) : _provider = provider, + _clock = clock, + _cooldown = cooldown; + + final MosaicCustomerTokenProvider? _provider; + final MosaicCustomerTokenClock _clock; + final Duration _cooldown; + + MosaicCustomerToken? _token; + Future? _operation; + int _identityGeneration = 0; + int _tokenGeneration = 0; + + /// The token generation that a forced refresh itself produced. A second + /// force against that generation means the freshly minted token was refused, + /// which is a real failure rather than something to retry. + int _forceMintedGeneration = -1; + bool _forcingMint = false; + DateTime? _cooldownUntil; + String? _lastSafeCode; + String? _userId; + String? _installationId; + + /// The held token, only while it is usable. It never mints: callers on + /// fire-and-forget paths must not be able to trigger a refresh storm. + MosaicCustomerToken? get currentToken { + final token = _token; + return token != null && token.isUsableAt(_clock()) ? token : null; + } + + int get identityGeneration => _identityGeneration; + int get tokenGeneration => _tokenGeneration; + + MosaicCustomerTokenDiagnostics get diagnostics => + MosaicCustomerTokenDiagnostics( + hasToken: _token != null, + identityGeneration: _identityGeneration, + tokenId: _token?.tokenId, + expiresAt: _token?.expiresAt, + lastSafeCode: _lastSafeCode, + cooldownUntil: _cooldownUntil, + ); + + /// Records the identity the next token belongs to. A change bumps the + /// generation and discards the held token, so a token minted for the previous + /// user can never be presented after a sign-in or sign-out. + void bindIdentity({ + required int generation, + String? userId, + String? installationId, + }) { + _userId = userId; + _installationId = installationId; + if (generation == _identityGeneration) return; + _identityGeneration = generation; + _clearToken(); + } + + /// Discards the token on logout. The entitlement cache is cleared by the + /// runtime in the same transition; neither is sufficient alone. + void clearCustomer() { + _identityGeneration += 1; + _clearToken(); + } + + void _clearToken() { + _token = null; + _cooldownUntil = null; + _forceMintedGeneration = -1; + _forcingMint = false; + // An in-flight mint belongs to the previous generation. It is not + // cancellable, so it is disowned: its result is discarded on completion. + _operation = null; + } + + /// Resolves a usable token, coalescing concurrent callers onto one mint. + /// + /// [observedGeneration] is the generation a caller was using when the server + /// refused its request. Passing it makes the forced refresh idempotent: if + /// another caller has already minted a newer token, this one uses it instead + /// of minting again, which is what bounds a `401` to exactly one retry. + Future resolve({ + bool forceRefresh = false, + int? observedGeneration, + }) { + final provider = _provider; + if (provider == null) { + return Future.value( + const MosaicCustomerTokenUnavailable('entitlements.token.no_provider'), + ); + } + if (forceRefresh) { + if (observedGeneration != null && observedGeneration < _tokenGeneration) { + // Someone else already refreshed past the token that was refused. + final current = _token; + if (current != null && current.isUsableAt(_clock())) { + return Future.value( + MosaicCustomerTokenResolved(current, generation: _tokenGeneration), + ); + } + } + if (_forceMintedGeneration == _tokenGeneration && _operation == null) { + // A second forced refresh against the same token generation means the + // freshly minted token was itself refused. That is a real failure, and + // retrying it forever turns an outage into a request storm. + _lastSafeCode = 'entitlements.token.refresh_exhausted'; + return Future.value( + const MosaicCustomerTokenUnavailable( + 'entitlements.token.refresh_exhausted', + ), + ); + } + _forcingMint = true; + _token = null; + _cooldownUntil = null; + } else { + final current = _token; + if (current != null && current.isUsableAt(_clock())) { + return Future.value( + MosaicCustomerTokenResolved(current, generation: _tokenGeneration), + ); + } + final cooldownUntil = _cooldownUntil; + if (cooldownUntil != null && _clock().isBefore(cooldownUntil)) { + return Future.value( + MosaicCustomerTokenUnavailable( + _lastSafeCode ?? 'entitlements.token.cooldown', + ), + ); + } + } + return _operation ??= _mint(provider); + } + + Future _mint( + MosaicCustomerTokenProvider provider, + ) async { + final generation = _identityGeneration; + final forcing = _forcingMint; + _forcingMint = false; + final request = MosaicCustomerTokenRequest( + forceRefresh: forcing, + identityGeneration: generation, + userId: _userId, + installationId: _installationId, + ); + try { + final MosaicCustomerToken? token; + try { + token = await provider(request); + } on Object { + // The host's error is never surfaced: it can carry its own credentials + // and its own user data. + return _fail('entitlements.token.provider_failed'); + } + if (generation != _identityGeneration) { + // Identity changed while the mint was in flight. The token belongs to + // someone who is no longer signed in, so it is dropped, not stored. + return const MosaicCustomerTokenUnavailable( + 'entitlements.token.identity_changed', + ); + } + if (token == null) { + // A signed-out customer is unavailable. A host backend that will not + // mint a token has not revoked anyone's subscription. + return _fail('entitlements.token.signed_out'); + } + if (!token.isUsableAt(_clock())) { + return _fail('entitlements.token.expired_on_arrival'); + } + _token = token; + _tokenGeneration += 1; + if (forcing) _forceMintedGeneration = _tokenGeneration; + _cooldownUntil = null; + _lastSafeCode = null; + return MosaicCustomerTokenResolved(token, generation: _tokenGeneration); + } finally { + // Always released, so one provider failure cannot permanently poison + // token resolution for the rest of the process lifetime. + _operation = null; + } + } + + MosaicCustomerTokenUnavailable _fail(String reasonCode) { + _lastSafeCode = reasonCode; + _cooldownUntil = _clock().add(_cooldown); + return MosaicCustomerTokenUnavailable(reasonCode); + } +} diff --git a/sdk/flutter/lib/src/customer_entitlement_cache.dart b/sdk/flutter/lib/src/customer_entitlement_cache.dart new file mode 100644 index 00000000..7c92e430 --- /dev/null +++ b/sdk/flutter/lib/src/customer_entitlement_cache.dart @@ -0,0 +1,403 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +import 'customer_entitlements.dart'; +import 'sha256.dart'; + +/// Safe diagnostic code reported when entitlement persistence is unavailable +/// and the SDK degrades to an in-memory cache for the session. +const String mosaicCustomerEntitlementCacheUnavailableCode = + 'entitlements.cache_unavailable'; + +const int _cacheFormatVersion = 1; + +/// One accepted snapshot, with everything needed to decide freshness and +/// monotonicity without re-reading the document. +final class MosaicCustomerEntitlementCacheRecord { + MosaicCustomerEntitlementCacheRecord({ + required this.source, + required this.binding, + required this.snapshotVersion, + required DateTime asOf, + required this.entityTag, + required DateTime issuedAt, + required DateTime refreshAfter, + required DateTime validUntil, + this.staleGraceSeconds = 0, + DateTime? trustedServerTime, + DateTime? localReceiptTime, + }) : asOf = asOf.toUtc(), + issuedAt = issuedAt.toUtc(), + refreshAfter = refreshAfter.toUtc(), + validUntil = validUntil.toUtc(), + trustedServerTime = trustedServerTime?.toUtc(), + localReceiptTime = localReceiptTime?.toUtc(); + + /// The exact accepted document. Keeping the bytes rather than the decoded + /// model means a cache round trip cannot quietly re-shape a snapshot, and + /// the digest still verifies after a restart. + final String source; + final MosaicCustomerBinding binding; + final int snapshotVersion; + final DateTime asOf; + final String entityTag; + final DateTime issuedAt; + + /// Freshness bounds. A `snapshotUnchanged` response slides these without + /// touching the snapshot itself. + final DateTime refreshAfter; + final DateTime validUntil; + final int staleGraceSeconds; + + /// Server `Date` at receipt, paired with the device clock at the same + /// instant. Together they let the runtime notice a device clock that moved + /// after the snapshot was stored. + final DateTime? trustedServerTime; + final DateTime? localReceiptTime; + + MosaicCustomerEntitlementCacheRecord slideFreshness({ + required DateTime refreshAfter, + required DateTime validUntil, + required int staleGraceSeconds, + DateTime? trustedServerTime, + DateTime? localReceiptTime, + }) => + MosaicCustomerEntitlementCacheRecord( + source: source, + binding: binding, + snapshotVersion: snapshotVersion, + asOf: asOf, + entityTag: entityTag, + issuedAt: issuedAt, + refreshAfter: refreshAfter, + validUntil: validUntil, + staleGraceSeconds: staleGraceSeconds, + trustedServerTime: trustedServerTime ?? this.trustedServerTime, + localReceiptTime: localReceiptTime ?? this.localReceiptTime, + ); + + MosaicCustomerCachedSnapshotSummary get summary => + MosaicCustomerCachedSnapshotSummary( + binding: binding, + snapshotVersion: snapshotVersion, + asOf: asOf, + ); +} + +abstract interface class MosaicCustomerEntitlementCache { + Future read(String namespace); + + Future write( + String namespace, + MosaicCustomerEntitlementCacheRecord record, + ); + + Future clear(String namespace); + + /// Deletes every stored record that is not [namespace]. + /// + /// Called on every identity change, so the previous customer's snapshot does + /// not sit on disk waiting for a sign-in that happens to reuse its key. + Future removeOtherRecords(String namespace); +} + +/// In-memory cache. It is the test double and the degraded mode a device falls +/// back to when no application cache directory is available. +final class MosaicMemoryCustomerEntitlementCache + implements MosaicCustomerEntitlementCache { + final Map _records = + {}; + + @override + Future read(String namespace) async => + _records[namespace]; + + @override + Future write( + String namespace, + MosaicCustomerEntitlementCacheRecord record, + ) async { + _records[namespace] = record; + } + + @override + Future clear(String namespace) async => _records.remove(namespace); + + @override + Future removeOtherRecords(String namespace) async => + _records.removeWhere((key, _) => key != namespace); +} + +typedef MosaicCustomerEntitlementCacheDirectoryProvider = Future + Function(); + +Future _applicationCacheDirectory() => + getApplicationCacheDirectory(); + +/// Application-private file cache under the platform cache directory. +/// +/// The cache directory, never the support directory: an entitlement snapshot +/// is derived state that must not travel in a device backup to another device +/// or another user, and on both platforms the cache directory is the location +/// with that property. When it is unavailable the store degrades to memory for +/// the session and reports [mosaicCustomerEntitlementCacheUnavailableCode]; it +/// never silently falls back to a backed-up location. +final class MosaicFileCustomerEntitlementCache + implements MosaicCustomerEntitlementCache { + MosaicFileCustomerEntitlementCache({ + MosaicCustomerEntitlementCacheDirectoryProvider directoryProvider = + _applicationCacheDirectory, + void Function(String diagnosticCode)? onDiagnostic, + }) : _directoryProvider = directoryProvider, + _onDiagnostic = onDiagnostic; + + final MosaicCustomerEntitlementCacheDirectoryProvider _directoryProvider; + final void Function(String diagnosticCode)? _onDiagnostic; + MosaicMemoryCustomerEntitlementCache? _degraded; + + bool get isDegraded => _degraded != null; + + @override + Future read(String namespace) async { + final degraded = _degraded; + if (degraded != null) return degraded.read(namespace); + final File file; + try { + file = await _file(namespace); + } on ArgumentError { + rethrow; + } on Object { + return _degrade().read(namespace); + } + if (!await file.exists()) return null; + if ((await file.stat()).size > + mosaicCustomerEntitlementMaximumRecordBytes + 8192) { + throw const MosaicCustomerEntitlementFormatException( + 'cache_record_too_large', + ); + } + return decodeCustomerEntitlementCacheRecord(await file.readAsString()); + } + + @override + Future write( + String namespace, + MosaicCustomerEntitlementCacheRecord record, + ) async { + final degraded = _degraded; + if (degraded != null) return degraded.write(namespace, record); + final File target; + try { + target = await _file(namespace); + } on ArgumentError { + rethrow; + } on Object { + return _degrade().write(namespace, record); + } + await target.parent.create(recursive: true); + // Write, flush, rename in the same directory: a crash between the two + // steps leaves either the previous record or the new one, never a + // half-written document that would read as corruption on next launch. + final temporary = File( + '${target.path}.tmp-${DateTime.now().microsecondsSinceEpoch}', + ); + try { + await temporary.writeAsString( + encodeCustomerEntitlementCacheRecord(record), + flush: true, + ); + await temporary.rename(target.path); + } on Object { + if (await temporary.exists()) await temporary.delete(); + rethrow; + } + } + + @override + Future clear(String namespace) async { + final degraded = _degraded; + if (degraded != null) return degraded.clear(namespace); + try { + final file = await _file(namespace); + if (await file.exists()) await file.delete(); + } on ArgumentError { + rethrow; + } on Object { + // A cache that cannot be deleted must not crash a sign-out. The record + // is unreadable to the next identity anyway: its namespace differs. + _degrade(); + } + } + + @override + Future removeOtherRecords(String namespace) async { + final degraded = _degraded; + if (degraded != null) return degraded.removeOtherRecords(namespace); + try { + final keep = await _file(namespace); + final directory = keep.parent; + if (!await directory.exists()) return; + await for (final entity in directory.list()) { + final name = entity.uri.pathSegments.last; + if (entity is File && + name.startsWith('entitlements-') && + entity.path != keep.path) { + await entity.delete(); + } + } + } on ArgumentError { + rethrow; + } on Object { + _degrade(); + } + } + + MosaicMemoryCustomerEntitlementCache _degrade() { + final degraded = _degraded ??= MosaicMemoryCustomerEntitlementCache(); + _onDiagnostic?.call(mosaicCustomerEntitlementCacheUnavailableCode); + return degraded; + } + + Future _file(String namespace) async { + if (!RegExp(r'^[a-f0-9]{64}$').hasMatch(namespace)) { + throw ArgumentError.value(namespace, 'namespace', 'Invalid cache key.'); + } + final root = await _directoryProvider(); + return File('${root.path}/mosaic/entitlements-$namespace.json'); + } +} + +/// Cache key for one customer's authoritative snapshot. +/// +/// [customerBinding] is the host's own user identity, which is what the SDK +/// knows before any snapshot exists. Putting it in the path — rather than +/// inside one shared document — is what makes a wrong-customer read a missing +/// file instead of a filtering mistake. +String mosaicCustomerEntitlementCacheNamespace( + Uri baseUrl, + String publicSdkKey, + String customerBinding, +) => + mosaicSha256String( + '${_normalizedBaseUrl(baseUrl)}\n$publicSdkKey\n$customerBinding\n' + 'entitlements-v1', + ); + +String _normalizedBaseUrl(Uri value) { + final path = value.path.endsWith('/') + ? value.path.substring(0, value.path.length - 1) + : value.path; + return value.replace(path: path, query: null, fragment: null).toString(); +} + +String encodeCustomerEntitlementCacheRecord( + MosaicCustomerEntitlementCacheRecord record, +) { + final body = { + 'cacheFormatVersion': _cacheFormatVersion, + 'snapshot': record.source, + 'billingCustomerId': record.binding.billingCustomerId, + 'projectId': record.binding.projectId, + 'environmentId': record.binding.environmentId, + 'snapshotVersion': record.snapshotVersion, + 'asOf': record.asOf.toIso8601String(), + 'entityTag': record.entityTag, + 'issuedAt': record.issuedAt.toIso8601String(), + 'refreshAfter': record.refreshAfter.toIso8601String(), + 'validUntil': record.validUntil.toIso8601String(), + 'staleGraceSeconds': record.staleGraceSeconds, + if (record.trustedServerTime != null) + 'trustedServerTime': record.trustedServerTime!.toIso8601String(), + if (record.localReceiptTime != null) + 'localReceiptTime': record.localReceiptTime!.toIso8601String(), + }; + return jsonEncode({ + ...body, + // Integrity, documented as corruption detection rather than security: a + // process that can write this file can also recompute this digest. + 'checksum': _checksum(body), + }); +} + +MosaicCustomerEntitlementCacheRecord decodeCustomerEntitlementCacheRecord( + String source, +) { + final Object? decoded; + try { + decoded = jsonDecode(source); + } on FormatException { + throw const MosaicCustomerEntitlementFormatException('cache_corrupt'); + } + if (decoded is! Map) { + throw const MosaicCustomerEntitlementFormatException('cache_corrupt'); + } + final object = decoded.cast(); + if (object['cacheFormatVersion'] != _cacheFormatVersion) { + throw const MosaicCustomerEntitlementFormatException( + 'cache_format_unsupported', + ); + } + final keys = object.keys.toSet(); + if (!keys.containsAll(_requiredKeys) || + keys.difference(_allowedKeys).isNotEmpty) { + // Strict closed-key reading, exactly as on the wire. A record with a + // member this version does not define was written by something else. + throw const MosaicCustomerEntitlementFormatException('cache_corrupt'); + } + final body = Map.of(object)..remove('checksum'); + if (object['checksum'] != _checksum(body)) { + throw const MosaicCustomerEntitlementFormatException('cache_corrupt'); + } + try { + return MosaicCustomerEntitlementCacheRecord( + source: object['snapshot']! as String, + binding: MosaicCustomerBinding( + billingCustomerId: object['billingCustomerId']! as String, + projectId: object['projectId']! as String, + environmentId: object['environmentId']! as String, + ), + snapshotVersion: object['snapshotVersion']! as int, + asOf: DateTime.parse(object['asOf']! as String), + entityTag: object['entityTag']! as String, + issuedAt: DateTime.parse(object['issuedAt']! as String), + refreshAfter: DateTime.parse(object['refreshAfter']! as String), + validUntil: DateTime.parse(object['validUntil']! as String), + staleGraceSeconds: object['staleGraceSeconds']! as int, + trustedServerTime: object['trustedServerTime'] == null + ? null + : DateTime.parse(object['trustedServerTime']! as String), + localReceiptTime: object['localReceiptTime'] == null + ? null + : DateTime.parse(object['localReceiptTime']! as String), + ); + } on Object { + throw const MosaicCustomerEntitlementFormatException('cache_corrupt'); + } +} + +String _checksum(Map body) => 'sha256:' + '${mosaicSha256Hex(utf8.encode(mosaicCustomerCanonicalJson(body)))}'; + +const Set _requiredKeys = { + 'cacheFormatVersion', + 'snapshot', + 'billingCustomerId', + 'projectId', + 'environmentId', + 'snapshotVersion', + 'asOf', + 'entityTag', + 'issuedAt', + 'refreshAfter', + 'validUntil', + 'staleGraceSeconds', + 'checksum', +}; + +const Set _allowedKeys = { + ..._requiredKeys, + 'trustedServerTime', + 'localReceiptTime', +}; diff --git a/sdk/flutter/lib/src/customer_entitlement_runtime.dart b/sdk/flutter/lib/src/customer_entitlement_runtime.dart new file mode 100644 index 00000000..7e0d97e8 --- /dev/null +++ b/sdk/flutter/lib/src/customer_entitlement_runtime.dart @@ -0,0 +1,716 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import 'customer_authentication.dart'; +import 'customer_entitlement_cache.dart'; +import 'customer_entitlement_transport.dart'; +import 'customer_entitlements.dart'; +import 'placement_identity.dart'; + +/// Host-facing settings for the authoritative entitlement subsystem. +final class MosaicCustomerEntitlementSettings { + const MosaicCustomerEntitlementSettings({ + this.refreshOnResume = true, + this.requestedEntitlementKeys = const [], + }); + + /// Refresh when the application returns to the foreground. A device that was + /// offline for a week is most likely to be online again at this moment. + final bool refreshOnResume; + + /// Narrows the response to these keys. Unrecognized keys are Project data + /// and are accepted; a key the Project does not define contributes no entry. + final List requestedEntitlementKeys; +} + +/// Everything a host may safely display about authoritative entitlement state. +final class MosaicCustomerEntitlementDiagnostics { + const MosaicCustomerEntitlementDiagnostics({ + required this.enabled, + required this.cacheState, + required this.identityGeneration, + required this.staleGraceSeconds, + required this.token, + this.snapshotVersion, + this.issuedAt, + this.asOf, + this.refreshAfter, + this.validUntil, + this.lastReasonCode, + this.billingCustomerId, + this.projectionState, + }); + + final bool enabled; + final MosaicEntitlementCacheState cacheState; + final int identityGeneration; + final int staleGraceSeconds; + + /// Token handle and expiry only. The token value is structurally absent. + final MosaicCustomerTokenDiagnostics token; + final int? snapshotVersion; + final DateTime? issuedAt; + final DateTime? asOf; + final DateTime? refreshAfter; + final DateTime? validUntil; + final String? lastReasonCode; + final String? billingCustomerId; + final MosaicCustomerProjectionState? projectionState; +} + +typedef MosaicCustomerEntitlementClock = DateTime Function(); + +DateTime _systemClock() => DateTime.now().toUtc(); + +/// Owns the authoritative entitlement state of one Billing Customer. +/// +/// Everything it exposes obeys one rule: a state Mosaic did not confirm is +/// `unknown` or `unavailable`, never `inactive`. A reader that collapsed those +/// into "you do not have it" would turn every outage into a mass revocation +/// experienced by paying customers, at exactly the moment Mosaic is least able +/// to notice. +final class MosaicCustomerEntitlementRuntime extends ChangeNotifier + with WidgetsBindingObserver { + MosaicCustomerEntitlementRuntime({ + required this.baseUrl, + required this.publicSdkKey, + required this.transport, + required this.cache, + required MosaicCustomerTokenProvider? tokenProvider, + this.settings = const MosaicCustomerEntitlementSettings(), + this.timeout = const Duration(seconds: 5), + this.clock = _systemClock, + this.onDiagnostic = null, + MosaicCustomerTokenHolder? tokenHolder, + }) : _tokens = tokenHolder ?? + MosaicCustomerTokenHolder( + provider: tokenProvider, + clock: clock, + ), + _enabled = tokenProvider != null || tokenHolder != null { + _observeLifecycleIfAvailable(); + } + + final Uri baseUrl; + final String publicSdkKey; + final MosaicCustomerEntitlementTransport transport; + final MosaicCustomerEntitlementCache cache; + final MosaicCustomerEntitlementSettings settings; + final Duration timeout; + final MosaicCustomerEntitlementClock clock; + final void Function(String diagnosticCode, {required bool severe})? + onDiagnostic; + + final MosaicCustomerTokenHolder _tokens; + final bool _enabled; + final StreamController _updates = + StreamController.broadcast(); + final MosaicCustomerEntitlementDecoder _decoder = + const MosaicCustomerEntitlementDecoder(); + + String _customerBinding = ''; + String? _namespace; + MosaicCustomerEntitlementCacheRecord? _record; + MosaicCustomerEntitlementSnapshot? _snapshot; + Future? _refresh; + Future? _load; + int _generation = 0; + String? _lastReasonCode; + bool _lastOutcomeUnavailable = true; + bool _observingLifecycle = false; + bool _disposed = false; + + /// Sealed transitions. `Cleared` exists so an identity change is observable + /// without ever emitting the previous customer's grants. + Stream get updates => _updates.stream; + + MosaicCustomerEntitlementSnapshot? get snapshot => _snapshot; + + /// The current Customer Access Token, for transport-level binding of a + /// Transaction Observation to this Billing Customer. + /// + /// Internal to the SDK's wiring. It returns the held token only while it is + /// usable and never mints one: observation delivery is fire-and-forget, so a + /// minting read here would turn a backend outage into a request storm. A + /// `null` result omits the header, which is a valid anonymous submission. + String? currentCustomerTokenForSubmission() => _tokens.currentToken?.value; + + MosaicEntitlementCacheState get cacheState => _cacheState(); + + MosaicCustomerEntitlementDiagnostics get diagnostics => + MosaicCustomerEntitlementDiagnostics( + enabled: _enabled, + cacheState: _cacheState(), + identityGeneration: _generation, + staleGraceSeconds: _record?.staleGraceSeconds ?? 0, + token: _tokens.diagnostics, + snapshotVersion: _snapshot?.snapshotVersion, + issuedAt: _snapshot?.issuedAt, + asOf: _snapshot?.asOf, + refreshAfter: _record?.refreshAfter, + validUntil: _record?.validUntil, + lastReasonCode: _lastReasonCode, + billingCustomerId: _snapshot?.billingCustomerId, + projectionState: _snapshot?.projectionStatus.state, + ); + + // ------------------------------------------------------------------------- + // Identity + // ------------------------------------------------------------------------- + + /// Binds the runtime to the current Phase 6 identity. + /// + /// A change in the host's user identity clears everything bound to the + /// previous customer before any read can observe it, and deletes the sibling + /// records on disk. Installation identity is deliberately untouched: it is + /// Phase 6 state and it is evidence, never an anchor. + Future bindIdentity(MosaicIdentityState identity) async { + final binding = identity.userId ?? ''; + _tokens.bindIdentity( + generation: identity.generation, + userId: identity.userId, + installationId: identity.installationId, + ); + if (binding == _customerBinding && _namespace != null) return; + final previousNamespace = _namespace; + _customerBinding = binding; + _generation += 1; + // In-flight work belongs to the previous identity. It is disowned here so + // its result can never be applied to this one. + _refresh = null; + _load = null; + final hadState = _snapshot != null || _record != null; + _record = null; + _snapshot = null; + _namespace = mosaicCustomerEntitlementCacheNamespace( + baseUrl, + publicSdkKey, + binding, + ); + if (hadState) _emitCleared('entitlements.identity.changed'); + await _forgetOtherCustomers(previousNamespace); + await load(); + } + + /// Signs the customer out: token discarded, cache cleared, state emitted. + /// Neither half is sufficient alone. + Future clearCustomer() async { + _tokens.clearCustomer(); + final previousNamespace = _namespace; + _customerBinding = ''; + _generation += 1; + _refresh = null; + _load = null; + final hadState = _snapshot != null || _record != null; + _record = null; + _snapshot = null; + _namespace = + mosaicCustomerEntitlementCacheNamespace(baseUrl, publicSdkKey, ''); + _lastReasonCode = 'entitlements.token.signed_out'; + _lastOutcomeUnavailable = true; + if (hadState) _emitCleared('entitlements.customer.signed_out'); + await _forgetOtherCustomers(previousNamespace); + } + + Future _forgetOtherCustomers(String? previousNamespace) async { + final namespace = _namespace; + if (namespace == null) return; + try { + if (previousNamespace != null && previousNamespace != namespace) { + await cache.clear(previousNamespace); + } + await cache.removeOtherRecords(namespace); + } on Object { + _report(mosaicCustomerEntitlementCacheUnavailableCode, severe: true); + } + } + + // ------------------------------------------------------------------------- + // Reading + // ------------------------------------------------------------------------- + + /// Loads the last accepted snapshot from the cache. Never networks. + Future load() => _load ??= _performLoad(); + + Future _performLoad() async { + final generation = _generation; + final namespace = _namespace ??= mosaicCustomerEntitlementCacheNamespace( + baseUrl, + publicSdkKey, + _customerBinding, + ); + try { + final record = await cache.read(namespace); + if (generation != _generation) return; + if (record == null) { + _load = null; + return; + } + final decoded = _decoder.decode(record.source); + if (decoded is! MosaicCustomerSnapshotRecord || + !decoded.contentDigestValid) { + // A record that no longer verifies is discarded rather than served. It + // is not evidence of anything, in either direction. + await _discard(namespace, 'entitlements.cache.invalid'); + return; + } + _record = record; + _snapshot = decoded.snapshot; + _lastOutcomeUnavailable = false; + notifyListeners(); + } on MosaicCustomerEntitlementFormatException catch (error) { + if (generation == _generation) { + await _discard(namespace, 'entitlements.cache.${error.reasonCode}'); + } + } on Object { + if (generation == _generation) { + _report(mosaicCustomerEntitlementCacheUnavailableCode, severe: false); + } + } finally { + _load = null; + } + } + + Future _discard(String namespace, String reasonCode) async { + _record = null; + _snapshot = null; + _lastReasonCode = reasonCode; + try { + await cache.clear(namespace); + } on Object { + // Nothing to recover: the record is already not being served. + } + _report(reasonCode, severe: false); + _emitCleared(reasonCode); + } + + /// Answers one access question from memory. It performs no I/O, so it is + /// safe to call while building a widget. + MosaicCustomerEntitlementCheck checkCustomerEntitlement( + String entitlementKey, + ) { + final state = _cacheState(); + final snapshot = _snapshot; + + if (snapshot == null || state == MosaicEntitlementCacheState.missing) { + return MosaicCustomerEntitlementCheck( + entitlementKey: entitlementKey, + state: _lastOutcomeUnavailable + ? MosaicCustomerAccessState.unavailable + : MosaicCustomerAccessState.unknown, + cacheState: MosaicEntitlementCacheState.missing, + sourceCount: 0, + endKnown: false, + isStale: false, + isTestSource: false, + reasonCode: _lastReasonCode ?? 'entitlements.cache.missing', + ); + } + if (state == MosaicEntitlementCacheState.expired || + state == MosaicEntitlementCacheState.invalid || + state == MosaicEntitlementCacheState.differentCustomer) { + // An expired cache means Mosaic has not been heard from, not that access + // ended. A host that must not over-grant asks its own server. + return MosaicCustomerEntitlementCheck( + entitlementKey: entitlementKey, + state: MosaicCustomerAccessState.unknown, + cacheState: state, + sourceCount: 0, + endKnown: false, + isStale: false, + isTestSource: false, + reasonCode: 'entitlements.cache.${state.name}', + snapshotVersion: snapshot.snapshotVersion, + asOf: snapshot.asOf, + ); + } + + final entry = snapshot.entryFor(entitlementKey); + if (entry == null) { + // Absence is not a statement. Mosaic never said this key is inactive — + // most often the response was narrowed to other keys. + return MosaicCustomerEntitlementCheck( + entitlementKey: entitlementKey, + state: MosaicCustomerAccessState.unknown, + cacheState: state, + sourceCount: 0, + endKnown: false, + isStale: state == MosaicEntitlementCacheState.staleWithinGrace, + isTestSource: false, + reasonCode: 'entitlements.entry.absent', + snapshotVersion: snapshot.snapshotVersion, + asOf: snapshot.asOf, + ); + } + final access = switch (entry.state) { + MosaicCustomerEntitlementState.active => MosaicCustomerAccessState.active, + MosaicCustomerEntitlementState.inactive => + MosaicCustomerAccessState.inactive, + MosaicCustomerEntitlementState.unknown => + MosaicCustomerAccessState.unknown, + }; + final contributing = entry.sourceIds + .map(snapshot.sourceFor) + .whereType(); + return MosaicCustomerEntitlementCheck( + entitlementKey: entitlementKey, + state: access, + cacheState: state, + sourceCount: entry.sourceCount, + endKnown: entry.endKnown, + isStale: state == MosaicEntitlementCacheState.staleWithinGrace, + isTestSource: contributing.any((source) => source.isTestSource), + reasonCode: access == MosaicCustomerAccessState.active + ? null + : entry.primaryExplanation.code.wireValue, + primaryExplanation: entry.primaryExplanation, + uncertainty: entry.uncertainty, + effectiveStart: entry.effectiveStart, + effectiveEnd: entry.effectiveEnd, + snapshotVersion: snapshot.snapshotVersion, + asOf: snapshot.asOf, + ); + } + + MosaicEntitlementCacheState _cacheState() { + final record = _record; + if (record == null || _snapshot == null) { + return MosaicEntitlementCacheState.missing; + } + final now = clock().toUtc(); + final received = record.localReceiptTime; + if (received != null && + now.isBefore(received.subtract( + const Duration( + seconds: mosaicCustomerEntitlementClockSkewToleranceSeconds, + ), + ))) { + // The device clock moved backwards since this record was stored. Its age + // is unmeasurable, so it is treated as expired rather than young. + return MosaicEntitlementCacheState.expired; + } + return mosaicEvaluateCustomerFreshness( + issuedAt: record.issuedAt, + refreshAfter: record.refreshAfter, + validUntil: record.validUntil, + staleGraceSeconds: record.staleGraceSeconds, + deviceNow: now, + ); + } + + // ------------------------------------------------------------------------- + // Refreshing + // ------------------------------------------------------------------------- + + /// Refreshes authoritative state. Concurrent callers coalesce onto one + /// request: a cold start can want this from a placement read, a lifecycle + /// resume, and a purchase completion in the same frame. + Future refresh() => + _refresh ??= _performRefresh().whenComplete(() => _refresh = null); + + /// Fire-and-forget refresh. It never blocks or alters a purchase, a restore, + /// or a presentation result. + void refreshInBackground() { + if (!_enabled || _disposed) return; + unawaited( + refresh().then((_) {}, onError: (Object _, StackTrace __) {})); + } + + Future _performRefresh() async { + if (!_enabled) { + return _unavailable('entitlements.token.no_provider'); + } + final generation = _generation; + await load(); + final resolution = await _tokens.resolve(); + if (resolution is MosaicCustomerTokenUnavailable) { + return _unavailable(resolution.reasonCode); + } + final resolved = resolution as MosaicCustomerTokenResolved; + var response = await _send(resolved.token); + if (response is MosaicCustomerEntitlementSyncUnauthorized) { + // Exactly one forced refresh and exactly one retry. A second refusal on + // a freshly minted token is a real failure; retrying forever turns an + // outage into a request storm. + final retry = await _tokens.resolve( + forceRefresh: true, + observedGeneration: resolved.generation, + ); + if (retry is MosaicCustomerTokenUnavailable) { + return _unavailable(retry.reasonCode); + } + response = await _send((retry as MosaicCustomerTokenResolved).token); + if (response is MosaicCustomerEntitlementSyncUnauthorized) { + return _unavailable('entitlements.sync.unauthorized'); + } + } + if (generation != _generation) { + // Identity changed while the request was in flight. Applying this now + // would write one customer's snapshot into another's cache. + return _unavailable('entitlements.identity.changed'); + } + return switch (response) { + MosaicCustomerEntitlementSyncFailed(:final diagnosticCode) => + _unavailable(diagnosticCode), + MosaicCustomerEntitlementSyncUnauthorized() => + _unavailable('entitlements.sync.unauthorized'), + MosaicCustomerEntitlementSyncNotModified(:final serverTime) => + await _confirmNotModified(serverTime), + MosaicCustomerEntitlementSyncReceived(:final source, :final serverTime) => + await _accept(source, serverTime), + }; + } + + Future _send( + MosaicCustomerToken token, + ) { + final record = _record; + return transport.sync( + MosaicCustomerEntitlementSyncRequest( + baseUrl: baseUrl, + publicSdkKey: publicSdkKey, + customerToken: token.value, + timeout: timeout, + correlationId: 'mosaic-flutter-${clock().microsecondsSinceEpoch}', + knownSnapshotVersion: record?.snapshotVersion, + entityTag: record?.entityTag, + requestedEntitlementKeys: settings.requestedEntitlementKeys, + ), + ); + } + + /// A confirmed-current snapshot must not expire merely because it was + /// confirmed instead of resent. The canonical `snapshotUnchanged` record is + /// the only carrier of refreshed windows, so it is the only thing that moves + /// them. + Future _confirmFromRecord( + MosaicCustomerSnapshotUnchanged unchanged, + DateTime? serverTime, + ) async { + final record = _record; + if (record == null) { + return _unavailable('entitlements.sync.unchangedWithoutCache'); + } + final binding = MosaicCustomerBinding( + billingCustomerId: unchanged.billingCustomerId, + projectId: unchanged.projectId, + environmentId: unchanged.environmentId, + ); + if (binding != record.binding || + unchanged.snapshotVersion != record.snapshotVersion) { + // A confirmation for another customer, Environment, or version confirms + // nothing here. Sliding on it would extend one cache's life using + // another's evidence. + return _reject( + binding != record.binding + ? 'customer_mismatch' + : 'snapshot_version_not_newer', + binding != record.binding + ? MosaicCustomerCacheAction.clear + : MosaicCustomerCacheAction.preserve, + ); + } + final slid = record.slideFreshness( + refreshAfter: unchanged.refreshAfter, + validUntil: unchanged.validUntil, + staleGraceSeconds: unchanged.staleGraceSeconds, + trustedServerTime: serverTime, + localReceiptTime: clock().toUtc(), + ); + _record = slid; + _lastReasonCode = null; + _lastOutcomeUnavailable = false; + await _persist(slid); + notifyListeners(); + return MosaicCustomerEntitlementUnchanged( + snapshotVersion: slid.snapshotVersion, + ); + } + + /// A bodyless `304`. The cache is preserved and trusted time is re-anchored, + /// but the freshness window does not move: nothing in a bodyless response is + /// a contract-pinned carrier of refreshed windows, and treating an unpinned + /// header as one would let anything on the path extend offline access. + Future _confirmNotModified( + DateTime? serverTime, + ) async { + final record = _record; + if (record == null) { + return _unavailable('entitlements.sync.unchangedWithoutCache'); + } + final reanchored = record.slideFreshness( + refreshAfter: record.refreshAfter, + validUntil: record.validUntil, + staleGraceSeconds: record.staleGraceSeconds, + trustedServerTime: serverTime, + localReceiptTime: clock().toUtc(), + ); + _record = reanchored; + _lastReasonCode = null; + _lastOutcomeUnavailable = false; + await _persist(reanchored); + notifyListeners(); + return MosaicCustomerEntitlementUnchanged( + snapshotVersion: reanchored.snapshotVersion, + ); + } + + /// The acceptance gate. Every check runs in the normative order, and a + /// rejected snapshot never emits. + Future _accept( + String source, + DateTime? serverTime, + ) async { + final MosaicCustomerSyncRecord decoded; + try { + decoded = _decoder.decode(source); + } on MosaicCustomerEntitlementFormatException catch (error) { + return _reject(error.reasonCode, MosaicCustomerCacheAction.preserve); + } + if (decoded is MosaicCustomerUnchangedRecord) { + // The contract-conformant unchanged answer, and the only thing that + // slides the freshness window. + return _confirmFromRecord(decoded.unchanged, serverTime); + } + final record = decoded as MosaicCustomerSnapshotRecord; + final snapshot = record.snapshot; + final decision = mosaicEvaluateCustomerCacheDecision( + contractVersion: mosaicAuthoritativeEntitlementContractVersion, + incomingBinding: MosaicCustomerBinding( + billingCustomerId: snapshot.billingCustomerId, + projectId: snapshot.projectId, + environmentId: snapshot.environmentId, + ), + incomingSnapshotVersion: snapshot.snapshotVersion, + incomingAsOf: snapshot.asOf, + contentDigestValid: record.contentDigestValid, + cached: _record?.summary, + ); + if (!decision.accepted) { + return _reject(decision.reasonCode, decision.cacheAction); + } + + final now = clock().toUtc(); + final stored = MosaicCustomerEntitlementCacheRecord( + source: source, + binding: MosaicCustomerBinding( + billingCustomerId: snapshot.billingCustomerId, + projectId: snapshot.projectId, + environmentId: snapshot.environmentId, + ), + snapshotVersion: snapshot.snapshotVersion, + asOf: snapshot.asOf, + entityTag: snapshot.entityTag, + issuedAt: snapshot.issuedAt, + refreshAfter: snapshot.refreshAfter, + validUntil: snapshot.validUntil, + staleGraceSeconds: snapshot.staleGraceSeconds, + trustedServerTime: serverTime, + localReceiptTime: now, + ); + // Acceptance is atomic. There is no partial merge: a reader never keeps + // the entries it understood from a document it rejected, and never mixes + // two versions. + _record = stored; + _snapshot = snapshot; + _lastReasonCode = null; + _lastOutcomeUnavailable = false; + await _persist(stored); + _updates.add(MosaicCustomerEntitlementSnapshotAccepted(snapshot)); + notifyListeners(); + return MosaicCustomerEntitlementUpdated(snapshot); + } + + Future _persist(MosaicCustomerEntitlementCacheRecord record) async { + final namespace = _namespace; + if (namespace == null) return; + try { + await cache.write(namespace, record); + } on Object { + // Persistence failure degrades durability, never correctness: the + // accepted snapshot is already being served from memory. + _report(mosaicCustomerEntitlementCacheUnavailableCode, severe: false); + } + } + + MosaicCustomerEntitlementRefreshResult _reject( + String reasonCode, + MosaicCustomerCacheAction action, + ) { + _lastReasonCode = 'entitlements.rejected.$reasonCode'; + if (action == MosaicCustomerCacheAction.clear) { + // The one rejection that clears rather than preserves. Continuing to + // serve the previous customer's access after an identity change is + // precisely the leak this rule exists to prevent. + _record = null; + _snapshot = null; + _lastOutcomeUnavailable = false; + final namespace = _namespace; + if (namespace != null) { + unawaited(cache.clear(namespace).catchError((Object _) {})); + } + _report('entitlements.binding.$reasonCode', severe: true); + _emitCleared('entitlements.binding.$reasonCode'); + notifyListeners(); + } else { + _report('entitlements.rejected.$reasonCode', severe: false); + } + return MosaicCustomerEntitlementRejected( + reasonCode: reasonCode, + cacheAction: action, + ); + } + + MosaicCustomerEntitlementRefreshResult _unavailable(String reasonCode) { + _lastReasonCode = reasonCode; + // A network failure, a signed-out customer, and a backend that will not + // mint a token are all "Mosaic could not answer". None of them is a claim + // about the customer, and the previously accepted cache is untouched. + if (_snapshot == null) _lastOutcomeUnavailable = true; + _report(reasonCode, severe: false); + return MosaicCustomerEntitlementUnavailable(reasonCode: reasonCode); + } + + void _emitCleared(String reasonCode) { + if (_updates.isClosed) return; + _updates.add(MosaicCustomerEntitlementCleared(reasonCode: reasonCode)); + } + + void _report(String code, {required bool severe}) { + try { + onDiagnostic?.call(code, severe: severe); + } on Object { + // A host diagnostic sink that throws must never become a failed sync. + } + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + void _observeLifecycleIfAvailable() { + if (_observingLifecycle || !settings.refreshOnResume) return; + try { + WidgetsBinding.instance.addObserver(this); + _observingLifecycle = true; + } on FlutterError { + // A pure Dart host may configure before Flutter initializes. + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && settings.refreshOnResume) { + refreshInBackground(); + } + } + + @override + void dispose() { + _disposed = true; + if (_observingLifecycle) WidgetsBinding.instance.removeObserver(this); + unawaited(_updates.close()); + super.dispose(); + } +} diff --git a/sdk/flutter/lib/src/customer_entitlement_transport.dart b/sdk/flutter/lib/src/customer_entitlement_transport.dart new file mode 100644 index 00000000..4a603c63 --- /dev/null +++ b/sdk/flutter/lib/src/customer_entitlement_transport.dart @@ -0,0 +1,277 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'customer_entitlements.dart'; +import 'protocol.dart'; + +/// Header carrying the Environment-scoped public SDK key. +/// +/// Contract-owned and final: the public key says which application is asking, +/// the customer token in `Authorization: Bearer` says which customer is read, +/// and neither substitutes for the other. +const String mosaicCustomerSdkKeyHeader = 'Mosaic-SDK-Key'; + +final class MosaicCustomerEntitlementSyncRequest { + const MosaicCustomerEntitlementSyncRequest({ + required this.baseUrl, + required this.publicSdkKey, + required this.customerToken, + required this.timeout, + required this.correlationId, + this.knownSnapshotVersion, + this.entityTag, + this.requestedEntitlementKeys = const [], + }); + + final Uri baseUrl; + final String publicSdkKey; + + /// The opaque Customer Access Token. It is presented and then forgotten: it + /// is never logged, stored, or included in a diagnostic. + final String customerToken; + final Duration timeout; + final String correlationId; + + final int? knownSnapshotVersion; + + /// Contract form, without the HTTP quoting. + final String? entityTag; + final List requestedEntitlementKeys; +} + +sealed class MosaicCustomerEntitlementSyncResponse { + const MosaicCustomerEntitlementSyncResponse(); +} + +final class MosaicCustomerEntitlementSyncReceived + extends MosaicCustomerEntitlementSyncResponse { + const MosaicCustomerEntitlementSyncReceived({ + required this.source, + this.entityTag, + this.serverTime, + }); + + final String source; + final String? entityTag; + final DateTime? serverTime; +} + +/// A bodyless `304`. The cache is preserved and trusted time may be +/// re-anchored, but the freshness window does **not** move: no contract-pinned +/// carrier for refreshed windows exists in a bodyless response, and inferring +/// one from unpinned headers would let a proxy extend offline access. +/// +/// The sliding mechanism is the `200` carrying the canonical `snapshotUnchanged` +/// record, whose refreshed windows are part of the contract. +final class MosaicCustomerEntitlementSyncNotModified + extends MosaicCustomerEntitlementSyncResponse { + const MosaicCustomerEntitlementSyncNotModified({ + this.entityTag, + this.serverTime, + }); + + final String? entityTag; + final DateTime? serverTime; +} + +/// The token was refused. Expired, revoked, unknown, and wrong-audience are +/// deliberately indistinguishable, so the caller's only correct response is one +/// forced token refresh and one retry. +final class MosaicCustomerEntitlementSyncUnauthorized + extends MosaicCustomerEntitlementSyncResponse { + const MosaicCustomerEntitlementSyncUnauthorized(); +} + +final class MosaicCustomerEntitlementSyncFailed + extends MosaicCustomerEntitlementSyncResponse { + const MosaicCustomerEntitlementSyncFailed({ + required this.diagnosticCode, + this.retryAfterSeconds, + }); + + final String diagnosticCode; + final int? retryAfterSeconds; +} + +abstract interface class MosaicCustomerEntitlementTransport { + Future sync( + MosaicCustomerEntitlementSyncRequest request, + ); +} + +typedef MosaicCustomerEntitlementHttpClientFactory = HttpClient Function(); + +HttpClient _newHttpClient() => HttpClient(); + +/// The canonical `entitlementSyncRequest` envelope. +/// +/// Contract negotiation lives in this body rather than in a header, so the +/// request is sent with a body even though it is a read: a bodyless request +/// could not state which contract versions the caller can read, and the server +/// answers `snapshotUnchanged` only when it is told which version the caller +/// already holds. +Map mosaicEncodeEntitlementSyncRequest( + MosaicCustomerEntitlementSyncRequest request, +) => + { + 'authoritativeEntitlementContractVersion': + mosaicAuthoritativeEntitlementContractVersion, + 'recordType': 'entitlementSyncRequest', + 'payload': { + // billingCustomerId is deliberately never sent. The Customer Access + // Token is the sole customer selector; the hint could only narrow the + // answer or fail the request, so omitting it removes a way to be wrong + // without removing any way to be right. + if (request.knownSnapshotVersion != null) + 'knownSnapshotVersion': request.knownSnapshotVersion, + if (request.entityTag != null) 'entityTag': request.entityTag, + 'supportedAuthoritativeEntitlementContracts': [ + mosaicAuthoritativeEntitlementContractVersion, + ], + if (request.requestedEntitlementKeys.isNotEmpty) + 'requestedEntitlementKeys': request.requestedEntitlementKeys, + 'correlationId': request.correlationId, + }, + }; + +/// Native HTTP transport for `/v1/sdk/billing/entitlements`. +final class MosaicIoCustomerEntitlementTransport + implements MosaicCustomerEntitlementTransport { + const MosaicIoCustomerEntitlementTransport({ + MosaicCustomerEntitlementHttpClientFactory clientFactory = _newHttpClient, + }) : _clientFactory = clientFactory; + + final MosaicCustomerEntitlementHttpClientFactory _clientFactory; + + @override + Future sync( + MosaicCustomerEntitlementSyncRequest request, + ) async { + final client = _clientFactory() + ..connectionTimeout = request.timeout + ..autoUncompress = true; + try { + return await _sync(client, request).timeout(request.timeout); + } on TimeoutException { + return const MosaicCustomerEntitlementSyncFailed( + diagnosticCode: 'entitlements.sync.timeout', + ); + } on Object { + return const MosaicCustomerEntitlementSyncFailed( + diagnosticCode: 'entitlements.sync.networkFailed', + ); + } finally { + client.close(force: true); + } + } + + Future _sync( + HttpClient client, + MosaicCustomerEntitlementSyncRequest request, + ) async { + final outgoing = await client.postUrl(_endpoint(request.baseUrl)); + outgoing + // A redirect on an authenticated read is a request to present a bearer + // token to a host the SDK never chose. + ..followRedirects = false + ..headers.set( + HttpHeaders.authorizationHeader, + 'Bearer ${request.customerToken}', + ) + ..headers.set(mosaicCustomerSdkKeyHeader, request.publicSdkKey) + ..headers.set(HttpHeaders.acceptHeader, 'application/json') + ..headers.set(HttpHeaders.acceptEncodingHeader, 'gzip') + ..headers.contentType = ContentType.json + ..headers.set('Mosaic-SDK-Platform', 'flutter') + ..headers.set('Mosaic-SDK-Version', mosaicFlutterSdkVersion); + if (request.entityTag case final tag?) { + outgoing.headers.set(HttpHeaders.ifNoneMatchHeader, '"$tag"'); + } + outgoing.write(jsonEncode(mosaicEncodeEntitlementSyncRequest(request))); + + final response = await outgoing.close(); + if (response.isRedirect) { + return const MosaicCustomerEntitlementSyncFailed( + diagnosticCode: 'entitlements.sync.redirectRejected', + ); + } + final serverTime = _serverTime(response); + final entityTag = _entityTag(response); + + if (response.statusCode == HttpStatus.notModified) { + return MosaicCustomerEntitlementSyncNotModified( + entityTag: entityTag, + serverTime: serverTime, + ); + } + if (response.statusCode == HttpStatus.unauthorized) { + return const MosaicCustomerEntitlementSyncUnauthorized(); + } + if (response.statusCode != HttpStatus.ok) { + return MosaicCustomerEntitlementSyncFailed( + diagnosticCode: switch (response.statusCode) { + HttpStatus.forbidden => 'entitlements.sync.forbidden', + HttpStatus.notAcceptable => 'entitlements.sync.contractUnsupported', + HttpStatus.tooManyRequests => 'entitlements.sync.rateLimited', + _ => 'entitlements.sync.httpFailed', + }, + retryAfterSeconds: + int.tryParse(response.headers.value('Retry-After') ?? ''), + ); + } + final contentType = response.headers.contentType; + if (contentType == null || contentType.mimeType != 'application/json') { + // A captive portal answering with HTML is the common case, and it must + // never be parsed as an entitlement document. + return const MosaicCustomerEntitlementSyncFailed( + diagnosticCode: 'entitlements.sync.invalidContentType', + ); + } + final bytes = []; + await for (final chunk in response) { + bytes.addAll(chunk); + if (bytes.length > mosaicCustomerEntitlementMaximumRecordBytes) { + return const MosaicCustomerEntitlementSyncFailed( + diagnosticCode: 'entitlements.sync.responseTooLarge', + ); + } + } + final String source; + try { + source = utf8.decode(bytes); + } on FormatException { + return const MosaicCustomerEntitlementSyncFailed( + diagnosticCode: 'entitlements.sync.invalidEncoding', + ); + } + return MosaicCustomerEntitlementSyncReceived( + source: source, + entityTag: entityTag, + serverTime: serverTime, + ); + } + + DateTime? _serverTime(HttpClientResponse response) { + final value = response.headers.value(HttpHeaders.dateHeader); + if (value == null) return null; + try { + return HttpDate.parse(value).toUtc(); + } on FormatException { + return null; + } + } + + String? _entityTag(HttpClientResponse response) { + final value = response.headers.value(HttpHeaders.etagHeader); + if (value == null || value.length < 2 || !value.startsWith('"')) { + return null; + } + return value.substring(1, value.length - 1); + } +} + +Uri _endpoint(Uri baseUrl) { + final path = baseUrl.path.endsWith('/') ? baseUrl.path : '${baseUrl.path}/'; + return baseUrl.replace(path: path).resolve('v1/sdk/billing/entitlements'); +} diff --git a/sdk/flutter/lib/src/customer_entitlements.dart b/sdk/flutter/lib/src/customer_entitlements.dart new file mode 100644 index 00000000..c40b3c15 --- /dev/null +++ b/sdk/flutter/lib/src/customer_entitlements.dart @@ -0,0 +1,1669 @@ +import 'dart:convert'; + +import 'sha256.dart'; + +/// The exact Authoritative Entitlement Contract version this SDK reads. +/// +/// Reading is exact-match. A `"2"` document is as unreadable to a `"1"` reader +/// as a `"9.9"` document; numeric ordering never implies support. +const String mosaicAuthoritativeEntitlementContractVersion = '1'; + +/// Cross-platform clock-skew tolerance, applied in the direction that favours +/// the user. Identical on Flutter, iOS, and Android. +const int mosaicCustomerEntitlementClockSkewToleranceSeconds = 60; + +/// `staleGraceSeconds` absent means zero, so a producer that intends bounded +/// grace states it. This constant records the shipped server-side default and +/// is never substituted for an absent member. +const int mosaicCustomerEntitlementDefaultStaleGraceSeconds = 86400; + +/// `(validUntil - issuedAt) + staleGraceSeconds` may never exceed 30 days. +const int mosaicCustomerEntitlementMaximumCacheHorizonSeconds = 2592000; + +/// Contract record-size bound, enforced by the transport and the cache. +const int mosaicCustomerEntitlementMaximumRecordBytes = 65536; + +/// Cross-platform restore poll bound before reporting `validationPending`. +const int mosaicCustomerRestorePollAttempts = 3; +const Duration mosaicCustomerRestorePollBudget = Duration(seconds: 6); + +/// Whether access is granted under accepted policy. +/// +/// `unavailable` says the authoritative service could not answer. It is never +/// customer state, and a reader must never turn "I could not find out" into +/// [inactive]. +enum MosaicCustomerAccessState { + active('active'), + inactive('inactive'), + unknown('unknown'), + unavailable('unavailable'); + + const MosaicCustomerAccessState(this.wireValue); + + final String wireValue; +} + +/// Entitlement state admissible inside an immutable snapshot. `unavailable` is +/// deliberately absent: it describes Mosaic's ability to answer. +enum MosaicCustomerEntitlementState { + active('active'), + inactive('inactive'), + unknown('unknown'); + + const MosaicCustomerEntitlementState(this.wireValue); + + final String wireValue; +} + +enum MosaicCustomerUncertaintyReason { + none('none'), + providerUnavailable('provider_unavailable'), + missingFact('missing_fact'), + identityUnresolved('identity_unresolved'), + productUnresolved('product_unresolved'), + conflictingFacts('conflicting_facts'), + projectionFailed('projection_failed'), + staleValidation('stale_validation'), + unsupportedProviderState('unsupported_provider_state'); + + const MosaicCustomerUncertaintyReason(this.wireValue); + + final String wireValue; +} + +enum MosaicCustomerExpectedResolution { + automaticRetry('automatic_retry'), + nextProviderNotification('next_provider_notification'), + nextProjectionRun('next_projection_run'), + operatorAction('operator_action'), + customerAction('customer_action'), + noneExpected('none_expected'); + + const MosaicCustomerExpectedResolution(this.wireValue); + + final String wireValue; +} + +enum MosaicCustomerExplanationCode { + activeSubscriptionPeriod('active_subscription_period'), + activeTrialPeriod('active_trial_period'), + activeGracePeriod('active_grace_period'), + activeBillingRetryAllowance('active_billing_retry_allowance'), + permanentOneTimePurchase('permanent_one_time_purchase'), + familySharedSource('family_shared_source'), + scheduledPauseNotYetEffective('scheduled_pause_not_yet_effective'), + subscriptionCancelledAccessUntilPeriodEnd( + 'subscription_cancelled_access_until_period_end'), + subscriptionExpired('subscription_expired'), + subscriptionPaused('subscription_paused'), + subscriptionRevoked('subscription_revoked'), + subscriptionRefunded('subscription_refunded'), + subscriptionSuperseded('subscription_superseded'), + grantVersionEnded('grant_version_ended'), + noQualifyingSource('no_qualifying_source'), + identityUnresolved('identity_unresolved'), + productUnresolved('product_unresolved'), + conflictingFacts('conflicting_facts'), + projectionFailed('projection_failed'), + providerEvidenceStale('provider_evidence_stale'), + providerUnavailable('provider_unavailable'), + billingDisabled('billing_disabled'), + unsupportedProviderState('unsupported_provider_state'); + + const MosaicCustomerExplanationCode(this.wireValue); + + final String wireValue; +} + +enum MosaicCustomerSourceType { + activeSubscription('active_subscription'), + trial('trial'), + gracePeriod('grace_period'), + billingRetry('billing_retry'), + oneTimeNonConsumable('one_time_non_consumable'), + familyShared('family_shared'); + + const MosaicCustomerSourceType(this.wireValue); + + final String wireValue; +} + +enum MosaicCustomerSourceState { + granting('granting'), + notGranting('not_granting'), + unknown('unknown'); + + const MosaicCustomerSourceState(this.wireValue); + + final String wireValue; +} + +/// Store platform vocabulary of the Authoritative Entitlement Contract. It is +/// deliberately separate from `MosaicStorePlatform`, whose wire values belong +/// to Commerce Configuration. +enum MosaicCustomerStorePlatform { + appleAppStore('apple_app_store'), + googlePlay('google_play'); + + const MosaicCustomerStorePlatform(this.wireValue); + + final String wireValue; +} + +enum MosaicCustomerProjectionState { + current('current'), + pending('pending'), + stale('stale'), + degraded('degraded'), + failed('failed'); + + const MosaicCustomerProjectionState(this.wireValue); + + final String wireValue; +} + +enum MosaicCustomerChangeReason { + initialProjection('initial_projection'), + subscriptionStateChanged('subscription_state_changed'), + subscriptionPeriodChanged('subscription_period_changed'), + renewalIntentChanged('renewal_intent_changed'), + sourceAdded('source_added'), + sourceEnded('source_ended'), + refundApplied('refund_applied'), + revocationApplied('revocation_applied'), + grantVersionChanged('grant_version_changed'), + identityChanged('identity_changed'), + identityConflictOpened('identity_conflict_opened'), + identityConflictResolved('identity_conflict_resolved'), + projectionReplayed('projection_replayed'), + projectionRuleUpgraded('projection_rule_upgraded'), + projectionRecovered('projection_recovered'), + projectionFailed('projection_failed'), + manualReprojection('manual_reprojection'); + + const MosaicCustomerChangeReason(this.wireValue); + + final String wireValue; +} + +/// The freshness and acceptance state of the locally cached snapshot. +/// +/// Clock unreliability is deliberately not a member: it forces +/// expired-equivalent behaviour rather than becoming an extra state. +enum MosaicEntitlementCacheState { + fresh, + refreshRecommended, + staleWithinGrace, + expired, + missing, + invalid, + differentCustomer, +} + +final class MosaicCustomerUncertainty { + const MosaicCustomerUncertainty({ + required this.reason, + this.since, + this.expectedResolution, + this.diagnosticCode, + }); + + static const MosaicCustomerUncertainty definite = MosaicCustomerUncertainty( + reason: MosaicCustomerUncertaintyReason.none, + ); + + final MosaicCustomerUncertaintyReason reason; + final DateTime? since; + final MosaicCustomerExpectedResolution? expectedResolution; + final String? diagnosticCode; + + bool get isDefinite => reason == MosaicCustomerUncertaintyReason.none; +} + +final class MosaicCustomerExplanation { + const MosaicCustomerExplanation({ + required this.code, + this.sourceId, + this.safeSummary, + }); + + final MosaicCustomerExplanationCode code; + final String? sourceId; + + /// Operator-facing convenience. It is never parsed and never carries a + /// provider identifier, token, or raw provider error text. + final String? safeSummary; +} + +final class MosaicCustomerDiagnostic { + const MosaicCustomerDiagnostic({ + required this.code, + required this.safeMessage, + required this.severity, + required this.retryable, + required this.correlationId, + this.retryAfterSeconds, + this.recoveryAction, + }); + + final String code; + final String safeMessage; + final String severity; + final bool retryable; + final String correlationId; + final int? retryAfterSeconds; + final String? recoveryAction; +} + +final class MosaicCustomerProjectionStatus { + const MosaicCustomerProjectionStatus({ + required this.state, + required this.lastProjectedAt, + this.pendingFactCount, + this.diagnosticCode, + }); + + final MosaicCustomerProjectionState state; + final DateTime lastProjectedAt; + final int? pendingFactCount; + final String? diagnosticCode; +} + +/// One reason a Billing Customer holds, or may hold, access. +/// +/// Mosaic Product and Subscription Instance identity live here and nowhere +/// else: duplicating them onto the entry would create two places that can +/// disagree when several sources grant one Entitlement. +final class MosaicCustomerEntitlementSource { + const MosaicCustomerEntitlementSource({ + required this.sourceId, + required this.sourceType, + required this.mosaicProductId, + required this.grantVersionId, + required this.sourceSnapshotId, + required this.start, + required this.sourceState, + required this.uncertainty, + required this.explanationCode, + required this.isTestSource, + this.subscriptionInstanceId, + this.oneTimePurchaseInstanceId, + this.storePlatform, + this.end, + }); + + final String sourceId; + final MosaicCustomerSourceType sourceType; + final String mosaicProductId; + final String grantVersionId; + final String sourceSnapshotId; + final DateTime start; + final MosaicCustomerSourceState sourceState; + final MosaicCustomerUncertainty uncertainty; + final MosaicCustomerExplanationCode explanationCode; + + /// True when this source derives from a provider test transaction. On Google + /// Play it is the only thing separating a licence-tester grant from a paid + /// one, so every surface that reports access reports it. + final bool isTestSource; + final String? subscriptionInstanceId; + final String? oneTimePurchaseInstanceId; + final MosaicCustomerStorePlatform? storePlatform; + + /// Absent means this source has no finite end Mosaic can state. For a + /// permanent source that is a fact; for an uncertain source [uncertainty] + /// explains why. + final DateTime? end; +} + +/// The authoritative state of one Entitlement for one Billing Customer. +final class MosaicCustomerEntitlementEntry { + const MosaicCustomerEntitlementEntry({ + required this.entitlementId, + required this.entitlementKey, + required this.state, + required this.endKnown, + required this.sourceIds, + required this.sourceCount, + required this.primaryExplanation, + this.effectiveStart, + this.effectiveEnd, + this.refreshRecommendedAt, + this.uncertainty, + }); + + final String entitlementId; + final String entitlementKey; + final MosaicCustomerEntitlementState state; + + /// Whether Mosaic can state the effective end at all. False means an active + /// source has an uncertain end and a reader must not display or enforce any + /// expiry. + final bool endKnown; + final List sourceIds; + final int sourceCount; + final MosaicCustomerExplanation primaryExplanation; + final DateTime? effectiveStart; + + /// Present only when [endKnown] is true. `endKnown == true` with this member + /// absent means the Entitlement is permanent. + final DateTime? effectiveEnd; + final DateTime? refreshRecommendedAt; + final MosaicCustomerUncertainty? uncertainty; + + bool get isPermanent => endKnown && effectiveEnd == null; +} + +/// Immutable authoritative state for one Billing Customer in one Environment +/// at one snapshot version. +/// +/// It is a read model, never a bearer credential: possessing it authorizes +/// nothing, and an application backend must never accept one presented by a +/// client as proof of access. +final class MosaicCustomerEntitlementSnapshot { + MosaicCustomerEntitlementSnapshot({ + required this.snapshotId, + required this.billingCustomerId, + required this.projectId, + required this.environmentId, + required this.snapshotVersion, + required this.projectionRuleVersion, + required this.issuedAt, + required this.asOf, + required this.refreshAfter, + required this.validUntil, + required this.entityTag, + required this.contentDigest, + required this.changeReason, + required this.correlationId, + required this.projectionStatus, + required Iterable entries, + required Iterable sources, + this.previousSnapshotVersion, + this.staleGraceSeconds = 0, + Iterable diagnostics = + const [], + }) : entries = List.unmodifiable(entries), + sources = List.unmodifiable(sources), + diagnostics = List.unmodifiable(diagnostics); + + final String snapshotId; + final String billingCustomerId; + final String projectId; + final String environmentId; + final int snapshotVersion; + final int? previousSnapshotVersion; + final int projectionRuleVersion; + final DateTime issuedAt; + final DateTime asOf; + final DateTime refreshAfter; + final DateTime validUntil; + + /// Absent on the wire means zero, which is the strict policy expressed + /// through the same fields rather than as a separate mode. + final int staleGraceSeconds; + final String entityTag; + final String contentDigest; + final List entries; + final List sources; + final MosaicCustomerProjectionStatus projectionStatus; + final MosaicCustomerChangeReason changeReason; + final String correlationId; + final List diagnostics; + + MosaicCustomerEntitlementEntry? entryFor(String entitlementKey) { + for (final entry in entries) { + if (entry.entitlementKey == entitlementKey) return entry; + } + return null; + } + + MosaicCustomerEntitlementSource? sourceFor(String sourceId) { + for (final source in sources) { + if (source.sourceId == sourceId) return source; + } + return null; + } +} + +/// The conditional-request answer confirming a cached snapshot is current. It +/// carries no entries but does slide the freshness window, so a snapshot +/// confirmed instead of resent never expires for having been confirmed. +final class MosaicCustomerSnapshotUnchanged { + const MosaicCustomerSnapshotUnchanged({ + required this.billingCustomerId, + required this.projectId, + required this.environmentId, + required this.snapshotVersion, + required this.entityTag, + required this.issuedAt, + required this.asOf, + required this.refreshAfter, + required this.validUntil, + required this.projectionStatus, + required this.correlationId, + this.staleGraceSeconds = 0, + }); + + final String billingCustomerId; + final String projectId; + final String environmentId; + final int snapshotVersion; + final String entityTag; + final DateTime issuedAt; + final DateTime asOf; + final DateTime refreshAfter; + final DateTime validUntil; + final int staleGraceSeconds; + final MosaicCustomerProjectionStatus projectionStatus; + final String correlationId; +} + +/// The customer, Project, and Environment a cached snapshot is bound to. +final class MosaicCustomerBinding { + const MosaicCustomerBinding({ + required this.billingCustomerId, + required this.projectId, + required this.environmentId, + }); + + final String billingCustomerId; + final String projectId; + final String environmentId; + + @override + bool operator ==(Object other) => + other is MosaicCustomerBinding && + other.billingCustomerId == billingCustomerId && + other.projectId == projectId && + other.environmentId == environmentId; + + @override + int get hashCode => Object.hash(billingCustomerId, projectId, environmentId); +} + +/// What a reader does with the previously accepted cache after evaluating an +/// incoming snapshot. +enum MosaicCustomerCacheAction { replace, preserve, clear } + +/// The outcome of the normative cache-acceptance order. +final class MosaicCustomerCacheDecision { + const MosaicCustomerCacheDecision({ + required this.accepted, + required this.reasonCode, + required this.cacheAction, + }); + + final bool accepted; + + /// Wire vocabulary shared with the cross-implementation reference vectors. + final String reasonCode; + final MosaicCustomerCacheAction cacheAction; +} + +/// The identity and version of a cached snapshot, without its entries. This is +/// the whole input the acceptance order needs. +final class MosaicCustomerCachedSnapshotSummary { + const MosaicCustomerCachedSnapshotSummary({ + required this.binding, + required this.snapshotVersion, + required this.asOf, + }); + + final MosaicCustomerBinding binding; + final int snapshotVersion; + final DateTime asOf; +} + +/// Applies the normative cache-acceptance order. +/// +/// The order matters. Binding is checked before version because snapshot +/// versions are monotonic *per Environment*, so a staging snapshot legitimately +/// starts at 1; diagnosing that as a version regression would preserve a +/// production cache under a staging identity. +/// +/// [contractVersion] and [contentDigestValid] are supplied by the caller +/// because they are decided while decoding, not from the decoded model. +MosaicCustomerCacheDecision mosaicEvaluateCustomerCacheDecision({ + required String contractVersion, + required MosaicCustomerBinding incomingBinding, + required int incomingSnapshotVersion, + required DateTime incomingAsOf, + required bool contentDigestValid, + required MosaicCustomerCachedSnapshotSummary? cached, +}) { + if (contractVersion != mosaicAuthoritativeEntitlementContractVersion) { + return const MosaicCustomerCacheDecision( + accepted: false, + reasonCode: 'unsupported_contract_version', + cacheAction: MosaicCustomerCacheAction.preserve, + ); + } + if (cached != null) { + final binding = cached.binding; + // Each binding member is reported separately: an operator reading a + // diagnostic needs to know whether the app changed customer, Project, or + // Environment, and all three clear the cache. + if (binding.billingCustomerId != incomingBinding.billingCustomerId) { + return const MosaicCustomerCacheDecision( + accepted: false, + reasonCode: 'customer_mismatch', + cacheAction: MosaicCustomerCacheAction.clear, + ); + } + if (binding.projectId != incomingBinding.projectId) { + return const MosaicCustomerCacheDecision( + accepted: false, + reasonCode: 'project_mismatch', + cacheAction: MosaicCustomerCacheAction.clear, + ); + } + if (binding.environmentId != incomingBinding.environmentId) { + return const MosaicCustomerCacheDecision( + accepted: false, + reasonCode: 'environment_mismatch', + cacheAction: MosaicCustomerCacheAction.clear, + ); + } + } + if (!contentDigestValid) { + return const MosaicCustomerCacheDecision( + accepted: false, + reasonCode: 'content_digest_mismatch', + cacheAction: MosaicCustomerCacheAction.preserve, + ); + } + if (cached == null) { + return const MosaicCustomerCacheDecision( + accepted: true, + reasonCode: 'no_cached_snapshot', + cacheAction: MosaicCustomerCacheAction.replace, + ); + } + // Equal is not newer. Confirming a current snapshot is what the unchanged + // response is for, so "accepted" always means the state advanced. + if (incomingSnapshotVersion <= cached.snapshotVersion) { + return const MosaicCustomerCacheDecision( + accepted: false, + reasonCode: 'snapshot_version_not_newer', + cacheAction: MosaicCustomerCacheAction.preserve, + ); + } + if (incomingAsOf.isBefore(cached.asOf)) { + return const MosaicCustomerCacheDecision( + accepted: false, + reasonCode: 'as_of_regression', + cacheAction: MosaicCustomerCacheAction.preserve, + ); + } + return const MosaicCustomerCacheDecision( + accepted: true, + reasonCode: 'newer_snapshot_version', + cacheAction: MosaicCustomerCacheAction.replace, + ); +} + +/// Derives the bounded-grace freshness band of a snapshot against the device +/// clock. +/// +/// A device clock earlier than `issuedAt` by more than the tolerance is +/// unreliable, and an unreliable clock forces expired-equivalent behaviour: a +/// naive implementation computes a negative cache age, concludes "fresh", and +/// hands unlimited offline access to anyone willing to move their clock back. +MosaicEntitlementCacheState mosaicEvaluateCustomerFreshness({ + required DateTime issuedAt, + required DateTime refreshAfter, + required DateTime validUntil, + required int staleGraceSeconds, + required DateTime deviceNow, + int clockSkewToleranceSeconds = + mosaicCustomerEntitlementClockSkewToleranceSeconds, +}) { + final tolerance = Duration(seconds: clockSkewToleranceSeconds); + final now = deviceNow.toUtc(); + if (now.isBefore(issuedAt.toUtc().subtract(tolerance))) { + return MosaicEntitlementCacheState.expired; + } + if (!now.isAfter(refreshAfter.toUtc().add(tolerance))) { + return MosaicEntitlementCacheState.fresh; + } + final graceEnd = + validUntil.toUtc().add(Duration(seconds: staleGraceSeconds)).add( + tolerance, + ); + if (now.isAfter(graceEnd)) { + return MosaicEntitlementCacheState.expired; + } + if (now.isAfter(validUntil.toUtc().add(tolerance))) { + return MosaicEntitlementCacheState.staleWithinGrace; + } + return MosaicEntitlementCacheState.refreshRecommended; +} + +/// The answer to a synchronous access question. There is no boolean anywhere: +/// a caller that cannot see the difference between `inactive` and `unknown` +/// will eventually revoke a paying customer during an outage. +final class MosaicCustomerEntitlementCheck { + const MosaicCustomerEntitlementCheck({ + required this.entitlementKey, + required this.state, + required this.cacheState, + required this.sourceCount, + required this.endKnown, + required this.isStale, + required this.isTestSource, + this.reasonCode, + this.primaryExplanation, + this.uncertainty, + this.effectiveStart, + this.effectiveEnd, + this.snapshotVersion, + this.asOf, + }) : assert( + state == MosaicCustomerAccessState.active || reasonCode != null, + 'Every non-active answer states why.', + ); + + final String entitlementKey; + final MosaicCustomerAccessState state; + final MosaicEntitlementCacheState cacheState; + final int sourceCount; + final bool endKnown; + + /// True while access is served from the bounded-grace band. A host must + /// surface it: the contract requires stale access to be visibly stale. + final bool isStale; + final bool isTestSource; + + /// Present whenever [state] is not [MosaicCustomerAccessState.active]. + final String? reasonCode; + final MosaicCustomerExplanation? primaryExplanation; + final MosaicCustomerUncertainty? uncertainty; + final DateTime? effectiveStart; + final DateTime? effectiveEnd; + final int? snapshotVersion; + final DateTime? asOf; + + bool get isPermanent => endKnown && effectiveEnd == null; +} + +/// Observable transitions of the authoritative entitlement state. +/// +/// `Cleared` exists so an identity transition is observable without ever +/// emitting the previous customer's grants. +sealed class MosaicCustomerEntitlementUpdate { + const MosaicCustomerEntitlementUpdate(); +} + +final class MosaicCustomerEntitlementSnapshotAccepted + extends MosaicCustomerEntitlementUpdate { + const MosaicCustomerEntitlementSnapshotAccepted(this.snapshot); + + final MosaicCustomerEntitlementSnapshot snapshot; +} + +final class MosaicCustomerEntitlementCleared + extends MosaicCustomerEntitlementUpdate { + const MosaicCustomerEntitlementCleared({required this.reasonCode}); + + final String reasonCode; +} + +/// The outcome of one authoritative refresh. +sealed class MosaicCustomerEntitlementRefreshResult { + const MosaicCustomerEntitlementRefreshResult(); +} + +final class MosaicCustomerEntitlementUpdated + extends MosaicCustomerEntitlementRefreshResult { + const MosaicCustomerEntitlementUpdated(this.snapshot); + + final MosaicCustomerEntitlementSnapshot snapshot; +} + +/// The server confirmed the cached snapshot. The cache is preserved and its +/// freshness window slides. +final class MosaicCustomerEntitlementUnchanged + extends MosaicCustomerEntitlementRefreshResult { + const MosaicCustomerEntitlementUnchanged({required this.snapshotVersion}); + + final int snapshotVersion; +} + +/// The response was read but not accepted. The cache is preserved unless +/// [cacheAction] says otherwise, and access reads report `unknown`. +final class MosaicCustomerEntitlementRejected + extends MosaicCustomerEntitlementRefreshResult { + const MosaicCustomerEntitlementRejected({ + required this.reasonCode, + required this.cacheAction, + }); + + final String reasonCode; + final MosaicCustomerCacheAction cacheAction; +} + +/// Mosaic could not answer. This is never a claim about the customer. +final class MosaicCustomerEntitlementUnavailable + extends MosaicCustomerEntitlementRefreshResult { + const MosaicCustomerEntitlementUnavailable({required this.reasonCode}); + + final String reasonCode; +} + +/// A record the reader rejected, carrying the safe reason code a diagnostic +/// reports. Rejection always yields `unknown` and never `inactive`. +final class MosaicCustomerEntitlementFormatException implements Exception { + const MosaicCustomerEntitlementFormatException(this.reasonCode); + + final String reasonCode; + + @override + String toString() => 'MosaicCustomerEntitlementFormatException: $reasonCode'; +} + +/// Canonical serialization of a decoded JSON tree. +/// +/// Five implementations must produce byte-identical input, so the rules are +/// mechanical: minified, object members ascending by UTF-16 code unit at every +/// depth, array order preserved exactly, absent members omitted, `null` never +/// emitted, integers in shortest decimal form. +String mosaicCustomerCanonicalJson(Object? value) { + final buffer = StringBuffer(); + _writeCanonical(value, buffer); + return buffer.toString(); +} + +void _writeCanonical(Object? value, StringBuffer buffer) { + if (value == null) { + // Absent and null are different bytes and therefore different digests, and + // null is invalid everywhere in this contract. + throw const MosaicCustomerEntitlementFormatException('null_member'); + } + if (value is Map) { + final keys = value.keys.whereType().toList()..sort(); + if (keys.length != value.length) { + throw const MosaicCustomerEntitlementFormatException('malformed_record'); + } + buffer.write('{'); + for (var index = 0; index < keys.length; index += 1) { + if (index > 0) buffer.write(','); + buffer + ..write(jsonEncode(keys[index])) + ..write(':'); + _writeCanonical(value[keys[index]], buffer); + } + buffer.write('}'); + return; + } + if (value is List) { + // Array order is normative. A serializer that sorted an array would + // silently repair a document the semantic rules exist to reject. + buffer.write('['); + for (var index = 0; index < value.length; index += 1) { + if (index > 0) buffer.write(','); + _writeCanonical(value[index], buffer); + } + buffer.write(']'); + return; + } + if (value is double && value == value.roundToDouble() && value.isFinite) { + buffer.write(value.toInt().toString()); + return; + } + buffer.write(jsonEncode(value)); +} + +/// SHA-256 over the canonical serialization of [payload] with [excludedMember] +/// removed. +/// +/// This is corruption and binding detection, not authentication. It covers the +/// customer, Project, Environment, and version, so a snapshot cannot be +/// accepted into another customer's cache — but anyone can compute it. +String mosaicCustomerContentDigest( + Map payload, { + String excludedMember = 'contentDigest', +}) { + final copy = Map.of(payload)..remove(excludedMember); + return 'sha256:' + '${mosaicSha256Hex(utf8.encode(mosaicCustomerCanonicalJson(copy)))}'; +} + +// --------------------------------------------------------------------------- +// Strict closed reading +// --------------------------------------------------------------------------- + +/// A record read from the SDK entitlement sync surface. +/// +/// The surface carries exactly two record types. A Subscription Snapshot, a +/// check result, or a restore result arriving here is a different contract +/// surface and is rejected rather than partially understood. +sealed class MosaicCustomerSyncRecord { + const MosaicCustomerSyncRecord(); +} + +final class MosaicCustomerSnapshotRecord extends MosaicCustomerSyncRecord { + const MosaicCustomerSnapshotRecord({ + required this.snapshot, + required this.contentDigestValid, + }); + + final MosaicCustomerEntitlementSnapshot snapshot; + + /// Reported rather than thrown, because a digest mismatch is a distinct + /// cache decision (reject, preserve) in the normative acceptance order. + final bool contentDigestValid; +} + +final class MosaicCustomerUnchangedRecord extends MosaicCustomerSyncRecord { + const MosaicCustomerUnchangedRecord(this.unchanged); + + final MosaicCustomerSnapshotUnchanged unchanged; +} + +/// Reads Authoritative Entitlement Contract v1 records from the SDK sync +/// surface. +/// +/// Reading is closed and whole-document: any unknown version, record type, +/// field, or enumeration member rejects the entire record. The single +/// exception is an unrecognized `entitlementKey`, which is Project data and is +/// carried, because making *defining a new Entitlement* a breaking change for +/// every shipped SDK is the opposite of what fail-closed reading is for. +final class MosaicCustomerEntitlementDecoder { + const MosaicCustomerEntitlementDecoder(); + + MosaicCustomerSyncRecord decode(String source) { + if (utf8.encode(source).length > + mosaicCustomerEntitlementMaximumRecordBytes) { + throw const MosaicCustomerEntitlementFormatException('record_too_large'); + } + final Object? decoded; + try { + decoded = jsonDecode(source); + } on FormatException { + throw const MosaicCustomerEntitlementFormatException('malformed_record'); + } + if (decoded is! Map) { + throw const MosaicCustomerEntitlementFormatException('malformed_record'); + } + return decodeObject(decoded.cast()); + } + + MosaicCustomerSyncRecord decodeObject(Map envelope) { + final fields = _Fields(envelope, const { + 'authoritativeEntitlementContractVersion', + 'recordType', + 'payload', + }); + if (fields.raw['authoritativeEntitlementContractVersion'] != + mosaicAuthoritativeEntitlementContractVersion) { + throw const MosaicCustomerEntitlementFormatException( + 'unsupported_contract_version', + ); + } + final payload = fields.object('payload'); + return switch (fields.raw['recordType']) { + 'customerEntitlementSnapshot' => _snapshotRecord(payload), + 'snapshotUnchanged' => MosaicCustomerUnchangedRecord(_unchanged(payload)), + // Every other member of the closed record-type set belongs to a surface + // this reader does not serve; anything else is not a record type at all. + 'entitlementSyncRequest' || + 'entitlementCheckRequest' || + 'entitlementCheckResult' || + 'subscriptionSnapshot' || + 'restoreResult' => + throw const MosaicCustomerEntitlementFormatException( + 'unsupported_record_type', + ), + _ => throw const MosaicCustomerEntitlementFormatException( + 'unknown_record_type', + ), + }; + } + + MosaicCustomerSnapshotRecord _snapshotRecord(Map payload) { + final fields = _Fields(payload, const { + 'snapshotId', + 'billingCustomerId', + 'projectId', + 'environmentId', + 'snapshotVersion', + 'previousSnapshotVersion', + 'projectionRuleVersion', + 'issuedAt', + 'asOf', + 'refreshAfter', + 'validUntil', + 'staleGraceSeconds', + 'entityTag', + 'contentDigest', + 'entries', + 'sources', + 'projectionStatus', + 'changeReason', + 'correlationId', + 'diagnostics', + }); + final snapshotVersion = fields.integer('snapshotVersion', minimum: 0); + final previousSnapshotVersion = + fields.optionalInteger('previousSnapshotVersion', minimum: 0); + if (previousSnapshotVersion != null && + snapshotVersion <= previousSnapshotVersion) { + // A version that regresses against its own stated predecessor is + // self-inconsistent, independent of anything the reader has cached. + throw const MosaicCustomerEntitlementFormatException( + 'semantic_invariant_violated', + ); + } + final issuedAt = fields.timestamp('issuedAt'); + final refreshAfter = fields.timestamp('refreshAfter'); + final validUntil = fields.timestamp('validUntil'); + final staleGraceSeconds = fields.optionalInteger( + 'staleGraceSeconds', + minimum: 0, + maximum: mosaicCustomerEntitlementMaximumCacheHorizonSeconds, + ) ?? + 0; + _validateHorizon( + issuedAt: issuedAt, + refreshAfter: refreshAfter, + validUntil: validUntil, + staleGraceSeconds: staleGraceSeconds, + ); + + final entries = fields + .list('entries', maximum: 200) + .map(_entry) + .toList(growable: false); + final sources = fields + .list('sources', maximum: 200) + .map(_source) + .toList(growable: false); + _validateGraph(entries, sources); + + final projectionStatus = + _projectionStatus(fields.object('projectionStatus')); + final changeReason = fields.enumeration( + 'changeReason', + MosaicCustomerChangeReason.values, + (value) => value.wireValue, + ); + if (snapshotVersion == 0 && + (previousSnapshotVersion != null || + entries.isNotEmpty || + sources.isNotEmpty || + projectionStatus.state != MosaicCustomerProjectionState.pending || + changeReason != MosaicCustomerChangeReason.initialProjection)) { + // Zero is the never-projected placeholder, not a snapshot of empty or + // projected state. Its strict content rules keep it fail-closed while + // allowing the ordinary monotonic gate to replace it with version 1. + throw const MosaicCustomerEntitlementFormatException( + 'semantic_invariant_violated', + ); + } + + final snapshot = MosaicCustomerEntitlementSnapshot( + snapshotId: fields.identifier('snapshotId'), + billingCustomerId: fields.identifier('billingCustomerId'), + projectId: fields.identifier('projectId'), + environmentId: fields.identifier('environmentId'), + snapshotVersion: snapshotVersion, + previousSnapshotVersion: previousSnapshotVersion, + projectionRuleVersion: + fields.integer('projectionRuleVersion', minimum: 1), + issuedAt: issuedAt, + asOf: fields.timestamp('asOf'), + refreshAfter: refreshAfter, + validUntil: validUntil, + staleGraceSeconds: staleGraceSeconds, + entityTag: fields.entityTag('entityTag'), + contentDigest: fields.digest('contentDigest'), + entries: entries, + sources: sources, + projectionStatus: projectionStatus, + changeReason: changeReason, + correlationId: fields.identifier('correlationId'), + diagnostics: fields + .optionalList('diagnostics', maximum: 10) + .map(_diagnostic) + .toList(growable: false), + ); + return MosaicCustomerSnapshotRecord( + snapshot: snapshot, + contentDigestValid: + mosaicCustomerContentDigest(payload) == snapshot.contentDigest, + ); + } + + MosaicCustomerSnapshotUnchanged _unchanged(Map payload) { + final fields = _Fields(payload, const { + 'billingCustomerId', + 'projectId', + 'environmentId', + 'snapshotVersion', + 'entityTag', + 'issuedAt', + 'asOf', + 'refreshAfter', + 'validUntil', + 'staleGraceSeconds', + 'projectionStatus', + 'correlationId', + 'diagnostics', + }); + final issuedAt = fields.timestamp('issuedAt'); + final refreshAfter = fields.timestamp('refreshAfter'); + final validUntil = fields.timestamp('validUntil'); + final staleGraceSeconds = fields.optionalInteger( + 'staleGraceSeconds', + minimum: 0, + maximum: mosaicCustomerEntitlementMaximumCacheHorizonSeconds, + ) ?? + 0; + // The horizon bound is enforced here too. Otherwise it could be evaded by + // confirming a snapshot rather than reissuing one. + _validateHorizon( + issuedAt: issuedAt, + refreshAfter: refreshAfter, + validUntil: validUntil, + staleGraceSeconds: staleGraceSeconds, + ); + fields.optionalList('diagnostics', maximum: 10).forEach(_diagnostic); + return MosaicCustomerSnapshotUnchanged( + billingCustomerId: fields.identifier('billingCustomerId'), + projectId: fields.identifier('projectId'), + environmentId: fields.identifier('environmentId'), + snapshotVersion: fields.integer('snapshotVersion', minimum: 1), + entityTag: fields.entityTag('entityTag'), + issuedAt: issuedAt, + asOf: fields.timestamp('asOf'), + refreshAfter: refreshAfter, + validUntil: validUntil, + staleGraceSeconds: staleGraceSeconds, + projectionStatus: _projectionStatus(fields.object('projectionStatus')), + correlationId: fields.identifier('correlationId'), + ); + } + + void _validateHorizon({ + required DateTime issuedAt, + required DateTime refreshAfter, + required DateTime validUntil, + required int staleGraceSeconds, + }) { + if (refreshAfter.isAfter(validUntil)) { + throw const MosaicCustomerEntitlementFormatException( + 'semantic_invariant_violated', + ); + } + final horizon = + validUntil.difference(issuedAt).inSeconds + staleGraceSeconds; + if (horizon > mosaicCustomerEntitlementMaximumCacheHorizonSeconds) { + // Bounding each field alone would let a 30-day validity and a 30-day + // grace window compose into 60 days of unconfirmed offline access. + throw const MosaicCustomerEntitlementFormatException( + 'semantic_invariant_violated', + ); + } + } + + MosaicCustomerEntitlementEntry _entry(Map value) { + final fields = _Fields(value, const { + 'entitlementId', + 'entitlementKey', + 'state', + 'effectiveStart', + 'effectiveEnd', + 'endKnown', + 'refreshRecommendedAt', + 'sourceIds', + 'sourceCount', + 'primaryExplanation', + 'uncertainty', + }); + final state = fields.enumeration( + 'state', + MosaicCustomerEntitlementState.values, + (item) => item.wireValue, + ); + final endKnown = fields.boolean('endKnown'); + final effectiveStart = fields.optionalTimestamp('effectiveStart'); + final effectiveEnd = fields.optionalTimestamp('effectiveEnd'); + final uncertainty = fields.raw.containsKey('uncertainty') + ? _uncertainty(fields.object('uncertainty')) + : null; + if (!endKnown && effectiveEnd != null) { + // endKnown false means the end is genuinely uncertain, so a reader must + // display no expiry at all rather than an unreliable one. + throw const MosaicCustomerEntitlementFormatException( + 'invalid_field_value', + ); + } + if (state == MosaicCustomerEntitlementState.active && + effectiveStart == null || + state == MosaicCustomerEntitlementState.unknown && + (uncertainty == null || uncertainty.isDefinite)) { + throw const MosaicCustomerEntitlementFormatException( + 'invalid_field_value', + ); + } + final sourceIds = fields.identifierList('sourceIds', maximum: 64); + final sourceCount = fields.integer('sourceCount', minimum: 0, maximum: 64); + if (sourceCount != sourceIds.length) { + throw const MosaicCustomerEntitlementFormatException( + 'semantic_invariant_violated', + ); + } + return MosaicCustomerEntitlementEntry( + entitlementId: fields.identifier('entitlementId'), + // Project data: an unrecognized key is carried, never rejected. + entitlementKey: fields.entitlementKey('entitlementKey'), + state: state, + endKnown: endKnown, + sourceIds: sourceIds, + sourceCount: sourceCount, + primaryExplanation: _explanation(fields.object('primaryExplanation')), + effectiveStart: effectiveStart, + effectiveEnd: effectiveEnd, + refreshRecommendedAt: fields.optionalTimestamp('refreshRecommendedAt'), + uncertainty: uncertainty, + ); + } + + MosaicCustomerEntitlementSource _source(Map value) { + final fields = _Fields(value, const { + 'sourceId', + 'sourceType', + 'subscriptionInstanceId', + 'oneTimePurchaseInstanceId', + 'mosaicProductId', + 'grantVersionId', + 'sourceSnapshotId', + 'storePlatform', + 'start', + 'end', + 'sourceState', + 'uncertainty', + 'explanationCode', + 'isTestSource', + }); + final sourceType = fields.enumeration( + 'sourceType', + MosaicCustomerSourceType.values, + (item) => item.wireValue, + ); + final subscriptionInstanceId = + fields.optionalIdentifier('subscriptionInstanceId'); + final oneTimePurchaseInstanceId = + fields.optionalIdentifier('oneTimePurchaseInstanceId'); + final expectsOneTime = + sourceType == MosaicCustomerSourceType.oneTimeNonConsumable; + if (expectsOneTime && + (oneTimePurchaseInstanceId == null || + subscriptionInstanceId != null) || + !expectsOneTime && + (subscriptionInstanceId == null || + oneTimePurchaseInstanceId != null)) { + throw const MosaicCustomerEntitlementFormatException( + 'invalid_field_value', + ); + } + final sourceState = fields.enumeration( + 'sourceState', + MosaicCustomerSourceState.values, + (item) => item.wireValue, + ); + final uncertainty = _uncertainty(fields.object('uncertainty')); + if (sourceState == MosaicCustomerSourceState.unknown && + uncertainty.isDefinite) { + throw const MosaicCustomerEntitlementFormatException( + 'invalid_field_value', + ); + } + return MosaicCustomerEntitlementSource( + sourceId: fields.identifier('sourceId'), + sourceType: sourceType, + mosaicProductId: fields.identifier('mosaicProductId'), + grantVersionId: fields.identifier('grantVersionId'), + sourceSnapshotId: fields.identifier('sourceSnapshotId'), + start: fields.timestamp('start'), + sourceState: sourceState, + uncertainty: uncertainty, + explanationCode: fields.enumeration( + 'explanationCode', + MosaicCustomerExplanationCode.values, + (item) => item.wireValue, + ), + isTestSource: fields.boolean('isTestSource'), + subscriptionInstanceId: subscriptionInstanceId, + oneTimePurchaseInstanceId: oneTimePurchaseInstanceId, + storePlatform: fields.raw.containsKey('storePlatform') + ? fields.enumeration( + 'storePlatform', + MosaicCustomerStorePlatform.values, + (item) => item.wireValue, + ) + : null, + end: fields.optionalTimestamp('end'), + ); + } + + MosaicCustomerUncertainty _uncertainty(Map value) { + final fields = _Fields(value, const { + 'reason', + 'since', + 'expectedResolution', + 'diagnosticCode', + }); + final reason = fields.enumeration( + 'reason', + MosaicCustomerUncertaintyReason.values, + (item) => item.wireValue, + ); + final since = fields.optionalTimestamp('since'); + // A definite state carries no since instant; a non-definite one must. + if (reason == MosaicCustomerUncertaintyReason.none && since != null || + reason != MosaicCustomerUncertaintyReason.none && since == null) { + throw const MosaicCustomerEntitlementFormatException( + 'invalid_field_value', + ); + } + return MosaicCustomerUncertainty( + reason: reason, + since: since, + expectedResolution: fields.raw.containsKey('expectedResolution') + ? fields.enumeration( + 'expectedResolution', + MosaicCustomerExpectedResolution.values, + (item) => item.wireValue, + ) + : null, + diagnosticCode: fields.optionalDiagnosticCode('diagnosticCode'), + ); + } + + MosaicCustomerExplanation _explanation(Map value) { + final fields = _Fields(value, const { + 'code', + 'sourceId', + 'safeSummary', + }); + return MosaicCustomerExplanation( + code: fields.enumeration( + 'code', + MosaicCustomerExplanationCode.values, + (item) => item.wireValue, + ), + sourceId: fields.optionalIdentifier('sourceId'), + safeSummary: fields.optionalSafeText('safeSummary'), + ); + } + + MosaicCustomerProjectionStatus _projectionStatus( + Map value, + ) { + final fields = _Fields(value, const { + 'state', + 'lastProjectedAt', + 'pendingFactCount', + 'diagnosticCode', + }); + final state = fields.enumeration( + 'state', + MosaicCustomerProjectionState.values, + (item) => item.wireValue, + ); + final pendingFactCount = fields.optionalInteger( + 'pendingFactCount', + minimum: 0, + maximum: 1000000, + ); + final diagnosticCode = fields.optionalDiagnosticCode('diagnosticCode'); + if (state == MosaicCustomerProjectionState.pending && + pendingFactCount == null || + (state == MosaicCustomerProjectionState.degraded || + state == MosaicCustomerProjectionState.failed) && + diagnosticCode == null) { + throw const MosaicCustomerEntitlementFormatException( + 'invalid_field_value', + ); + } + return MosaicCustomerProjectionStatus( + state: state, + lastProjectedAt: fields.timestamp('lastProjectedAt'), + pendingFactCount: pendingFactCount, + diagnosticCode: diagnosticCode, + ); + } + + MosaicCustomerDiagnostic _diagnostic(Map value) { + final fields = _Fields(value, const { + 'code', + 'safeMessage', + 'severity', + 'retryable', + 'retryAfterSeconds', + 'correlationId', + 'recoveryAction', + }); + final severity = fields.string('severity'); + if (!const {'info', 'warning', 'error'}.contains(severity)) { + throw const MosaicCustomerEntitlementFormatException( + 'unknown_enum_member', + ); + } + final recoveryAction = fields.raw.containsKey('recoveryAction') + ? fields.string('recoveryAction') + : null; + if (recoveryAction != null && + !const { + 'retry', + 'refreshCustomerAccessToken', + 'requestAuthoritativeSync', + 'resolveIdentityConflict', + 'fixProductMapping', + 'contactProvider', + 'none', + }.contains(recoveryAction)) { + throw const MosaicCustomerEntitlementFormatException( + 'unknown_enum_member', + ); + } + return MosaicCustomerDiagnostic( + code: fields.diagnosticCode('code'), + safeMessage: fields.safeText('safeMessage'), + severity: severity, + retryable: fields.boolean('retryable'), + correlationId: fields.identifier('correlationId'), + retryAfterSeconds: fields.optionalInteger( + 'retryAfterSeconds', + minimum: 1, + maximum: 86400, + ), + recoveryAction: recoveryAction, + ); + } + + /// Semantic rules the schema cannot express. All of them protect the same + /// property: an entry a reader trusts always resolves to the reasons behind + /// it, and unresolved evidence never reads as `inactive`. + void _validateGraph( + List entries, + List sources, + ) { + if (!_ascendingUnique(entries.map((entry) => entry.entitlementKey)) || + !_ascendingUnique(sources.map((source) => source.sourceId))) { + throw const MosaicCustomerEntitlementFormatException( + 'semantic_invariant_violated', + ); + } + final byId = { + for (final source in sources) source.sourceId: source, + }; + final referenced = {}; + for (final entry in entries) { + var granting = false; + var indeterminate = false; + for (final sourceId in entry.sourceIds) { + final source = byId[sourceId]; + if (source == null) { + throw const MosaicCustomerEntitlementFormatException( + 'semantic_invariant_violated', + ); + } + referenced.add(sourceId); + granting |= source.sourceState == MosaicCustomerSourceState.granting; + indeterminate |= + source.sourceState == MosaicCustomerSourceState.unknown; + } + // An active Entitlement always has a reason, and an inactive one has no + // reason that might still turn out to grant. + if (entry.state == MosaicCustomerEntitlementState.active && !granting || + entry.state == MosaicCustomerEntitlementState.inactive && + (granting || indeterminate)) { + throw const MosaicCustomerEntitlementFormatException( + 'semantic_invariant_violated', + ); + } + } + if (referenced.length != byId.length) { + // An orphan source is a projection defect: the snapshot claims a reason + // no entry accounts for. + throw const MosaicCustomerEntitlementFormatException( + 'semantic_invariant_violated', + ); + } + } + + static bool _ascendingUnique(Iterable values) { + String? previous; + for (final value in values) { + if (previous != null && value.compareTo(previous) <= 0) return false; + previous = value; + } + return true; + } +} + +// --------------------------------------------------------------------------- +// Closed-key field reading +// --------------------------------------------------------------------------- + +final RegExp _identifierPattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._:-]*$'); +final RegExp _entitlementKeyPattern = RegExp(r'^[a-z][a-z0-9_.-]*$'); +final RegExp _timestampPattern = RegExp( + r'^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$', +); +final RegExp _digestPattern = RegExp(r'^sha256:[a-f0-9]{64}$'); +final RegExp _entityTagPattern = RegExp(r'^[A-Za-z0-9._-]+$'); +final RegExp _diagnosticCodePattern = RegExp( + r'^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$', +); + +/// Reads one closed JSON object. Construction alone rejects an unknown member, +/// which is what makes reading whole-document rather than best-effort. +final class _Fields { + _Fields(this.raw, Set allowed) { + for (final key in raw.keys) { + if (!allowed.contains(key)) { + throw const MosaicCustomerEntitlementFormatException('unknown_field'); + } + } + } + + final Map raw; + + Never _missing() => throw const MosaicCustomerEntitlementFormatException( + 'missing_required_field', + ); + + Never _invalid() => throw const MosaicCustomerEntitlementFormatException( + 'invalid_field_value', + ); + + Map object(String key) { + final value = raw[key]; + if (value == null) _missing(); + if (value is! Map) _invalid(); + return value.cast(); + } + + List> list(String key, {required int maximum}) { + final value = raw[key]; + if (value == null) _missing(); + return _objects(value, maximum); + } + + List> optionalList(String key, {required int maximum}) { + final value = raw[key]; + if (value == null) return const >[]; + return _objects(value, maximum); + } + + List> _objects(Object? value, int maximum) { + if (value is! List || value.length > maximum) _invalid(); + return value.map((item) { + if (item is! Map) _invalid(); + return item.cast(); + }).toList(growable: false); + } + + List identifierList(String key, {required int maximum}) { + final value = raw[key]; + if (value == null) _missing(); + if (value is! List || value.length > maximum) _invalid(); + final result = value.map((item) { + if (item is! String || !_isIdentifier(item)) _invalid(); + return item; + }).toList(growable: false); + if (result.toSet().length != result.length) _invalid(); + return result; + } + + String string(String key) { + final value = raw[key]; + if (value == null) _missing(); + if (value is! String) _invalid(); + return value; + } + + bool boolean(String key) { + final value = raw[key]; + if (value == null) _missing(); + if (value is! bool) _invalid(); + return value; + } + + int integer(String key, {required int minimum, int? maximum}) { + final value = optionalInteger(key, minimum: minimum, maximum: maximum); + if (value == null) _missing(); + return value; + } + + int? optionalInteger(String key, {required int minimum, int? maximum}) { + final value = raw[key]; + if (value == null) return null; + if (value is! int || + value < minimum || + maximum != null && value > maximum) { + _invalid(); + } + return value; + } + + DateTime timestamp(String key) { + final value = optionalTimestamp(key); + if (value == null) _missing(); + return value; + } + + DateTime? optionalTimestamp(String key) { + final value = raw[key]; + if (value == null) return null; + // Millisecond precision is fixed by the schema because the same instant + // written at another precision digests differently. + if (value is! String || !_timestampPattern.hasMatch(value)) _invalid(); + return DateTime.parse(value).toUtc(); + } + + String identifier(String key) { + final value = optionalIdentifier(key); + if (value == null) _missing(); + return value; + } + + String? optionalIdentifier(String key) { + final value = raw[key]; + if (value == null) return null; + if (value is! String || !_isIdentifier(value)) _invalid(); + return value; + } + + // A signed-payload-shaped identifier — three dot-separated base64url + // segments — is a producer defect the semantic validator rejects, not a + // reader obligation. Rejecting it here would diverge from iOS and Android + // and would make the reader guess at intent from a value's shape. + static bool _isIdentifier(String value) => + value.isNotEmpty && + value.length <= 128 && + _identifierPattern.hasMatch(value); + + String entitlementKey(String key) { + final value = string(key); + if (value.isEmpty || + value.length > 64 || + !_entitlementKeyPattern.hasMatch(value)) { + _invalid(); + } + return value; + } + + String digest(String key) { + final value = string(key); + if (!_digestPattern.hasMatch(value)) _invalid(); + return value; + } + + String entityTag(String key) { + final value = string(key); + if (value.length < 8 || + value.length > 128 || + !_entityTagPattern.hasMatch(value)) { + _invalid(); + } + return value; + } + + String diagnosticCode(String key) { + final value = optionalDiagnosticCode(key); + if (value == null) _missing(); + return value; + } + + String? optionalDiagnosticCode(String key) { + final value = raw[key]; + if (value == null) return null; + if (value is! String || + value.length < 3 || + value.length > 96 || + !_diagnosticCodePattern.hasMatch(value)) { + _invalid(); + } + return value; + } + + String safeText(String key) { + final value = optionalSafeText(key); + if (value == null) _missing(); + return value; + } + + String? optionalSafeText(String key) { + final value = raw[key]; + if (value == null) return null; + if (value is! String || + value.isEmpty || + value.length > 240 || + value.runes.any((rune) => rune < 0x20 || rune == 0x7f)) { + _invalid(); + } + return value; + } + + T enumeration( + String key, + List values, + String Function(T value) wire, + ) { + final value = string(key); + for (final candidate in values) { + if (wire(candidate) == value) return candidate; + } + // Every vocabulary in this contract is closed and over-provisioned, so an + // unrecognized member means the document is from a version this reader + // cannot claim to understand. + throw const MosaicCustomerEntitlementFormatException( + 'unknown_enum_member', + ); + } +} diff --git a/sdk/flutter/lib/src/customer_restore_sync.dart b/sdk/flutter/lib/src/customer_restore_sync.dart new file mode 100644 index 00000000..16cdd276 --- /dev/null +++ b/sdk/flutter/lib/src/customer_restore_sync.dart @@ -0,0 +1,421 @@ +import 'dart:async'; + +import 'commerce.dart'; +import 'customer_entitlement_runtime.dart'; +import 'customer_entitlements.dart'; +import 'transaction_observation.dart'; + +/// Mosaic's authoritative answer to a restore. It is separate from what the +/// native provider did, because a successful native restore whose facts have +/// not been validated is not restored access, and reporting it as one is how a +/// restore flow starts lying. +enum MosaicCustomerRestoreOutcome { + restored('restored'), + noAdditionalPurchases('no_additional_purchases'), + validationPending('validation_pending'), + identityUnresolved('identity_unresolved'), + productUnresolved('product_unresolved'), + providerUnavailable('provider_unavailable'), + failed('failed'); + + const MosaicCustomerRestoreOutcome(this.wireValue); + + final String wireValue; +} + +/// What the native provider restore itself did, reported separately and never +/// merged into the authoritative outcome. +enum MosaicCustomerRestoreProviderOutcome { + completed('completed'), + noPurchasesFound('no_purchases_found'), + cancelled('cancelled'), + failed('failed'), + unsupported('unsupported'), + notAttempted('not_attempted'); + + const MosaicCustomerRestoreProviderOutcome(this.wireValue); + + final String wireValue; +} + +enum MosaicCustomerRestoreStageName { + providerRestore, + observationHandoff, + authoritativeSync, + completed, +} + +/// One observable step of a restore. The example application renders these; +/// support uses them to tell "the store found nothing" apart from "Mosaic has +/// not validated it yet". +final class MosaicCustomerRestoreStage { + MosaicCustomerRestoreStage({ + required this.name, + required this.detail, + required DateTime at, + }) : at = at.toUtc(); + + final MosaicCustomerRestoreStageName name; + final String detail; + final DateTime at; +} + +/// The outcome of `restorePurchasesAndSync`, on two independent axes. +sealed class MosaicCustomerRestoreResult { + MosaicCustomerRestoreResult({ + required this.providerOutcome, + required DateTime requestedAt, + required Iterable stages, + }) : requestedAt = requestedAt.toUtc(), + stages = List.unmodifiable(stages); + + MosaicCustomerRestoreOutcome get outcome; + + final MosaicCustomerRestoreProviderOutcome providerOutcome; + final DateTime requestedAt; + final List stages; +} + +/// The only success. It exists only once an accepted snapshot at a higher +/// version reflects the restore: that snapshot is the evidence that makes the +/// outcome authoritative rather than hopeful. +final class MosaicCustomerEntitlementsRestored + extends MosaicCustomerRestoreResult { + MosaicCustomerEntitlementsRestored({ + required super.providerOutcome, + required super.requestedAt, + required super.stages, + required this.snapshotVersion, + required DateTime completedAt, + required this.snapshot, + }) : completedAt = completedAt.toUtc(), + super(); + + @override + MosaicCustomerRestoreOutcome get outcome => + MosaicCustomerRestoreOutcome.restored; + + final int snapshotVersion; + final DateTime completedAt; + final MosaicCustomerEntitlementSnapshot snapshot; +} + +/// The provider restored purchases and Mosaic has not yet confirmed them. This +/// is the honest answer inside the poll bound, and it is not a failure. +final class MosaicCustomerRestoreValidationPending + extends MosaicCustomerRestoreResult { + MosaicCustomerRestoreValidationPending({ + required super.providerOutcome, + required super.requestedAt, + required super.stages, + required this.pendingValidationCount, + required this.uncertainty, + }) : super(); + + @override + MosaicCustomerRestoreOutcome get outcome => + MosaicCustomerRestoreOutcome.validationPending; + + final int pendingValidationCount; + final MosaicCustomerUncertainty uncertainty; +} + +/// The provider found nothing further to restore and Mosaic agrees. +final class MosaicCustomerRestoreNoAdditionalPurchases + extends MosaicCustomerRestoreResult { + MosaicCustomerRestoreNoAdditionalPurchases({ + required super.providerOutcome, + required super.requestedAt, + required super.stages, + }) : super(); + + @override + MosaicCustomerRestoreOutcome get outcome => + MosaicCustomerRestoreOutcome.noAdditionalPurchases; +} + +/// No Billing Customer could be resolved, so there is nowhere to attach the +/// restore. Access is not claimed in either direction. +final class MosaicCustomerRestoreIdentityUnresolved + extends MosaicCustomerRestoreResult { + MosaicCustomerRestoreIdentityUnresolved({ + required super.providerOutcome, + required super.requestedAt, + required super.stages, + required this.uncertainty, + }) : super(); + + @override + MosaicCustomerRestoreOutcome get outcome => + MosaicCustomerRestoreOutcome.identityUnresolved; + + final MosaicCustomerUncertainty uncertainty; +} + +final class MosaicCustomerRestoreProviderUnavailable + extends MosaicCustomerRestoreResult { + MosaicCustomerRestoreProviderUnavailable({ + required super.providerOutcome, + required super.requestedAt, + required super.stages, + required this.uncertainty, + }) : super(); + + @override + MosaicCustomerRestoreOutcome get outcome => + MosaicCustomerRestoreOutcome.providerUnavailable; + + final MosaicCustomerUncertainty uncertainty; +} + +final class MosaicCustomerRestoreFailed extends MosaicCustomerRestoreResult { + MosaicCustomerRestoreFailed({ + required super.providerOutcome, + required super.requestedAt, + required super.stages, + required this.uncertainty, + this.reasonCode, + }) : super(); + + @override + MosaicCustomerRestoreOutcome get outcome => + MosaicCustomerRestoreOutcome.failed; + + final MosaicCustomerUncertainty uncertainty; + final String? reasonCode; +} + +typedef MosaicCustomerRestoreClock = DateTime Function(); +typedef MosaicCustomerRestoreDelay = Future Function(Duration duration); + +DateTime _systemClock() => DateTime.now().toUtc(); + +Future _wait(Duration duration) => Future.delayed(duration); + +/// Composes a native restore, the Transaction Observation handoff, and a +/// bounded authoritative poll into one honest multi-stage result. +/// +/// The Flutter Commerce Provider contract exposes no provider transaction +/// references at restore, so references reach Mosaic through the Commerce +/// Provider update stream the SDK already bridges into the observation +/// runtime. This coordinator flushes that queue rather than synthesizing +/// references it does not have. +final class MosaicCustomerRestoreCoordinator { + MosaicCustomerRestoreCoordinator({ + required this.purchaseProvider, + required this.entitlements, + this.observations, + this.clock = _systemClock, + this.pollAttempts = mosaicCustomerRestorePollAttempts, + this.pollBudget = mosaicCustomerRestorePollBudget, + this.delay = _wait, + }); + + final MosaicPurchaseProvider purchaseProvider; + final MosaicCustomerEntitlementRuntime entitlements; + final MosaicTransactionObservationRuntime? observations; + final MosaicCustomerRestoreClock clock; + final int pollAttempts; + final Duration pollBudget; + final MosaicCustomerRestoreDelay delay; + + Future restorePurchasesAndSync() async { + final requestedAt = clock().toUtc(); + final stages = []; + final baseline = entitlements.snapshot?.snapshotVersion ?? 0; + + final MosaicRestoreResult providerResult; + try { + providerResult = await purchaseProvider.restore(); + } on Object { + stages.add(_stage( + MosaicCustomerRestoreStageName.providerRestore, + 'The provider restore threw.', + )); + return MosaicCustomerRestoreFailed( + providerOutcome: MosaicCustomerRestoreProviderOutcome.failed, + requestedAt: requestedAt, + stages: stages, + uncertainty: _uncertainty( + MosaicCustomerUncertaintyReason.providerUnavailable, + requestedAt, + ), + reasonCode: 'entitlements.restore.providerThrew', + ); + } + final providerOutcome = _providerOutcome(providerResult); + stages.add(_stage( + MosaicCustomerRestoreStageName.providerRestore, + providerOutcome.wireValue, + )); + + switch (providerOutcome) { + case MosaicCustomerRestoreProviderOutcome.cancelled: + case MosaicCustomerRestoreProviderOutcome.notAttempted: + case MosaicCustomerRestoreProviderOutcome.unsupported: + return MosaicCustomerRestoreFailed( + providerOutcome: providerOutcome, + requestedAt: requestedAt, + stages: stages, + uncertainty: _uncertainty( + MosaicCustomerUncertaintyReason.missingFact, + requestedAt, + ), + reasonCode: 'entitlements.restore.${providerOutcome.name}', + ); + case MosaicCustomerRestoreProviderOutcome.failed: + return MosaicCustomerRestoreProviderUnavailable( + providerOutcome: providerOutcome, + requestedAt: requestedAt, + stages: stages, + uncertainty: _uncertainty( + MosaicCustomerUncertaintyReason.providerUnavailable, + requestedAt, + ), + ); + case MosaicCustomerRestoreProviderOutcome.completed: + case MosaicCustomerRestoreProviderOutcome.noPurchasesFound: + break; + } + + // The handoff is a trigger for server-side validation and never proof. It + // can fail without changing what the restore reports. + var handedOff = 0; + final runtime = observations; + if (runtime != null) { + try { + final flushed = await runtime.flush(); + handedOff = switch (flushed) { + MosaicTransactionObservationFlushCompleted(:final sent) => sent, + _ => 0, + }; + } on Object { + handedOff = 0; + } + } + stages.add(_stage( + MosaicCustomerRestoreStageName.observationHandoff, + '$handedOff observation(s) submitted for validation.', + )); + + // Bounded poll. Three attempts across roughly six seconds, identical on + // every platform, so a user does not wait indefinitely for a projection. + final interval = Duration( + microseconds: pollAttempts <= 1 + ? 0 + : pollBudget.inMicroseconds ~/ (pollAttempts - 1), + ); + for (var attempt = 0; attempt < pollAttempts; attempt += 1) { + if (attempt > 0 && interval > Duration.zero) await delay(interval); + final refreshed = await entitlements.refresh(); + final snapshot = entitlements.snapshot; + stages.add(_stage( + MosaicCustomerRestoreStageName.authoritativeSync, + 'Attempt ${attempt + 1}: ${_describe(refreshed)}', + )); + if (snapshot != null && snapshot.snapshotVersion > baseline) { + // Success is the accepted snapshot, not the provider's optimism. + stages.add(_stage( + MosaicCustomerRestoreStageName.completed, + 'Snapshot ${snapshot.snapshotVersion} reflects the restore.', + )); + return MosaicCustomerEntitlementsRestored( + providerOutcome: providerOutcome, + requestedAt: requestedAt, + stages: stages, + snapshotVersion: snapshot.snapshotVersion, + completedAt: clock().toUtc(), + snapshot: snapshot, + ); + } + } + + if (providerOutcome == + MosaicCustomerRestoreProviderOutcome.noPurchasesFound) { + // The store has nothing further and Mosaic's state did not move. Those + // two agreeing is a definite answer. + return MosaicCustomerRestoreNoAdditionalPurchases( + providerOutcome: providerOutcome, + requestedAt: requestedAt, + stages: stages, + ); + } + if (entitlements.snapshot == null && + entitlements.diagnostics.token.hasToken == false) { + return MosaicCustomerRestoreIdentityUnresolved( + providerOutcome: providerOutcome, + requestedAt: requestedAt, + stages: stages, + uncertainty: _uncertainty( + MosaicCustomerUncertaintyReason.identityUnresolved, + requestedAt, + ), + ); + } + return MosaicCustomerRestoreValidationPending( + providerOutcome: providerOutcome, + requestedAt: requestedAt, + stages: stages, + pendingValidationCount: handedOff, + uncertainty: _uncertainty( + MosaicCustomerUncertaintyReason.staleValidation, + requestedAt, + ), + ); + } + + MosaicCustomerRestoreStage _stage( + MosaicCustomerRestoreStageName name, + String detail, + ) => + MosaicCustomerRestoreStage(name: name, detail: detail, at: clock()); + + MosaicCustomerUncertainty _uncertainty( + MosaicCustomerUncertaintyReason reason, + DateTime since, + ) => + MosaicCustomerUncertainty( + reason: reason, + since: since, + expectedResolution: MosaicCustomerExpectedResolution.automaticRetry, + ); + + static String _describe(MosaicCustomerEntitlementRefreshResult result) => + switch (result) { + MosaicCustomerEntitlementUpdated(:final snapshot) => + 'accepted v${snapshot.snapshotVersion}', + MosaicCustomerEntitlementUnchanged(:final snapshotVersion) => + 'unchanged at v$snapshotVersion', + MosaicCustomerEntitlementRejected(:final reasonCode) => + 'rejected: $reasonCode', + MosaicCustomerEntitlementUnavailable(:final reasonCode) => + 'unavailable: $reasonCode', + }; + + static MosaicCustomerRestoreProviderOutcome _providerOutcome( + MosaicRestoreResult result, + ) => + switch (result) { + MosaicRestored() => MosaicCustomerRestoreProviderOutcome.completed, + MosaicNothingToRestore() => + MosaicCustomerRestoreProviderOutcome.noPurchasesFound, + MosaicRestoreCancelled() => + MosaicCustomerRestoreProviderOutcome.cancelled, + MosaicRestoreProviderUnavailable() => + MosaicCustomerRestoreProviderOutcome.failed, + MosaicRestoreConfigurationUnavailable() => + MosaicCustomerRestoreProviderOutcome.notAttempted, + MosaicRestoreFailed() => MosaicCustomerRestoreProviderOutcome.failed, + MosaicDetailedRestoreResult(:final outcome) => switch (outcome) { + MosaicCommerceRecoveryOutcome.restored => + MosaicCustomerRestoreProviderOutcome.completed, + MosaicCommerceRecoveryOutcome.nothingToRestore => + MosaicCustomerRestoreProviderOutcome.noPurchasesFound, + MosaicCommerceRecoveryOutcome.cancelled => + MosaicCustomerRestoreProviderOutcome.cancelled, + MosaicCommerceRecoveryOutcome.providerUnavailable || + MosaicCommerceRecoveryOutcome.failed => + MosaicCustomerRestoreProviderOutcome.failed, + }, + }; +} diff --git a/sdk/flutter/lib/src/transaction_observation_transport.dart b/sdk/flutter/lib/src/transaction_observation_transport.dart index cb44e5ce..1963a4ec 100644 --- a/sdk/flutter/lib/src/transaction_observation_transport.dart +++ b/sdk/flutter/lib/src/transaction_observation_transport.dart @@ -8,6 +8,23 @@ import 'transaction_observation.dart'; /// It is an interface so hosts and tests can inject delivery without the /// runtime depending on `dart:io`, and so the queue can be exercised without a /// network. +/// Header carrying the current Customer Access Token alongside a Transaction +/// Observation. +/// +/// This is the evidence rung that binds an identified user's purchase to their +/// Billing Customer server-side. Without it a validated purchase can only +/// anchor to a purchase-anchored customer, and the identified user is +/// associated later or not at all. +const String mosaicCustomerTokenHeader = 'Mosaic-Customer-Token'; + +/// Supplies the currently held Customer Access Token at send time, or `null`. +/// +/// It is deliberately synchronous and non-minting. Observation delivery is +/// fire-and-forget and runs on a retry schedule, so a resolver that could mint +/// would turn a backend outage into a token-request storm. An absent or expired +/// token simply omits the header, which is a valid anonymous submission. +typedef MosaicCustomerTokenHeaderResolver = String? Function(); + abstract interface class MosaicTransactionObservationTransport { Future submit( MosaicTransactionObservation observation, @@ -26,12 +43,18 @@ final class MosaicIoTransactionObservationTransport required this.baseUrl, required this.publicSdkKey, this.timeout = const Duration(seconds: 5), + this.customerToken, }); final Uri baseUrl; final String publicSdkKey; final Duration timeout; + /// Resolved at send time, never at enqueue time: a queued observation must + /// not carry a credential, and a token minted after the purchase still binds + /// it. The token is never persisted with the queue and never logged. + final MosaicCustomerTokenHeaderResolver? customerToken; + static const String path = '/v1/sdk/billing/observations'; @override @@ -45,6 +68,12 @@ final class MosaicIoTransactionObservationTransport request.headers ..set(HttpHeaders.authorizationHeader, 'Bearer $publicSdkKey') ..contentType = ContentType.json; + // Transport-level, per the transport-is-not-contract precedent: the + // Billing Ingestion v1 observation record is unchanged. + final token = _currentCustomerToken(); + if (token != null) { + request.headers.set(mosaicCustomerTokenHeader, token); + } request.write(jsonEncode(observation.toJson())); final response = await request.close().timeout(timeout); final body = await utf8.decoder.bind(response).join().timeout(timeout); @@ -59,6 +88,17 @@ final class MosaicIoTransactionObservationTransport } } + /// Reads the held token without minting one and without letting a host + /// resolver that throws become a failed submission. + String? _currentCustomerToken() { + try { + final value = customerToken?.call(); + return value == null || value.isEmpty ? null : value; + } on Object { + return null; + } + } + /// Maps one HTTP answer onto the sealed submission result. /// /// The body is the Billing Ingestion Contract v1 observation submission diff --git a/sdk/flutter/test/configuration_test.dart b/sdk/flutter/test/configuration_test.dart index 7f4ad1de..ff4dbd90 100644 --- a/sdk/flutter/test/configuration_test.dart +++ b/sdk/flutter/test/configuration_test.dart @@ -1,6 +1,27 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mosaic_sdk/mosaic_sdk.dart'; +import 'support/canonical_fixture.dart'; + +/// Answers every sync with one scripted snapshot. +final class _FixtureEntitlementTransport + implements MosaicCustomerEntitlementTransport { + _FixtureEntitlementTransport(this.source); + + final String source; + var calls = 0; + + @override + Future sync( + MosaicCustomerEntitlementSyncRequest request, + ) async { + calls += 1; + return MosaicCustomerEntitlementSyncReceived( + source: source, + ); + } +} + void main() { test('configures an isolated Mosaic client', () { final provider = MockMosaicPurchaseProvider(); @@ -28,4 +49,98 @@ void main() { throwsA(isA()), ); }); + + group('authoritative entitlements through the client', () { + final snapshot = repositoryFile( + 'protocol/fixtures/authoritative-entitlement/v1/snapshots/' + 'active-subscription.json', + ).readAsStringSync(); + + Mosaic configure({ + required MosaicCustomerEntitlementCache cache, + required MosaicCustomerEntitlementTransport transport, + MosaicCustomerTokenProvider? tokenProvider, + }) => + Mosaic.configure( + publicSdkKey: 'public_test_key', + baseUrl: Uri.parse('https://api.mosaic.test'), + purchaseProvider: MockMosaicPurchaseProvider(), + identityStorage: MosaicMemoryIdentityStorage(), + customerEntitlementCache: cache, + customerEntitlementTransport: transport, + customerTokenProvider: tokenProvider ?? + (_) async => MosaicCustomerToken( + value: 'mcat_secret', + tokenId: 'token-a', + expiresAt: DateTime.utc(2026, 7, 28, 12) + .add(const Duration(hours: 1)), + ), + customerEntitlementSettings: const MosaicCustomerEntitlementSettings( + refreshOnResume: false, + ), + clock: () => DateTime.utc(2026, 7, 28, 12, 30), + ); + + test('identifying a second user cannot read the first user\'s access', + () async { + final cache = MosaicMemoryCustomerEntitlementCache(); + final transport = _FixtureEntitlementTransport(snapshot); + final mosaic = configure(cache: cache, transport: transport); + + await mosaic.identify('user-a'); + await mosaic.refreshCustomerEntitlements(); + expect( + mosaic.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.active, + ); + + await mosaic.identify('user-b'); + + // The leak this guards: user B seeing user A's Pro access because a + // cached snapshot outlived the sign-in. It must be gone before any read, + // and the answer must not be inactive either — Mosaic has said nothing + // about user B yet. + final check = mosaic.checkCustomerEntitlement('pro'); + expect(check.state, isNot(MosaicCustomerAccessState.active)); + expect(check.state, isNot(MosaicCustomerAccessState.inactive)); + expect(mosaic.customerEntitlements?.snapshot, isNull); + mosaic.dispose(); + }); + + test('resetting user identity signs the customer out of billing too', + () async { + final cache = MosaicMemoryCustomerEntitlementCache(); + final mosaic = configure( + cache: cache, + transport: _FixtureEntitlementTransport(snapshot), + ); + await mosaic.identify('user-a'); + await mosaic.refreshCustomerEntitlements(); + + await mosaic.resetUserIdentity(); + + expect(mosaic.customerEntitlementDiagnostics.token.hasToken, isFalse); + expect( + mosaic.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.unavailable, + ); + mosaic.dispose(); + }); + + test('a client without a token provider reports unavailable, not inactive', + () { + final mosaic = Mosaic.configure( + publicSdkKey: 'public_test_key', + baseUrl: Uri.parse('https://api.mosaic.test'), + purchaseProvider: MockMosaicPurchaseProvider(), + identityStorage: MosaicMemoryIdentityStorage(), + ); + + final check = mosaic.checkCustomerEntitlement('pro'); + expect(check.state, MosaicCustomerAccessState.unavailable); + expect(check.reasonCode, 'entitlements.disabled'); + expect(mosaic.customerEntitlements, isNull); + mosaic.dispose(); + }); + }); } diff --git a/sdk/flutter/test/customer_authentication_test.dart b/sdk/flutter/test/customer_authentication_test.dart new file mode 100644 index 00000000..dcdcea83 --- /dev/null +++ b/sdk/flutter/test/customer_authentication_test.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosaic_sdk/src/customer_authentication.dart'; + +void main() { + late DateTime now; + DateTime clock() => now; + + setUp(() => now = DateTime.utc(2026, 7, 28, 12)); + + MosaicCustomerToken token(String id, + {Duration life = const Duration(hours: 1)}) => + MosaicCustomerToken( + value: 'mcat_$id', + tokenId: id, + expiresAt: now.add(life), + ); + + test('concurrent callers coalesce onto one mint', () async { + // A cold start fans out: a placement read, a lifecycle refresh, and a + // purchase-completion refresh can all want a token in the same frame. Each + // one minting separately would multiply load on the host's backend. + var calls = 0; + final completer = Completer(); + final holder = MosaicCustomerTokenHolder( + provider: (_) { + calls += 1; + return completer.future; + }, + clock: clock, + ); + + final first = holder.resolve(); + final second = holder.resolve(); + completer.complete(token('token-a')); + + expect(await first, isA()); + expect(await second, isA()); + expect(calls, 1); + }); + + test('a token inside the expiry margin is replaced before it is used', + () async { + var calls = 0; + final holder = MosaicCustomerTokenHolder( + provider: (_) async { + calls += 1; + return token('token-$calls', life: const Duration(seconds: 90)); + }, + clock: clock, + ); + + await holder.resolve(); + expect(calls, 1); + // Thirty-one seconds of life left: inside the 60-second margin, so the + // request would otherwise be sent with a credential that dies in flight. + now = now.add(const Duration(seconds: 59)); + await holder.resolve(); + expect(calls, 2); + }); + + test('a token minted for a superseded identity is discarded', () async { + final completer = Completer(); + final holder = MosaicCustomerTokenHolder( + provider: (_) => completer.future, + clock: clock, + ); + + final pending = holder.resolve(); + holder.clearCustomer(); + completer.complete(token('token-previous-user')); + + final resolution = await pending; + expect( + resolution, + isA().having( + (value) => value.reasonCode, + 'reasonCode', + 'entitlements.token.identity_changed', + ), + ); + // The critical part: nothing was retained. A held token would be presented + // on the next sync and would read the previous user's entitlements. + expect(holder.diagnostics.hasToken, isFalse); + }); + + test('a signed-out customer is unavailable, never inactive', () async { + final holder = MosaicCustomerTokenHolder( + provider: (_) async => null, + clock: clock, + ); + + expect( + await holder.resolve(), + isA().having( + (value) => value.reasonCode, + 'reasonCode', + 'entitlements.token.signed_out', + ), + ); + }); + + test('a provider failure is cooled down rather than retried in a storm', + () async { + var calls = 0; + final holder = MosaicCustomerTokenHolder( + provider: (_) async { + calls += 1; + throw StateError('backend down'); + }, + clock: clock, + ); + + expect(await holder.resolve(), isA()); + expect(await holder.resolve(), isA()); + expect(calls, 1, reason: 'The cooldown must suppress the second attempt.'); + + now = now.add(mosaicCustomerTokenFailureCooldown); + await holder.resolve(); + expect(calls, 2); + }); + + test('a second forced refresh on one generation refuses instead of storming', + () async { + var calls = 0; + final holder = MosaicCustomerTokenHolder( + provider: (_) async { + calls += 1; + return token('token-$calls'); + }, + clock: clock, + ); + + await holder.resolve(); + final generation = holder.tokenGeneration; + // First 401: force one refresh and retry. + expect( + await holder.resolve(forceRefresh: true, observedGeneration: generation), + isA(), + ); + expect(calls, 2); + + // A second 401 against that same freshly minted token is a real failure. + final exhausted = await holder.resolve( + forceRefresh: true, + observedGeneration: holder.tokenGeneration, + ); + expect(calls, 2); + expect( + exhausted, + isA().having( + (value) => value.reasonCode, + 'reasonCode', + 'entitlements.token.refresh_exhausted', + ), + ); + }); + + test('a refresh another caller already performed is not repeated', () async { + var calls = 0; + final holder = MosaicCustomerTokenHolder( + provider: (_) async { + calls += 1; + return token('token-$calls'); + }, + clock: clock, + ); + + await holder.resolve(); + final stale = holder.tokenGeneration; + await holder.resolve(forceRefresh: true, observedGeneration: stale); + expect(calls, 2); + + // A concurrent request that was still using the old token now reports its + // 401. The token it complained about is already gone. + expect( + await holder.resolve(forceRefresh: true, observedGeneration: stale), + isA(), + ); + expect(calls, 2); + }); + + test('the token value never reaches diagnostics or a string form', () async { + final holder = MosaicCustomerTokenHolder( + provider: (_) async => token('token-visible'), + clock: clock, + ); + final resolution = await holder.resolve() as MosaicCustomerTokenResolved; + + expect(resolution.token.toString(), isNot(contains('mcat_'))); + final diagnostics = holder.diagnostics; + expect(diagnostics.tokenId, 'token-visible'); + expect(diagnostics.toString(), isNot(contains('mcat_'))); + }); + + test('no configured provider is unavailable rather than an error', () async { + final holder = MosaicCustomerTokenHolder(provider: null, clock: clock); + expect( + await holder.resolve(), + isA().having( + (value) => value.reasonCode, + 'reasonCode', + 'entitlements.token.no_provider', + ), + ); + }); +} diff --git a/sdk/flutter/test/customer_entitlement_cache_test.dart b/sdk/flutter/test/customer_entitlement_cache_test.dart new file mode 100644 index 00000000..ac5e189d --- /dev/null +++ b/sdk/flutter/test/customer_entitlement_cache_test.dart @@ -0,0 +1,198 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosaic_sdk/mosaic_sdk.dart'; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('mosaic-entitlement-cache'); + }); + + tearDown(() async { + if (root.existsSync()) await root.delete(recursive: true); + }); + + MosaicFileCustomerEntitlementCache cacheIn( + Directory directory, { + void Function(String code)? onDiagnostic, + }) => + MosaicFileCustomerEntitlementCache( + directoryProvider: () async => directory, + onDiagnostic: onDiagnostic, + ); + + MosaicCustomerEntitlementCacheRecord record({ + String customerId = 'customer-a', + int version = 4, + }) => + MosaicCustomerEntitlementCacheRecord( + source: '{"recordType":"customerEntitlementSnapshot"}', + binding: MosaicCustomerBinding( + billingCustomerId: customerId, + projectId: 'project-mosaic', + environmentId: 'environment-production', + ), + snapshotVersion: version, + asOf: DateTime.utc(2026, 7, 28, 11, 59, 58), + entityTag: 'cs-0001-v$version', + issuedAt: DateTime.utc(2026, 7, 28, 12), + refreshAfter: DateTime.utc(2026, 7, 28, 13), + validUntil: DateTime.utc(2026, 8, 4, 12), + staleGraceSeconds: 86400, + trustedServerTime: DateTime.utc(2026, 7, 28, 12), + localReceiptTime: DateTime.utc(2026, 7, 28, 11, 59, 30), + ); + + final namespaceA = mosaicCustomerEntitlementCacheNamespace( + Uri.parse('https://api.mosaic.test'), + 'public_key', + 'user-a', + ); + final namespaceB = mosaicCustomerEntitlementCacheNamespace( + Uri.parse('https://api.mosaic.test'), + 'public_key', + 'user-b', + ); + + test('a namespace is per customer, not per environment alone', () { + // Two signed-in users on one install must never resolve to one file. If + // they did, the wrong-customer case would be a filtering bug rather than a + // missing file, and filtering bugs leak. + expect(namespaceA, isNot(namespaceB)); + expect( + mosaicCustomerEntitlementCacheNamespace( + Uri.parse('https://api.mosaic.test/'), + 'public_key', + 'user-a', + ), + namespaceA, + reason: 'A trailing slash is not a different Environment.', + ); + }); + + test('a written record round-trips through its checksum', () async { + final cache = cacheIn(root); + await cache.write(namespaceA, record()); + final read = await cache.read(namespaceA); + + expect(read, isNotNull); + expect(read!.binding.billingCustomerId, 'customer-a'); + expect(read.snapshotVersion, 4); + expect(read.staleGraceSeconds, 86400); + expect(read.trustedServerTime, DateTime.utc(2026, 7, 28, 12)); + expect(read.localReceiptTime, DateTime.utc(2026, 7, 28, 11, 59, 30)); + }); + + test('a tampered record is rejected rather than half-trusted', () async { + final cache = cacheIn(root); + await cache.write(namespaceA, record()); + final file = File('${root.path}/mosaic/entitlements-$namespaceA.json'); + // Flip the version without recomputing the digest. Accepting this would + // let anything that can touch the file grant a newer snapshot's authority + // to older content. + await file.writeAsString( + file.readAsStringSync().replaceFirst( + '"snapshotVersion":4', + '"snapshotVersion":9', + ), + ); + + await expectLater( + cache.read(namespaceA), + throwsA( + isA().having( + (error) => error.reasonCode, + 'reasonCode', + 'cache_corrupt', + ), + ), + ); + }); + + test('an unknown member is rejected, not ignored', () async { + final cache = cacheIn(root); + await cache.write(namespaceA, record()); + final file = File('${root.path}/mosaic/entitlements-$namespaceA.json'); + await file.writeAsString( + file.readAsStringSync().replaceFirst('{', '{"grantOverride":true,'), + ); + + await expectLater( + cache.read(namespaceA), + throwsA(isA()), + ); + }); + + test('a write leaves no temporary file behind', () async { + final cache = cacheIn(root); + await cache.write(namespaceA, record()); + final remaining = Directory('${root.path}/mosaic') + .listSync() + .map((entity) => entity.uri.pathSegments.last) + .where((name) => name.contains('.tmp-')); + + expect(remaining, isEmpty); + }); + + test('an identity change deletes the previous customer record', () async { + final cache = cacheIn(root); + await cache.write(namespaceA, record()); + await cache.write(namespaceB, record(customerId: 'customer-b')); + + await cache.removeOtherRecords(namespaceB); + + // The leak this prevents: user A signs out, user B signs in, and A's + // snapshot is still sitting on disk under a key a later sign-in reuses. + expect(await cache.read(namespaceA), isNull); + expect((await cache.read(namespaceB))!.binding.billingCustomerId, + 'customer-b'); + }); + + test('an unavailable cache directory degrades to memory, never to backup', + () async { + final codes = []; + final cache = MosaicFileCustomerEntitlementCache( + directoryProvider: () async => + throw const FileSystemException('no cache directory'), + onDiagnostic: codes.add, + ); + + await cache.write(namespaceA, record()); + final read = await cache.read(namespaceA); + + expect(read, isNotNull); + expect(cache.isDegraded, isTrue); + expect(codes, contains(mosaicCustomerEntitlementCacheUnavailableCode)); + // Nothing reached any on-disk location. The support directory is a backed + // up location on both platforms and is never used as a fallback. + expect(root.listSync(), isEmpty); + }); + + test('clearing removes the record for this customer only', () async { + final cache = cacheIn(root); + await cache.write(namespaceA, record()); + await cache.write(namespaceB, record(customerId: 'customer-b')); + + await cache.clear(namespaceA); + + expect(await cache.read(namespaceA), isNull); + expect(await cache.read(namespaceB), isNotNull); + }); + + test('sliding freshness keeps the accepted snapshot untouched', () { + final slid = record().slideFreshness( + refreshAfter: DateTime.utc(2026, 7, 29, 13), + validUntil: DateTime.utc(2026, 8, 5, 12), + staleGraceSeconds: 86400, + ); + + // A 304 confirms a snapshot; it never re-accepts one, so the version, the + // bytes, and the as-of instant must all be identical. + expect(slid.snapshotVersion, 4); + expect(slid.source, record().source); + expect(slid.asOf, record().asOf); + expect(slid.validUntil, DateTime.utc(2026, 8, 5, 12)); + }); +} diff --git a/sdk/flutter/test/customer_entitlement_contract_test.dart b/sdk/flutter/test/customer_entitlement_contract_test.dart new file mode 100644 index 00000000..f3f83bd8 --- /dev/null +++ b/sdk/flutter/test/customer_entitlement_contract_test.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosaic_sdk/mosaic_sdk.dart'; + +import 'support/canonical_fixture.dart'; + +/// Conformance against the canonical Authoritative Entitlement v1 fixtures. +/// +/// The SDK entitlement sync surface carries exactly two record types. Fixtures +/// for the other surfaces are asserted to be *rejected here*, which documents +/// the boundary rather than pretending the SDK reads a trusted-server record. +void main() { + const decoder = MosaicCustomerEntitlementDecoder(); + final root = repositoryDirectory( + 'protocol/fixtures/authoritative-entitlement/v1', + ); + + group('canonical snapshot fixtures', () { + for (final file + in canonicalFixtureFiles(Directory('${root.path}/snapshots'))) { + final name = file.uri.pathSegments.last; + test('$name is read whole', () { + final record = decoder.decode(file.readAsStringSync()); + switch (record) { + case MosaicCustomerSnapshotRecord( + :final snapshot, + :final contentDigestValid + ): + // Every published snapshot digests to the value it carries. A + // failure here means Dart's canonical serialization has drifted + // from the four other implementations. + expect(contentDigestValid, isTrue); + expect(snapshot.snapshotVersion, greaterThanOrEqualTo(0)); + expect(snapshot.correlationId, isNotEmpty); + case MosaicCustomerUnchangedRecord(:final unchanged): + expect(unchanged.snapshotVersion, greaterThan(0)); + } + }); + } + + test('a permanent source reports no finite expiry', () { + // Reporting the subscription's end date when a lifetime purchase also + // contributes would tell a lifetime purchaser their access expires. + final record = decoder.decode( + File('${root.path}/snapshots/permanent-source-no-finite-expiry.json') + .readAsStringSync(), + ) as MosaicCustomerSnapshotRecord; + final entry = record.snapshot.entries.first; + expect(entry.state, MosaicCustomerEntitlementState.active); + expect(entry.endKnown, isTrue); + expect(entry.effectiveEnd, isNull); + expect(entry.isPermanent, isTrue); + }); + + test('a test-derived source is flagged on the source it came from', () { + final record = decoder.decode( + File('${root.path}/snapshots/test-source-sandbox-grant.json') + .readAsStringSync(), + ) as MosaicCustomerSnapshotRecord; + expect( + record.snapshot.sources.any((source) => source.isTestSource), + isTrue, + ); + }); + + test('an unknown entry keeps a non-definite uncertainty', () { + final record = decoder.decode( + File('${root.path}/snapshots/unknown-state-identity-unresolved.json') + .readAsStringSync(), + ) as MosaicCustomerSnapshotRecord; + final entry = record.snapshot.entries.first; + expect(entry.state, MosaicCustomerEntitlementState.unknown); + expect(entry.uncertainty?.isDefinite, isFalse); + expect(entry.uncertainty?.since, isNotNull); + }); + + test('bounded offline caching states its grace window explicitly', () { + final record = decoder.decode( + File('${root.path}/snapshots/bounded-offline-cache.json') + .readAsStringSync(), + ) as MosaicCustomerSnapshotRecord; + expect(record.snapshot.staleGraceSeconds, greaterThan(0)); + }); + }); + + group('invalid fixtures', () { + final invalid = Directory('${root.path}/invalid'); + final layers = (jsonDecode( + File('${invalid.path}/rejection-layers.json').readAsStringSync(), + ) as Map)['layers']! as Map; + + // Classified producer-side: the semantic validator rejects it, and a + // reader that inferred intent from a value's shape would diverge from the + // other SDKs. Asserted separately below. + const producerSideOnly = { + 'snapshot-carries-signed-payload-value.json', + }; + + for (final file in canonicalFixtureFiles(invalid)) { + final name = file.uri.pathSegments.last; + if (producerSideOnly.contains(name)) continue; + test('$name never yields access', () { + final source = file.readAsStringSync(); + String? reasonCode; + var digestValid = true; + try { + final record = decoder.decode(source); + if (record is MosaicCustomerSnapshotRecord) { + digestValid = record.contentDigestValid; + } + } on MosaicCustomerEntitlementFormatException catch (error) { + reasonCode = error.reasonCode; + } + // Rejection is either a thrown reason or a failed binding digest. + // Nothing in between: partial acceptance is forbidden, so there is no + // path where some entries of an invalid document survive. + expect( + reasonCode != null || !digestValid, + isTrue, + reason: '$name was accepted whole.', + ); + expect(layers.containsKey(name), isTrue, + reason: '$name is not classified in rejection-layers.json.'); + }); + } + + test('a version regressing against its own predecessor is semantic', () { + // No JSON Schema can express this cross-field arithmetic, so the reader + // owns it. rejection-layers.json classifies it as semantic. + expect(layers['older-snapshot-version-rejected.json'], 'semantic'); + expect( + () => decoder.decode( + File('${invalid.path}/older-snapshot-version-rejected.json') + .readAsStringSync(), + ), + throwsA( + isA().having( + (error) => error.reasonCode, + 'reasonCode', + 'semantic_invariant_violated', + ), + ), + ); + }); + + test('a digest computed over another customer fails binding', () { + expect(layers['different-customer-rejected.json'], 'semantic'); + final record = decoder.decode( + File('${invalid.path}/different-customer-rejected.json') + .readAsStringSync(), + ) as MosaicCustomerSnapshotRecord; + // The document is structurally readable; the digest is exactly what + // catches the binding failure, which is what the digest exists for. + expect(record.contentDigestValid, isFalse); + }); + + test('an unknown field rejects the whole record', () { + expect( + () => decoder.decode( + File('${invalid.path}/snapshot-entry-unknown-field.json') + .readAsStringSync(), + ), + throwsA( + isA().having( + (error) => error.reasonCode, + 'reasonCode', + 'unknown_field', + ), + ), + ); + }); + + test('an unsupported contract version rejects before anything else', () { + expect( + () => decoder.decode( + File('${invalid.path}/unknown-contract-version.json') + .readAsStringSync(), + ), + throwsA( + isA().having( + (error) => error.reasonCode, + 'reasonCode', + 'unsupported_contract_version', + ), + ), + ); + }); + + test('a signed-payload-shaped identifier is a producer-side rejection', () { + // Cross-SDK alignment: iOS, Android, and Flutter all accept this record + // as a reader. The shape of a correlation identifier is not something a + // reader is entitled to infer intent from, and the semantic validator is + // where the defect is caught. + expect(layers['snapshot-carries-signed-payload-value.json'], 'semantic'); + final record = decoder.decode( + File('${invalid.path}/snapshot-carries-signed-payload-value.json') + .readAsStringSync(), + ) as MosaicCustomerSnapshotRecord; + expect(record.contentDigestValid, isTrue); + }); + }); + + group('other contract surfaces', () { + for (final directory in const [ + 'checks', + 'subscriptions', + 'restores' + ]) { + for (final file + in canonicalFixtureFiles(Directory('${root.path}/$directory'))) { + final name = file.uri.pathSegments.last; + test('$directory/$name is not read by the SDK sync surface', () { + expect( + () => decoder.decode(file.readAsStringSync()), + throwsA( + isA().having( + (error) => error.reasonCode, + 'reasonCode', + 'unsupported_record_type', + ), + ), + ); + }); + } + } + }); +} diff --git a/sdk/flutter/test/customer_entitlement_sync_test.dart b/sdk/flutter/test/customer_entitlement_sync_test.dart new file mode 100644 index 00000000..cf6ef199 --- /dev/null +++ b/sdk/flutter/test/customer_entitlement_sync_test.dart @@ -0,0 +1,623 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosaic_sdk/mosaic_sdk.dart'; + +import 'support/canonical_fixture.dart'; + +/// Records what the runtime asked for and replays scripted answers. +final class _RecordingTransport implements MosaicCustomerEntitlementTransport { + _RecordingTransport(this.responses); + + final List responses; + final List requests = + []; + Completer? gate; + var _index = 0; + + @override + Future sync( + MosaicCustomerEntitlementSyncRequest request, + ) async { + requests.add(request); + if (gate case final pending?) await pending.future; + final response = responses[_index.clamp(0, responses.length - 1)]; + _index += 1; + return response; + } +} + +void main() { + final root = repositoryDirectory( + 'protocol/fixtures/authoritative-entitlement/v1', + ); + String fixture(String path) => File('${root.path}/$path').readAsStringSync(); + + late DateTime now; + DateTime clock() => now; + + setUp(() => now = DateTime.utc(2026, 7, 28, 12, 30)); + + MosaicCustomerToken token([String id = 'token-a']) => MosaicCustomerToken( + value: 'mcat_$id', + tokenId: id, + expiresAt: now.add(const Duration(hours: 1)), + ); + + MosaicCustomerEntitlementSyncReceived received(String path) => + MosaicCustomerEntitlementSyncReceived( + source: fixture(path), + ); + + String firstProjectedSnapshot(int version) { + final envelope = jsonDecode( + fixture('snapshots/never-projected-placeholder.json'), + ) as Map; + final payload = (envelope['payload']! as Map).cast(); + final projected = (jsonDecode( + fixture('snapshots/active-subscription.json'), + ) as Map)['payload']! as Map; + payload['snapshotVersion'] = version; + payload['entityTag'] = 'cs-0001-v$version'; + payload.remove('previousSnapshotVersion'); + payload['issuedAt'] = '2026-07-29T10:00:00.000Z'; + payload['asOf'] = '2026-07-29T09:59:58.000Z'; + payload['refreshAfter'] = '2026-07-29T11:00:00.000Z'; + payload['validUntil'] = '2026-08-05T10:00:00.000Z'; + payload['entries'] = projected['entries']; + payload['sources'] = projected['sources']; + payload['projectionStatus'] = { + 'state': 'current', + 'lastProjectedAt': '2026-07-29T09:59:58.000Z', + }; + payload['contentDigest'] = mosaicCustomerContentDigest(payload); + return jsonEncode(envelope); + } + + MosaicCustomerEntitlementRuntime runtimeWith( + _RecordingTransport transport, { + MosaicCustomerEntitlementCache? cache, + MosaicCustomerTokenProvider? tokenProvider, + void Function(String code, {required bool severe})? onDiagnostic, + }) => + MosaicCustomerEntitlementRuntime( + baseUrl: Uri.parse('https://api.mosaic.test'), + publicSdkKey: 'public_key', + transport: transport, + cache: cache ?? MosaicMemoryCustomerEntitlementCache(), + tokenProvider: tokenProvider ?? (_) async => token(), + settings: const MosaicCustomerEntitlementSettings( + refreshOnResume: false, + ), + clock: clock, + onDiagnostic: onDiagnostic, + ); + + test('the sync request matches the canonical conditional fixture shape', () { + final canonical = jsonDecode(fixture('sync/sync-request-conditional.json')) + as Map; + final encoded = mosaicEncodeEntitlementSyncRequest( + MosaicCustomerEntitlementSyncRequest( + baseUrl: Uri.parse('https://api.mosaic.test'), + publicSdkKey: 'public_key', + customerToken: 'mcat_secret', + timeout: const Duration(seconds: 5), + correlationId: 'fixture-correlation-0001', + knownSnapshotVersion: 4, + entityTag: 'cs-0001-v4', + ), + ); + + expect( + encoded['authoritativeEntitlementContractVersion'], + canonical['authoritativeEntitlementContractVersion'], + ); + expect(encoded['recordType'], canonical['recordType']); + final payload = encoded['payload']! as Map; + // Contract negotiation lives in the body, which is why this read is sent + // with one: a bodyless request cannot state which versions it can read, + // and the server answers snapshotUnchanged only when told which version + // the caller holds. + expect( + payload['supportedAuthoritativeEntitlementContracts'], ['1']); + expect(payload['knownSnapshotVersion'], 4); + expect(payload['entityTag'], 'cs-0001-v4'); + // No credential ever appears in the record. + expect(jsonEncode(encoded), isNot(contains('mcat_'))); + }); + + test('a newer snapshot is accepted and emitted once', () async { + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + ]); + final runtime = runtimeWith(transport); + final updates = []; + runtime.updates.listen(updates.add); + + final result = await runtime.refresh(); + + expect(result, isA()); + expect(runtime.snapshot?.snapshotVersion, 4); + final check = runtime.checkCustomerEntitlement('pro'); + expect(check.state, MosaicCustomerAccessState.active); + expect(check.reasonCode, isNull); + expect(check.sourceCount, 1); + await pumpEventQueue(); + expect(updates, hasLength(1)); + runtime.dispose(); + }); + + test('the never-projected placeholder is cached and replaced by version 1', + () async { + now = DateTime.utc(2026, 7, 29, 8, 30); + final transport = + _RecordingTransport([ + MosaicCustomerEntitlementSyncReceived( + source: fixture('snapshots/never-projected-placeholder.json'), + ), + MosaicCustomerEntitlementSyncReceived( + source: firstProjectedSnapshot(1), + ), + ]); + final runtime = runtimeWith(transport); + + final placeholder = await runtime.refresh(); + + expect(placeholder, isA()); + expect(runtime.snapshot?.snapshotVersion, 0); + expect( + runtime.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.unknown, + ); + + now = DateTime.utc(2026, 7, 29, 10, 30); + final projected = await runtime.refresh(); + + expect(transport.requests.last.knownSnapshotVersion, 0); + final requestPayload = mosaicEncodeEntitlementSyncRequest( + transport.requests.last, + )['payload']! as Map; + expect(requestPayload['knownSnapshotVersion'], 0); + expect(projected, isA()); + expect(runtime.snapshot?.snapshotVersion, 1); + expect( + runtime.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.active, + ); + runtime.dispose(); + }); + + test('version zero cannot carry projected entitlement content', () { + expect( + () => const MosaicCustomerEntitlementDecoder().decode( + firstProjectedSnapshot(0), + ), + throwsA( + isA().having( + (error) => error.reasonCode, + 'reasonCode', + 'semantic_invariant_violated', + ), + ), + ); + }); + + test('an older snapshot never rolls accepted state backwards', () async { + final transport = + _RecordingTransport([ + received('snapshots/newer-snapshot.json'), + received('snapshots/active-subscription.json'), + ]); + // The newer fixture is issued at 14:00, so the device clock is set after + // it: a clock earlier than issuance is the unreliable-clock path, not the + // case under test here. + now = DateTime.utc(2026, 7, 28, 14, 30); + final runtime = runtimeWith(transport); + + await runtime.refresh(); + final accepted = runtime.snapshot!.snapshotVersion; + final second = await runtime.refresh(); + + expect( + second, + isA().having( + (value) => value.reasonCode, + 'reasonCode', + 'snapshot_version_not_newer', + ), + ); + expect(runtime.snapshot!.snapshotVersion, accepted); + // The cache is preserved, so access continues from the newer state rather + // than dropping to unknown because a late response arrived. + expect( + runtime.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.active, + ); + runtime.dispose(); + }); + + test('the canonical unchanged record is what slides the window', () async { + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + received('snapshots/snapshot-unchanged.json'), + ]); + final runtime = runtimeWith(transport); + + await runtime.refresh(); + // Past the accepted snapshot's own validUntil. The unchanged record's + // refreshed window is the only contract-pinned carrier, so a device that + // is demonstrably in contact with the server does not expire. + now = DateTime.utc(2026, 8, 4, 12, 20); + final unchanged = await runtime.refresh(); + + expect(unchanged, isA()); + expect(runtime.snapshot!.snapshotVersion, 4); + expect(runtime.cacheState, MosaicEntitlementCacheState.refreshRecommended); + expect( + runtime.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.active, + ); + // The conditional request carried both the version and the validator. + expect(transport.requests.last.knownSnapshotVersion, 4); + expect(transport.requests.last.entityTag, 'cs-0001-v4'); + runtime.dispose(); + }); + + test('a bodyless 304 preserves the cache without sliding it', () async { + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + const MosaicCustomerEntitlementSyncNotModified(), + ]); + final runtime = runtimeWith(transport); + await runtime.refresh(); + + now = DateTime.utc(2026, 8, 4, 12, 20); + final unchanged = await runtime.refresh(); + + expect(unchanged, isA()); + // Nothing in a bodyless response is a contract-pinned carrier of refreshed + // windows. Inferring one from an unpinned header would let anything on the + // network path extend offline access, so the window does not move. + expect(runtime.cacheState, MosaicEntitlementCacheState.expired); + expect( + runtime.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.unknown, + ); + runtime.dispose(); + }); + + test('an unchanged record for another customer confirms nothing', () async { + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + MosaicCustomerEntitlementSyncReceived( + source: fixture('snapshots/snapshot-unchanged.json').replaceFirst( + 'fixture-customer-0001', + 'fixture-customer-0002', + ), + ), + ]); + final runtime = runtimeWith(transport); + await runtime.refresh(); + + final result = await runtime.refresh(); + + expect( + result, + isA().having( + (value) => value.cacheAction, + 'cacheAction', + MosaicCustomerCacheAction.clear, + ), + ); + expect(runtime.snapshot, isNull); + runtime.dispose(); + }); + + test('the sync body never carries a customer identifier', () { + // The Customer Access Token is the sole customer selector. A hint could + // only narrow the answer or fail the request, so it is not sent at all. + final encoded = mosaicEncodeEntitlementSyncRequest( + MosaicCustomerEntitlementSyncRequest( + baseUrl: Uri.parse('https://api.mosaic.test'), + publicSdkKey: 'public_key', + customerToken: 'mcat_secret', + timeout: const Duration(seconds: 5), + correlationId: 'fixture-correlation-0001', + ), + ); + final payload = encoded['payload']! as Map; + expect(payload.containsKey('billingCustomerId'), isFalse); + }); + + test('an absent entitlement key reads unknown, never inactive', () async { + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + ]); + final runtime = runtimeWith(transport); + await runtime.refresh(); + + final check = runtime.checkCustomerEntitlement('enterprise'); + + // Absence is not a statement. Mosaic never said this key is inactive, and + // most often the response was simply narrowed to other keys. + expect(check.state, MosaicCustomerAccessState.unknown); + expect(check.reasonCode, 'entitlements.entry.absent'); + expect(check.sourceCount, 0); + // Cross-platform spelling of the cache-state vocabulary. + expect( + MosaicEntitlementCacheState.values.map((value) => value.name), + containsAll([ + 'fresh', + 'refreshRecommended', + 'staleWithinGrace', + 'expired', + 'missing', + 'invalid', + 'differentCustomer', + ]), + ); + runtime.dispose(); + }); + + test('concurrent refreshes coalesce onto one request', () async { + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + ]) + ..gate = Completer(); + final runtime = runtimeWith(transport); + + final first = runtime.refresh(); + final second = runtime.refresh(); + transport.gate!.complete(); + await Future.wait(>[first, second]); + + expect(transport.requests, hasLength(1)); + runtime.dispose(); + }); + + test('an expired cache reports unknown, never inactive', () async { + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + ]); + final runtime = runtimeWith(transport); + await runtime.refresh(); + + // Past validUntil plus the default grace window in the fixture (absent, + // therefore zero: the strict policy expressed through the same fields). + now = DateTime.utc(2026, 8, 6, 12); + final check = runtime.checkCustomerEntitlement('pro'); + + expect(runtime.cacheState, MosaicEntitlementCacheState.expired); + expect(check.state, MosaicCustomerAccessState.unknown); + expect(check.state, isNot(MosaicCustomerAccessState.inactive)); + expect(check.reasonCode, isNotNull); + runtime.dispose(); + }); + + test('a backwards device clock expires the cache instead of freezing it', + () async { + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + ]); + final runtime = runtimeWith(transport); + await runtime.refresh(); + expect(runtime.cacheState, MosaicEntitlementCacheState.fresh); + + // Moving the clock back is the cheapest attack on an offline cache. The + // age becomes unmeasurable, so the cache is treated as expired. + now = DateTime.utc(2026, 7, 27, 12); + expect(runtime.cacheState, MosaicEntitlementCacheState.expired); + expect( + runtime.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.unknown, + ); + runtime.dispose(); + }); + + test('a 401 forces exactly one token refresh and one retry', () async { + var mints = 0; + final transport = + _RecordingTransport([ + const MosaicCustomerEntitlementSyncUnauthorized(), + received('snapshots/active-subscription.json'), + ]); + final runtime = runtimeWith( + transport, + tokenProvider: (_) async { + mints += 1; + return token('token-$mints'); + }, + ); + + final result = await runtime.refresh(); + + expect(result, isA()); + expect(mints, 2); + expect(transport.requests, hasLength(2)); + runtime.dispose(); + }); + + test('a signed-out customer is unavailable with no cache', () async { + final transport = + _RecordingTransport([]); + final runtime = runtimeWith(transport, tokenProvider: (_) async => null); + + final result = await runtime.refresh(); + + expect(result, isA()); + expect(transport.requests, isEmpty); + final check = runtime.checkCustomerEntitlement('pro'); + expect(check.state, MosaicCustomerAccessState.unavailable); + runtime.dispose(); + }); + + test('a network failure preserves the cache and never claims inactive', + () async { + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + const MosaicCustomerEntitlementSyncFailed( + diagnosticCode: 'entitlements.sync.networkFailed', + ), + ]); + final runtime = runtimeWith(transport); + await runtime.refresh(); + + final result = await runtime.refresh(); + + expect(result, isA()); + expect( + runtime.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.active, + ); + runtime.dispose(); + }); + + test('an identity change clears state before any read can observe it', + () async { + final cache = MosaicMemoryCustomerEntitlementCache(); + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + ]); + final runtime = runtimeWith(transport, cache: cache); + await runtime.bindIdentity( + MosaicIdentityState( + installationId: 'installation_a', + generation: 1, + userId: 'user-a', + ), + ); + await runtime.refresh(); + expect( + runtime.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.active, + ); + final updates = []; + runtime.updates.listen(updates.add); + + await runtime.bindIdentity( + MosaicIdentityState( + installationId: 'installation_a', + generation: 2, + userId: 'user-b', + ), + ); + + // The leak this prevents: the second user reading the first user's + // entitlements because a cached snapshot outlived the sign-in. + expect(runtime.snapshot, isNull); + final check = runtime.checkCustomerEntitlement('pro'); + expect(check.state, isNot(MosaicCustomerAccessState.active)); + expect(check.state, isNot(MosaicCustomerAccessState.inactive)); + await pumpEventQueue(); + expect(updates.whereType(), isNotEmpty); + runtime.dispose(); + }); + + test('signing out discards the token and the cached snapshot', () async { + final cache = MosaicMemoryCustomerEntitlementCache(); + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + ]); + final runtime = runtimeWith(transport, cache: cache); + await runtime.bindIdentity( + MosaicIdentityState( + installationId: 'installation_a', + generation: 1, + userId: 'user-a', + ), + ); + await runtime.refresh(); + + await runtime.clearCustomer(); + + expect(runtime.snapshot, isNull); + expect(runtime.diagnostics.token.hasToken, isFalse); + expect( + runtime.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.unavailable, + ); + runtime.dispose(); + }); + + test('a snapshot bound to another customer clears the cache loudly', + () async { + final severeCodes = []; + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + received('snapshots/test-source-sandbox-grant.json'), + ]); + final runtime = runtimeWith( + transport, + onDiagnostic: (code, {required bool severe}) { + if (severe) severeCodes.add(code); + }, + ); + await runtime.refresh(); + final updates = []; + runtime.updates.listen(updates.add); + + final rejected = await runtime.refresh(); + + expect( + rejected, + isA() + .having( + (value) => value.reasonCode, 'reasonCode', 'customer_mismatch') + .having( + (value) => value.cacheAction, + 'cacheAction', + MosaicCustomerCacheAction.clear, + ), + ); + // The one rejection that clears. Preserving here would keep serving the + // previous customer's access under a new identity. + expect(runtime.snapshot, isNull); + expect( + runtime.checkCustomerEntitlement('pro').state, + MosaicCustomerAccessState.unknown, + ); + await pumpEventQueue(); + expect(updates.whereType(), isNotEmpty); + expect(severeCodes, isNotEmpty); + runtime.dispose(); + }); + + test('a rejected document leaves the accepted snapshot in place', () async { + final transport = + _RecordingTransport([ + received('snapshots/active-subscription.json'), + MosaicCustomerEntitlementSyncReceived( + source: fixture('invalid/snapshot-entry-unknown-field.json'), + ), + ]); + final runtime = runtimeWith(transport); + await runtime.refresh(); + + final rejected = await runtime.refresh(); + + expect( + rejected, + isA().having( + (value) => value.cacheAction, + 'cacheAction', + MosaicCustomerCacheAction.preserve, + ), + ); + expect(runtime.snapshot!.snapshotVersion, 4); + runtime.dispose(); + }); +} diff --git a/sdk/flutter/test/customer_entitlement_vectors_test.dart b/sdk/flutter/test/customer_entitlement_vectors_test.dart new file mode 100644 index 00000000..5cf31930 --- /dev/null +++ b/sdk/flutter/test/customer_entitlement_vectors_test.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosaic_sdk/mosaic_sdk.dart'; + +import 'support/canonical_fixture.dart'; + +/// The cross-platform divergence guard. +/// +/// Go, Dart, Swift, and Kotlin must agree on these three tables exactly. They +/// are read straight from the shared reference vectors rather than restated +/// here, so a Dart implementation that drifts from the contract fails even when +/// it is internally self-consistent. +void main() { + group('entitlement snapshot digest vectors', () { + final vectors = _vectors('entitlement-snapshot-digest-vectors.json'); + + for (final vector in vectors) { + final id = vector['id']! as String; + test('$id digests canonically', () { + final payload = (vector['payload']! as Map).cast(); + final excluded = vector['excludedMember'] as String? ?? 'contentDigest'; + expect( + mosaicCustomerContentDigest(payload, excludedMember: excluded), + vector['digest'], + reason: vector['notes'] as String?, + ); + }); + } + + test('covers every published vector', () { + expect(vectors, hasLength(9)); + }); + }); + + group('entitlement cache decision vectors', () { + final document = _document('entitlement-cache-decision-vectors.json'); + final vectors = (document['vectors']! as List).cast>(); + + for (final vector in vectors) { + final id = vector['id']! as String; + test('$id decides as the contract requires', () { + final incoming = (vector['incoming']! as Map).cast(); + final rawCached = vector['cached']; + final decision = mosaicEvaluateCustomerCacheDecision( + contractVersion: incoming['contractVersion']! as String, + incomingBinding: _binding(incoming), + incomingSnapshotVersion: incoming['snapshotVersion']! as int, + incomingAsOf: DateTime.parse(incoming['asOf']! as String), + contentDigestValid: incoming['contentDigestValid']! as bool, + cached: rawCached == null + ? null + : _cached((rawCached as Map).cast()), + ); + expect( + decision.accepted, + vector['decision'] == 'accept', + reason: vector['notes'] as String?, + ); + expect(decision.reasonCode, vector['reason']); + expect(decision.cacheAction.name, _action(vector['cacheAction'])); + }); + } + + test('no rejection vector ever resolves to inactive', () { + // The contract's top rule, asserted against the vector table itself so a + // future vector that said otherwise would fail here rather than ship. + for (final vector in vectors) { + if (vector['decision'] == 'reject') { + expect(vector['resultingAccessState'], isNot('inactive')); + } + } + }); + + test('covers every published vector', () { + expect(vectors, hasLength(11)); + }); + }); + + group('entitlement freshness vectors', () { + final document = _document('entitlement-freshness-vectors.json'); + final vectors = (document['vectors']! as List).cast>(); + + for (final vector in vectors) { + final id = vector['id']! as String; + test('$id lands in the published band', () { + final snapshot = (vector['snapshot']! as Map).cast(); + final state = mosaicEvaluateCustomerFreshness( + issuedAt: DateTime.parse(snapshot['issuedAt']! as String), + refreshAfter: DateTime.parse(snapshot['refreshAfter']! as String), + validUntil: DateTime.parse(snapshot['validUntil']! as String), + staleGraceSeconds: snapshot['staleGraceSeconds']! as int, + deviceNow: DateTime.parse(vector['deviceNow']! as String), + clockSkewToleranceSeconds: + vector['clockSkewToleranceSeconds']! as int, + ); + expect( + state, + _cacheState(vector['state']! as String), + reason: vector['notes'] as String?, + ); + }); + } + + test('the shipped policy is bounded grace with a 60-second tolerance', () { + final policy = (document['policy']! as Map).cast(); + expect(policy['name'], 'boundedGrace'); + expect( + policy['clockSkewToleranceSeconds'], + mosaicCustomerEntitlementClockSkewToleranceSeconds, + ); + expect( + policy['defaultStaleGraceSeconds'], + mosaicCustomerEntitlementDefaultStaleGraceSeconds, + ); + expect( + policy['maxCacheHorizonSeconds'], + mosaicCustomerEntitlementMaximumCacheHorizonSeconds, + ); + }); + + test('covers every published vector', () { + expect(vectors, hasLength(12)); + }); + }); +} + +Map _document(String name) => jsonDecode( + repositoryFile('packages/test-fixtures/src/$name').readAsStringSync(), + ) as Map; + +List> _vectors(String name) => + (_document(name)['vectors']! as List).cast>(); + +MosaicCustomerBinding _binding(Map value) => + MosaicCustomerBinding( + billingCustomerId: value['billingCustomerId']! as String, + projectId: value['projectId']! as String, + environmentId: value['environmentId']! as String, + ); + +MosaicCustomerCachedSnapshotSummary _cached(Map value) => + MosaicCustomerCachedSnapshotSummary( + binding: _binding(value), + snapshotVersion: value['snapshotVersion']! as int, + asOf: DateTime.parse(value['asOf']! as String), + ); + +String _action(Object? value) => switch (value) { + 'replace' => 'replace', + 'preserve' => 'preserve', + 'clear' => 'clear', + _ => fail('Unknown cache action $value.'), + }; + +MosaicEntitlementCacheState _cacheState(String value) => switch (value) { + 'fresh' => MosaicEntitlementCacheState.fresh, + 'refresh_recommended' => MosaicEntitlementCacheState.refreshRecommended, + 'stale_within_grace' => MosaicEntitlementCacheState.staleWithinGrace, + 'expired' => MosaicEntitlementCacheState.expired, + _ => fail('Unknown freshness state $value.'), + }; diff --git a/sdk/flutter/test/customer_restore_sync_test.dart b/sdk/flutter/test/customer_restore_sync_test.dart new file mode 100644 index 00000000..2a76c7ec --- /dev/null +++ b/sdk/flutter/test/customer_restore_sync_test.dart @@ -0,0 +1,275 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosaic_sdk/mosaic_sdk.dart'; + +import 'support/canonical_fixture.dart'; + +final class _ScriptedTransport implements MosaicCustomerEntitlementTransport { + _ScriptedTransport(this.responses); + + final List responses; + var calls = 0; + + @override + Future sync( + MosaicCustomerEntitlementSyncRequest request, + ) async { + final response = responses[calls.clamp(0, responses.length - 1)]; + calls += 1; + return response; + } +} + +final class _ScriptedProvider implements MosaicPurchaseProvider { + _ScriptedProvider(this.result); + + final MosaicRestoreResult result; + var restores = 0; + + @override + Future restore() async { + restores += 1; + return result; + } + + @override + Future loadProducts(Iterable productIds) => + throw UnimplementedError(); + + @override + Future purchase(String productId) => + throw UnimplementedError(); + + @override + Future activeEntitlements() => + throw UnimplementedError(); +} + +void main() { + final root = repositoryDirectory( + 'protocol/fixtures/authoritative-entitlement/v1', + ); + String fixture(String path) => File('${root.path}/$path').readAsStringSync(); + + final now = DateTime.utc(2026, 7, 28, 12, 30); + + MosaicCustomerEntitlementSyncReceived received(String path) => + MosaicCustomerEntitlementSyncReceived( + source: fixture(path), + ); + + MosaicCustomerEntitlementRuntime runtimeWith( + MosaicCustomerEntitlementTransport transport, + ) => + MosaicCustomerEntitlementRuntime( + baseUrl: Uri.parse('https://api.mosaic.test'), + publicSdkKey: 'public_key', + transport: transport, + cache: MosaicMemoryCustomerEntitlementCache(), + tokenProvider: (_) async => MosaicCustomerToken( + value: 'mcat_secret', + tokenId: 'token-a', + expiresAt: now.add(const Duration(hours: 1)), + ), + settings: + const MosaicCustomerEntitlementSettings(refreshOnResume: false), + clock: () => now, + ); + + MosaicCustomerRestoreCoordinator coordinator( + MosaicPurchaseProvider provider, + MosaicCustomerEntitlementRuntime runtime, + ) => + MosaicCustomerRestoreCoordinator( + purchaseProvider: provider, + entitlements: runtime, + clock: () => now, + // The poll bound is real; only the waiting is elided so the suite does + // not spend six seconds proving it. + delay: (_) async {}, + ); + + test('restored requires an accepted snapshot that reflects the restore', + () async { + final transport = _ScriptedTransport( + [ + received('snapshots/active-subscription.json'), + ], + ); + final runtime = runtimeWith(transport); + final provider = _ScriptedProvider(MosaicRestored(const [ + MosaicEntitlement(id: 'pro'), + ])); + + final result = + await coordinator(provider, runtime).restorePurchasesAndSync(); + + expect(result, isA()); + final restored = result as MosaicCustomerEntitlementsRestored; + expect(restored.snapshotVersion, 4); + expect( + restored.providerOutcome, + MosaicCustomerRestoreProviderOutcome.completed, + ); + expect(restored.outcome.wireValue, 'restored'); + runtime.dispose(); + }); + + test('a successful native restore Mosaic has not confirmed is pending', + () async { + // The failure this prevents: telling a user their purchases are restored + // because the store said so, while Mosaic still grants nothing. + final transport = _ScriptedTransport( + [ + const MosaicCustomerEntitlementSyncFailed( + diagnosticCode: 'entitlements.sync.networkFailed', + ), + ], + ); + final runtime = runtimeWith(transport); + final provider = _ScriptedProvider(MosaicRestored(const [ + MosaicEntitlement(id: 'pro'), + ])); + + final result = + await coordinator(provider, runtime).restorePurchasesAndSync(); + + expect(result, isA()); + expect( + (result as MosaicCustomerRestoreValidationPending).uncertainty.isDefinite, + isFalse, + ); + expect(result.outcome.wireValue, 'validation_pending'); + // Three attempts, exactly as the cross-platform bound states. + expect(transport.calls, mosaicCustomerRestorePollAttempts); + runtime.dispose(); + }); + + test('an unchanged snapshot is never reported as restored', () async { + final transport = _ScriptedTransport( + [ + received('snapshots/active-subscription.json'), + const MosaicCustomerEntitlementSyncNotModified(), + ], + ); + final runtime = runtimeWith(transport); + await runtime.refresh(); + final provider = _ScriptedProvider(MosaicRestored(const [ + MosaicEntitlement(id: 'pro'), + ])); + + final result = + await coordinator(provider, runtime).restorePurchasesAndSync(); + + // The version did not advance, so nothing new was proven. Access may well + // already be active; that is a different question from "the restore + // produced something". + expect(result, isA()); + runtime.dispose(); + }); + + test('nothing to restore, and Mosaic agreeing, is a definite answer', + () async { + final transport = _ScriptedTransport( + [ + const MosaicCustomerEntitlementSyncNotModified(), + ], + ); + final runtime = runtimeWith(transport); + final provider = _ScriptedProvider(const MosaicNothingToRestore()); + + final result = + await coordinator(provider, runtime).restorePurchasesAndSync(); + + expect(result, isA()); + expect( + result.providerOutcome, + MosaicCustomerRestoreProviderOutcome.noPurchasesFound, + ); + runtime.dispose(); + }); + + test('a cancelled restore never reaches the authoritative poll', () async { + final transport = + _ScriptedTransport([]); + final runtime = runtimeWith(transport); + final provider = _ScriptedProvider(const MosaicRestoreCancelled()); + + final result = + await coordinator(provider, runtime).restorePurchasesAndSync(); + + expect(result, isA()); + expect( + result.providerOutcome, + MosaicCustomerRestoreProviderOutcome.cancelled, + ); + expect(transport.calls, 0); + runtime.dispose(); + }); + + test('a provider failure is reported as unavailable, not as no purchases', + () async { + final transport = + _ScriptedTransport([]); + final runtime = runtimeWith(transport); + final provider = _ScriptedProvider( + const MosaicRestoreProviderUnavailable(), + ); + + final result = + await coordinator(provider, runtime).restorePurchasesAndSync(); + + expect(result, isA()); + expect(result.outcome.wireValue, 'provider_unavailable'); + runtime.dispose(); + }); + + test('every restore reports observable stages', () async { + final transport = _ScriptedTransport( + [ + received('snapshots/active-subscription.json'), + ], + ); + final runtime = runtimeWith(transport); + final provider = _ScriptedProvider(MosaicRestored(const [ + MosaicEntitlement(id: 'pro'), + ])); + + final result = + await coordinator(provider, runtime).restorePurchasesAndSync(); + + expect( + result.stages.map((stage) => stage.name), + containsAllInOrder([ + MosaicCustomerRestoreStageName.providerRestore, + MosaicCustomerRestoreStageName.observationHandoff, + MosaicCustomerRestoreStageName.authoritativeSync, + MosaicCustomerRestoreStageName.completed, + ]), + ); + runtime.dispose(); + }); + + test('the SDK vocabulary covers every canonical restore outcome', () { + // Conformance against the canonical restore fixtures. A contract outcome + // the SDK cannot express would silently become some other outcome. + final outcomes = {}; + final providerOutcomes = {}; + for (final file + in canonicalFixtureFiles(Directory('${root.path}/restores'))) { + final payload = (jsonDecode(file.readAsStringSync()) + as Map)['payload']! as Map; + outcomes.add(payload['outcome']! as String); + providerOutcomes.add(payload['providerOutcome']! as String); + } + + final known = + MosaicCustomerRestoreOutcome.values.map((value) => value.wireValue); + final knownProvider = MosaicCustomerRestoreProviderOutcome.values + .map((value) => value.wireValue); + expect(known, containsAll(outcomes)); + expect(knownProvider, containsAll(providerOutcomes)); + }); +} diff --git a/sdk/flutter/test/transaction_observation_test.dart b/sdk/flutter/test/transaction_observation_test.dart index f122b426..8da213fe 100644 --- a/sdk/flutter/test/transaction_observation_test.dart +++ b/sdk/flutter/test/transaction_observation_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:mosaic_sdk/mosaic_sdk.dart'; @@ -28,6 +29,8 @@ Map _vectors() => jsonDecode( ) as Map; void main() { + group('customer token binding', _customerTokenBindingTests); + // Purpose: a purchase observed on both production paths must reach the // ingestion endpoint exactly once, and must survive an app kill. Without // this, the two sources double-submit and a purchase completed just before @@ -706,3 +709,155 @@ final class _AlwaysRetryableTransport ); } } + +/// Phase 9B: the Customer Access Token binds a validated purchase to an +/// identified Billing Customer. Without it a purchase can only anchor to a +/// purchase-anchored customer, so the header is the association evidence rung +/// — and it must be read at send time, never stored with the queue. +void _customerTokenBindingTests() { + MosaicTransactionObservation observation() => MosaicTransactionObservation( + providerId: 'fixture-provider-apple', + storePlatform: MosaicStorePlatform.ios, + reference: MosaicTransactionReference.tryFor( + MosaicStorePlatform.ios, + '2000000900000001', + )!, + observedAt: DateTime.utc(2026, 7, 28, 12), + context: const MosaicTransactionObservationContext( + platform: 'ios', + sdkVersion: '0.2.0', + ), + ); + + Future<(HttpServer, List)> acceptingServer() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final seen = []; + unawaited(() async { + await for (final request in server) { + seen.add(request.headers); + await request.drain(); + request.response + ..statusCode = 200 + ..headers.contentType = ContentType.json + ..write(jsonEncode({ + 'billingIngestionContractVersion': + mosaicBillingIngestionContractVersion, + 'recordType': 'observationSubmissionResult', + 'payload': { + 'submissionId': observation().submissionId, + 'status': 'accepted_for_validation', + }, + })); + await request.response.close(); + } + }()); + return (server, seen); + } + + test('a held token binds the submission at send time', () async { + final (server, seen) = await acceptingServer(); + addTearDown(() => server.close(force: true)); + final transport = MosaicIoTransactionObservationTransport( + baseUrl: Uri.parse('http://127.0.0.1:${server.port}'), + publicSdkKey: 'public_sdk_key_test', + customerToken: () => 'mcat_customer_token', + ); + + final result = await transport.submit(observation()); + + expect(result, isA()); + expect(seen.single.value('Mosaic-Customer-Token'), 'mcat_customer_token'); + // The public SDK key still identifies the application. Neither header + // substitutes for the other. + expect(seen.single.value('authorization'), 'Bearer public_sdk_key_test'); + }); + + test('a signed-out submission omits the header rather than waiting', + () async { + final (server, seen) = await acceptingServer(); + addTearDown(() => server.close(force: true)); + final transport = MosaicIoTransactionObservationTransport( + baseUrl: Uri.parse('http://127.0.0.1:${server.port}'), + publicSdkKey: 'public_sdk_key_test', + customerToken: () => null, + ); + + await transport.submit(observation()); + + // Anonymous submission is valid. Blocking or minting here would turn a + // fire-and-forget path into a dependency on the host's backend. + expect(seen.single.value('Mosaic-Customer-Token'), isNull); + }); + + test('a token minted after the purchase still binds the retry', () async { + final (server, seen) = await acceptingServer(); + addTearDown(() => server.close(force: true)); + String? held; + final transport = MosaicIoTransactionObservationTransport( + baseUrl: Uri.parse('http://127.0.0.1:${server.port}'), + publicSdkKey: 'public_sdk_key_test', + customerToken: () => held, + ); + + // Enqueued and sent while signed out, then sent again after sign-in. The + // resolver is read at send time, so the second attempt carries the binding. + await transport.submit(observation()); + held = 'mcat_after_sign_in'; + await transport.submit(observation()); + + expect(seen.first.value('Mosaic-Customer-Token'), isNull); + expect(seen.last.value('Mosaic-Customer-Token'), 'mcat_after_sign_in'); + }); + + test('a resolver that throws never becomes a failed submission', () async { + final (server, seen) = await acceptingServer(); + addTearDown(() => server.close(force: true)); + final transport = MosaicIoTransactionObservationTransport( + baseUrl: Uri.parse('http://127.0.0.1:${server.port}'), + publicSdkKey: 'public_sdk_key_test', + customerToken: () => throw StateError('token unavailable'), + ); + + final result = await transport.submit(observation()); + + expect(result, isA()); + expect(seen.single.value('Mosaic-Customer-Token'), isNull); + }); + + test('the token never reaches the persisted queue or diagnostics', () async { + final storage = MosaicMemoryTransactionObservationStorage(); + final runtime = MosaicTransactionObservationRuntime( + namespace: _namespace, + transport: _NeverDeliversTransport(), + storePlatform: MosaicStorePlatform.ios, + context: const MosaicTransactionObservationContext( + platform: 'ios', + sdkVersion: '0.2.0', + ), + storage: storage, + ); + + await runtime.observe( + providerId: 'fixture-provider-apple', + transactionReference: '2000000900000001', + ); + + // The credential is a transport-time header and nothing else. A queue file + // that carried it would persist a bearer token across app restarts. + expect(storage.source, isNotNull); + expect(storage.source, isNot(contains('mcat_'))); + expect(storage.source, isNot(contains('customerToken'))); + final diagnostics = await runtime.diagnostics(); + expect(diagnostics.toString(), isNot(contains('mcat_'))); + await runtime.disposeRuntime(); + }); +} + +final class _NeverDeliversTransport + implements MosaicTransactionObservationTransport { + @override + Future submit( + MosaicTransactionObservation observation, + ) => + Completer().future; +} diff --git a/sdk/ios/README.md b/sdk/ios/README.md index 78282d1d..1f3f8205 100644 --- a/sdk/ios/README.md +++ b/sdk/ios/README.md @@ -532,6 +532,175 @@ Behaviour worth knowing before enabling it: accepted, duplicate, permanently rejected, retry, and dropped counters plus the last safe code. `flushTransactionObservations()` attempts delivery now. +When a `customerTokenProvider` is configured, a submission also carries the +current Customer Access Token in a `Mosaic-Customer-Token` header, which binds +the purchase to that Billing Customer server-side. The token is read at **send** +time, not enqueue time, so a purchase queued before sign-in is still bound +correctly once the user signs in. It is never written to the queue and never +logged. Without a token the submission still succeeds — it just anchors +anonymously and has to be associated later by other evidence. + +## Authoritative customer entitlements + +Two different questions, two different answers, both available: + +| | Provider-observed | Authoritative | +| --- | --- | --- | +| Asks | what this device's store account shows | what Mosaic has validated for this Billing Customer | +| Source | StoreKit / RevenueCat on device | Mosaic's server-side projection of provider facts | +| API | `activeEntitlements()`, entitlement targeting | `checkCustomerEntitlement(_:)` and friends | +| Spans devices and platforms | no | yes | +| Needs an app backend | no | **yes** | + +The provider-observed surface is unchanged. The authoritative surface is +additive: nothing that already worked behaves differently. + +### Mosaic Billing requires an application backend + +A public SDK key identifies an *application*; it can never select a *customer*. +An application user ID is guessable, so it cannot either. Reading someone's +billing state therefore needs a **Customer Access Token**, which only your +backend can mint: + +```text +your app → your backend (authenticates your user) + → Mosaic, with your secret_server key + → token, returned once + → back to the app + → SDK attaches it to every entitlement sync +``` + +There is no anonymous mode. Allowing a client-generated installation identifier +to select a Billing Customer would let anyone read someone else's entitlements +by guess or replay. + +```swift +let mosaic = try await Mosaic.configure( + publicSDKKey: key, + baseURL: baseURL, + purchaseProvider: provider, + customerTokenProvider: MosaicClosureCustomerTokenProvider { forceRefresh in + guard let user = await MyAuth.currentUser else { return .signedOut } + do { + return .token(MosaicCustomerAccessToken(try await MyBackend.mosaicToken(for: user))) + } catch { + // Never `.signedOut` for a backend failure: a backend that cannot mint a + // token has not revoked anyone's subscription. + return .unavailable + } + }) +``` + +The sync surface is a `POST` to `/v1/sdk/billing/entitlements` carrying the +canonical `entitlementSyncRequest` envelope, because contract negotiation lives +in the request body. Conditional revalidation rides on `If-None-Match`, and the +server confirms a current snapshot with a `snapshotUnchanged` record that also +slides the freshness window. + +Tokens are held **in memory only** — never the keychain, never a file — and are +never logged or parsed. On a refusal the SDK forces exactly one token refresh +per generation; a second refusal is a real failure, not something to retry. + +### Reading access + +```swift +let check = await mosaic.checkCustomerEntitlement("pro") +switch check.state { +case .active: unlock(stale: check.isStale) +case .inactive: showPaywall() +case .unknown(let reason): // Mosaic could not determine this +case .unavailable(let reason): // Mosaic could not answer at all +} +``` + +There is no boolean convenience API anywhere, deliberately. `unknown` and +`unavailable` are not `inactive`, and an API that collapsed them would make +that mistake easy to write and impossible to see. + +An entitlement key the snapshot does not carry reads `unknown`, **not** +`inactive`. Absence is not a statement Mosaic made: the key may be undefined for +the Project, unresolved by the projection, or simply outside a narrowed request. +`inactive` requires an entry that says so. + +> **The one rule.** A rejected response, a network failure, an expired cache, an +> unknown field, a digest mismatch, a token your backend could not mint — every +> one of those is `unknown` or `unavailable`. `inactive` is a claim about a +> person and only ever comes from a snapshot Mosaic issued and the SDK fully +> accepted. A reader that collapsed the two would turn every Mosaic outage into +> a mass revocation experienced by paying customers. + +Observe changes with a stream that replays current state to each new subscriber: + +```swift +for await update in await mosaic.customerEntitlementUpdates() { + switch update { + case .snapshot(let value): apply(value.snapshot, stale: value.cacheState.isStale) + case .signedOut, .cleared: lockEverything() + case .unavailable(let reason): keepCurrentUIAndRetry(reason) + case .loading: break + } +} +``` + +### Offline behaviour: bounded grace + +The shipped policy is bounded grace, uniform across iOS, Android, and Flutter. +Windows are server-issued per Environment: + +| Window | Cache state | Behaviour | +| --- | --- | --- | +| before `refreshAfter` (default 1 h) | `fresh` | serve; do not refresh | +| until `validUntil` (default 7 d) | `refreshRecommended` | fully valid | +| + `staleGraceSeconds` (default 24 h) | `staleWithinGrace` | previously active access continues and **must be shown as stale** | +| after that | `expired` | `unknown` — never `inactive` | + +Clock skew tolerance is 60 seconds. A device clock set earlier than issuance is +treated as unreliable and takes the expired path, because a cache whose age +cannot be measured cannot be trusted to be young. A strict policy is the same +fields with a grace window of zero. + +The cache is per-customer (the binding digest is in the file name), excluded +from backup, atomic, and checksummed for corruption detection. It supports UI +continuity and feature gating — **it is not a credential**, and your backend +must never accept one from a client as proof of access. Protected resources are +authorized by your own server. + +### Restore + +```swift +let result = await mosaic.restoreAndSyncCustomerEntitlements() +result.providerResult // what StoreKit did, verbatim +result.outcome // what Mosaic can say +result.authoritativeEntitlementsUpdated // true only with an accepted snapshot +result.stages // render as progress +``` + +A restore is two operations, so the result reports two axes. A successful native +restore whose facts Mosaic has not yet validated is `validationPending`, not +`restored`: the accepted snapshot is the evidence that makes the outcome +authoritative rather than hopeful. The validation poll is bounded at 3 attempts +over roughly 6 seconds. + +The StoreKit adapter now submits observations on the restore path as well as the +purchase path, so a fresh-device restore actually reaches Mosaic. Both +de-duplication layers make repeated restores idempotent. + +### Refresh timing + +Refreshes happen at `configure`, on foreground, and whenever you ask. **There is +no background refresh**: no `BGTaskScheduler`, no silent push. A device that has +been offline for days is exactly what the grace window and the `expired` state +describe. After a purchase, call `customerEntitlementsDidChangeAfterPurchase()`; +it is fire-and-forget and never a suspension point on the purchase path. + +### Identity + +`identify(userID:)`, `resetIdentity()`, and `resetInstallationIdentity()` all +fan out to the entitlement client: the token generation is bumped, in-flight +requests are cancelled, and the cache is cleared **before any read can return +the previous person's grants**. Installation identity is preserved. +`clearCustomerState()` does the same on request. + ## Bundled fallback and direct rendering The preview screen takes a valid bundled `MosaicPaywallDocument` and an diff --git a/sdk/ios/Sources/MosaicSDK/Configuration.swift b/sdk/ios/Sources/MosaicSDK/Configuration.swift index c8528c6e..da838bf5 100644 --- a/sdk/ios/Sources/MosaicSDK/Configuration.swift +++ b/sdk/ios/Sources/MosaicSDK/Configuration.swift @@ -90,6 +90,7 @@ public struct Mosaic: Sendable { private let identityStore: MosaicIdentityStore private let analyticsRuntime: MosaicAnalyticsRuntime? private let transactionObservationRuntime: MosaicTransactionObservationRuntime? + private let entitlementClient: MosaicCustomerEntitlementClient? private init( configuration: MosaicConfiguration, @@ -98,7 +99,8 @@ public struct Mosaic: Sendable { identityStore: MosaicIdentityStore = MosaicIdentityStore( persistence: MosaicMemoryIdentityPersistence()), analyticsRuntime: MosaicAnalyticsRuntime? = nil, - transactionObservationRuntime: MosaicTransactionObservationRuntime? = nil + transactionObservationRuntime: MosaicTransactionObservationRuntime? = nil, + entitlementClient: MosaicCustomerEntitlementClient? = nil ) { self.configuration = configuration self.purchaseProvider = purchaseProvider @@ -106,6 +108,7 @@ public struct Mosaic: Sendable { self.identityStore = identityStore self.analyticsRuntime = analyticsRuntime self.transactionObservationRuntime = transactionObservationRuntime + self.entitlementClient = entitlementClient } public static func configure( @@ -132,7 +135,8 @@ public struct Mosaic: Sendable { requestTimeout: TimeInterval = 5, bundledFallback: MosaicConfigurationBundledFallback = .packaged, transactionObservations: MosaicTransactionObservationMode = .disabled, - purchaseProvider: any MosaicPurchaseProvider + purchaseProvider: any MosaicPurchaseProvider, + customerTokenProvider: (any MosaicCustomerTokenProvider)? = nil ) async throws -> Mosaic { try await configureHosted( publicSDKKey: publicSDKKey, @@ -142,6 +146,7 @@ public struct Mosaic: Sendable { bundledFallback: bundledFallback, transactionObservations: transactionObservations, purchaseProvider: purchaseProvider, + customerTokenProvider: customerTokenProvider, persistenceRoot: .applicationSupport ) } @@ -154,6 +159,7 @@ public struct Mosaic: Sendable { bundledFallback: MosaicConfigurationBundledFallback, transactionObservations: MosaicTransactionObservationMode = .disabled, purchaseProvider: any MosaicPurchaseProvider, + customerTokenProvider: (any MosaicCustomerTokenProvider)? = nil, persistenceRoot: MosaicPersistenceRoot ) async throws -> Mosaic { let configuration = try MosaicConfiguration( @@ -207,6 +213,13 @@ public struct Mosaic: Sendable { let analyticsRuntime = analytics.runtime if analytics.degraded { degraded = true } + // Built before the observation runtime so an observation submission can + // carry the customer token that binds a purchase to its Billing Customer. + // Memory-only, so constructing it costs nothing and persists nothing. + let customerTokenStore = customerTokenProvider.map { + MosaicCustomerTokenStore(provider: $0) + } + // Opt-in. When observations are disabled no runtime exists, so nothing is // built, queued, persisted, or sent. var observationRuntime: MosaicTransactionObservationRuntime? @@ -216,6 +229,36 @@ public struct Mosaic: Sendable { applicationVersion: configuration.applicationVersion, rootDirectory: root) observationRuntime = observations.runtime if observations.degraded { degraded = true } + // Without this an identified user's purchase anchors anonymously and has + // to be associated later by other evidence. + await observationRuntime?.attachCustomerTokenSource(customerTokenStore) + } + + // Authoritative entitlements are opt-in: with no customer token provider + // there is no client at all, so nothing is fetched, cached, or persisted. + // Mosaic Billing requires an application backend (OD-4). + var entitlementClient: MosaicCustomerEntitlementClient? + if let customerTokenStore { + let identity = await identityStore.snapshot() + let bindingDigest = MosaicCustomerEntitlementFileCacheStore.bindingDigest( + userID: identity.userID) + entitlementClient = MosaicCustomerEntitlementClient( + publicSDKKey: key, + baseURL: baseURL, + requestTimeout: requestTimeout, + transport: MosaicURLSessionEntitlementSyncTransport(requestTimeout: requestTimeout), + tokenStore: customerTokenStore, + bindingDigest: bindingDigest, + cacheStoreFactory: { digest in + if let root, + let store = try? MosaicCustomerEntitlementFileCacheStore( + baseURL: baseURL, publicSDKKey: key, customerBindingDigest: digest, + rootDirectory: root) + { + return store + } + return MosaicCustomerEntitlementMemoryCacheStore() + }) } let client = MosaicConfigurationClient( @@ -240,6 +283,15 @@ public struct Mosaic: Sendable { await MosaicAnalyticsLifecycleRegistry.install( runtime: analyticsRuntime, namespace: namespace) + if let entitlementClient { + // The cached snapshot is read so a launch has an answer immediately; the + // network refresh is detached so entitlements never delay the host's + // configure call. + await entitlementClient.bootstrap() + await MosaicCustomerEntitlementLifecycleRegistry.install( + client: entitlementClient, namespace: namespace) + Task.detached(priority: .utility) { _ = await entitlementClient.refresh() } + } if let observationRuntime { await MosaicTransactionObservationLifecycleRegistry.install( runtime: observationRuntime, namespace: namespace) @@ -253,7 +305,8 @@ public struct Mosaic: Sendable { configurationClient: client, identityStore: identityStore, analyticsRuntime: analyticsRuntime, - transactionObservationRuntime: observationRuntime + transactionObservationRuntime: observationRuntime, + entitlementClient: entitlementClient ) } @@ -393,6 +446,14 @@ public struct Mosaic: Sendable { if before.userID != after.userID { await configurationClient?.identityChanged(user: true, installation: false) await analyticsRuntime?.identityChanged() + await entitlementClient?.identityChanged( + bindingDigest: MosaicCustomerEntitlementFileCacheStore.bindingDigest( + userID: after.userID), + signedOut: false) + // The new identity's entitlements are fetched off the caller's path. + if let entitlementClient { + Task.detached(priority: .utility) { _ = await entitlementClient.refresh() } + } } } @@ -408,6 +469,11 @@ public struct Mosaic: Sendable { if before.userID != nil || !before.attributes.isEmpty { await configurationClient?.identityChanged(user: true, installation: false) await analyticsRuntime?.identityChanged() + // Logout semantics: the token is discarded and the cache cleared before + // any read can return the previous person's grants. + await entitlementClient?.identityChanged( + bindingDigest: MosaicCustomerEntitlementFileCacheStore.bindingDigest(userID: nil), + signedOut: true) } } @@ -416,6 +482,9 @@ public struct Mosaic: Sendable { try await identityStore.resetInstallation() await configurationClient?.identityChanged(user: true, installation: true) await analyticsRuntime?.identityChanged() + await entitlementClient?.identityChanged( + bindingDigest: MosaicCustomerEntitlementFileCacheStore.bindingDigest(userID: nil), + signedOut: true) } /// Applies the Environment owner/admin collection setting and a host-app @@ -531,6 +600,107 @@ public struct Mosaic: Sendable { return values } + // MARK: - Authoritative entitlements + // + // These read Mosaic's server-side projection of what a Billing Customer is + // entitled to. They are additive and independent of the provider-observed + // surface (`purchaseProvider.activeEntitlements()`, entitlement targeting), + // which is unchanged: provider-observed answers what this device's store + // account shows, authoritative answers what Mosaic has validated for this + // customer across their devices and platforms. + // + // All of it requires a `customerTokenProvider`, because Mosaic Billing + // requires an application backend: a public SDK key identifies an application + // and can never select a customer. + + /// Answers one focused access question. Never a bare boolean, and never + /// `inactive` unless an accepted snapshot says so. + public func checkCustomerEntitlement(_ key: String) async -> MosaicCustomerEntitlementCheck { + guard let entitlementClient else { + return MosaicCustomerEntitlementCheck( + entitlementKey: key, state: .unavailable(reason: .notConfigured), cacheState: .missing) + } + return await entitlementClient.check(key: key) + } + + /// The full accepted snapshot and how fresh it is, or `nil` when none has been + /// accepted for this customer. + public func customerEntitlementSnapshot() async -> MosaicCustomerEntitlementSnapshotUpdate? { + await entitlementClient?.snapshot() + } + + /// A stream of accepted changes. Each subscriber gets its own stream and is + /// replayed the current state on subscription. + public func customerEntitlementUpdates() async + -> AsyncStream + { + guard let entitlementClient else { + return AsyncStream { continuation in + continuation.yield(.unavailable(.notConfigured)) + continuation.finish() + } + } + return await entitlementClient.updates() + } + + @discardableResult + public func refreshCustomerEntitlements() async -> MosaicCustomerEntitlementRefreshResult { + guard let entitlementClient else { + return .unavailable( + reason: .notConfigured, + diagnostics: [ + MosaicDiagnostic(code: "entitlement_not_configured", stage: .entitlementValidation) + ]) + } + return await entitlementClient.refresh() + } + + public func customerEntitlementDiagnostics() async -> MosaicCustomerEntitlementDiagnostics { + guard let entitlementClient else { return .notConfigured } + return await entitlementClient.diagnosticsSnapshot() + } + + /// Runs a native restore and then waits, briefly and boundedly, for Mosaic to + /// project it. + /// + /// The result reports both axes separately. `authoritativeEntitlementsUpdated` + /// is true only when an accepted snapshot reflects the restore, so a host can + /// tell "the store found your purchase" apart from "Mosaic has confirmed it". + public func restoreAndSyncCustomerEntitlements() async -> MosaicRestoreAndSyncResult { + let providerRestore: @Sendable () async -> MosaicRestoreResult = { [purchaseProvider] in + await purchaseProvider.restore() + } + guard let entitlementClient else { + let result = await providerRestore() + return MosaicRestoreAndSyncResult( + outcome: .identityUnresolved, + stages: [.providerRestoreStarted, .providerRestoreFinished(result)], + providerResult: result, + authoritativeEntitlementsUpdated: false, + snapshotVersion: nil, + completedAt: Date()) + } + return await MosaicCustomerRestoreCoordinator(client: entitlementClient) + .run(restore: providerRestore) + } + + /// Discards the held customer token and deletes this customer's cached + /// snapshot. Installation identity is preserved. + public func clearCustomerState() async { + await entitlementClient?.clearCustomerState() + } + + /// Refreshes authoritative entitlements after a purchase completes. + /// + /// Fire-and-forget by design: the refresh runs in a detached task so it is + /// never a suspension point on the purchase path. A purchase must never be + /// held open waiting for a projection, and a failed refresh must never change + /// a purchase result. + public func customerEntitlementsDidChangeAfterPurchase() { + guard let entitlementClient else { return } + Task.detached(priority: .utility) { _ = await entitlementClient.refresh() } + } + /// Returns the exact accepted Configuration Release association required to /// validate a Commerce Configuration sidecar. No sidecar should be decoded /// or cached without this binding. diff --git a/sdk/ios/Sources/MosaicSDK/CustomerAuthentication.swift b/sdk/ios/Sources/MosaicSDK/CustomerAuthentication.swift new file mode 100644 index 00000000..d50406ff --- /dev/null +++ b/sdk/ios/Sources/MosaicSDK/CustomerAuthentication.swift @@ -0,0 +1,74 @@ +import Foundation + +/// A Customer Access Token. +/// +/// The value is deliberately not readable from outside the SDK and the type +/// prints as a redaction, so a token cannot reach a log, a crash report, or +/// telemetry through an interpolation someone added in a hurry. Nothing in the +/// SDK ever parses it: it is opaque by contract, and inferring anything from its +/// bytes is forbidden. +public struct MosaicCustomerAccessToken: Sendable, Equatable, CustomStringConvertible, + CustomDebugStringConvertible +{ + let value: String + + public init(_ value: String) { + self.value = value + } + + public var description: String { "MosaicCustomerAccessToken(redacted)" } + public var debugDescription: String { description } +} + +/// What a host's token provider can answer. +/// +/// `signedOut` and `unavailable` are separate members on purpose. Signed out is +/// a fact about the person; unavailable is a fact about the host's backend. Both +/// yield `unavailable` access rather than `inactive` — a backend that cannot +/// mint a token has not revoked anyone's subscription — but only `signedOut` +/// carries logout semantics and clears the cache. +public enum MosaicCustomerTokenResult: Sendable, Equatable { + case token(MosaicCustomerAccessToken) + case signedOut + case unavailable +} + +/// Supplies Customer Access Tokens minted by the host application's own backend. +/// +/// Mosaic Billing requires an application backend (OD-4): a public SDK key +/// identifies an application and can never select a Billing Customer, and an +/// application user ID is guessable. The host authenticates its user, asks +/// Mosaic for a token with its `secret_server` key, and returns it here. +/// +/// `forceRefresh` is passed as `true` only after Mosaic refuses a token, and at +/// most once per token generation. +public protocol MosaicCustomerTokenProvider: Sendable { + func customerAccessToken(forceRefresh: Bool) async -> MosaicCustomerTokenResult +} + +/// A provider that always answers with the same token. Useful for previews, +/// tests, and single-session hosts; a real host refreshes. +public struct MosaicStaticCustomerTokenProvider: MosaicCustomerTokenProvider { + private let result: MosaicCustomerTokenResult + + public init(token: MosaicCustomerAccessToken) { result = .token(token) } + public init(result: MosaicCustomerTokenResult) { self.result = result } + + public func customerAccessToken(forceRefresh _: Bool) async -> MosaicCustomerTokenResult { + result + } +} + +/// Adapts a closure, which is what most hosts want: one call into their own +/// authenticated API. +public struct MosaicClosureCustomerTokenProvider: MosaicCustomerTokenProvider { + private let handler: @Sendable (Bool) async -> MosaicCustomerTokenResult + + public init(_ handler: @escaping @Sendable (Bool) async -> MosaicCustomerTokenResult) { + self.handler = handler + } + + public func customerAccessToken(forceRefresh: Bool) async -> MosaicCustomerTokenResult { + await handler(forceRefresh) + } +} diff --git a/sdk/ios/Sources/MosaicSDK/CustomerEntitlements.swift b/sdk/ios/Sources/MosaicSDK/CustomerEntitlements.swift new file mode 100644 index 00000000..1e69d0be --- /dev/null +++ b/sdk/ios/Sources/MosaicSDK/CustomerEntitlements.swift @@ -0,0 +1,656 @@ +import Foundation + +// Authoritative Entitlement Contract v1 — the Mosaic-derived answer to "what +// access does this Billing Customer have, and why". +// +// Everything in this file is *authoritative*: it is what Mosaic's server-side +// projection says, not what the on-device store provider observed. The +// provider-observed surface (`MosaicEntitlement`, `activeEntitlements()`, +// entitlement targeting) is unchanged and keeps its own vocabulary. +// +// The one rule that governs every type here: a rejection yields `unknown`, +// never `inactive`. `inactive` is a claim about a person and may only ever come +// from a snapshot Mosaic issued and this SDK fully accepted. + +public let mosaicAuthoritativeEntitlementContractVersion = "1" + +// MARK: - Closed contract vocabularies + +public enum MosaicCustomerUncertaintyReason: String, Sendable, Equatable, CaseIterable, Codable { + case none + case providerUnavailable = "provider_unavailable" + case missingFact = "missing_fact" + case identityUnresolved = "identity_unresolved" + case productUnresolved = "product_unresolved" + case conflictingFacts = "conflicting_facts" + case projectionFailed = "projection_failed" + case staleValidation = "stale_validation" + case unsupportedProviderState = "unsupported_provider_state" +} + +public enum MosaicCustomerExpectedResolution: String, Sendable, Equatable, CaseIterable, Codable { + case automaticRetry = "automatic_retry" + case nextProviderNotification = "next_provider_notification" + case nextProjectionRun = "next_projection_run" + case operatorAction = "operator_action" + case customerAction = "customer_action" + case noneExpected = "none_expected" +} + +/// Why a state is not definitive. `reason == .none` means the state is +/// definitive and carries no `since`. +public struct MosaicCustomerUncertainty: Sendable, Equatable, Codable { + public let reason: MosaicCustomerUncertaintyReason + public let since: Date? + public let expectedResolution: MosaicCustomerExpectedResolution? + public let diagnosticCode: String? + + public init( + reason: MosaicCustomerUncertaintyReason, + since: Date? = nil, + expectedResolution: MosaicCustomerExpectedResolution? = nil, + diagnosticCode: String? = nil + ) { + self.reason = reason + self.since = since + self.expectedResolution = expectedResolution + self.diagnosticCode = diagnosticCode + } + + public static let definite = MosaicCustomerUncertainty(reason: .none) +} + +public enum MosaicCustomerExplanationCode: String, Sendable, Equatable, CaseIterable, Codable { + case activeSubscriptionPeriod = "active_subscription_period" + case activeTrialPeriod = "active_trial_period" + case activeGracePeriod = "active_grace_period" + case activeBillingRetryAllowance = "active_billing_retry_allowance" + case permanentOneTimePurchase = "permanent_one_time_purchase" + case familySharedSource = "family_shared_source" + case scheduledPauseNotYetEffective = "scheduled_pause_not_yet_effective" + case subscriptionCancelledAccessUntilPeriodEnd = + "subscription_cancelled_access_until_period_end" + case subscriptionExpired = "subscription_expired" + case subscriptionPaused = "subscription_paused" + case subscriptionRevoked = "subscription_revoked" + case subscriptionRefunded = "subscription_refunded" + case subscriptionSuperseded = "subscription_superseded" + case grantVersionEnded = "grant_version_ended" + case noQualifyingSource = "no_qualifying_source" + case identityUnresolved = "identity_unresolved" + case productUnresolved = "product_unresolved" + case conflictingFacts = "conflicting_facts" + case projectionFailed = "projection_failed" + case providerEvidenceStale = "provider_evidence_stale" + case providerUnavailable = "provider_unavailable" + case billingDisabled = "billing_disabled" + case unsupportedProviderState = "unsupported_provider_state" +} + +/// The one reason a reader should show first. `safeSummary` is operator-facing +/// convenience text; it is never parsed. +public struct MosaicCustomerExplanation: Sendable, Equatable, Codable { + public let code: MosaicCustomerExplanationCode + public let sourceID: String? + public let safeSummary: String? + + public init( + code: MosaicCustomerExplanationCode, sourceID: String? = nil, safeSummary: String? = nil + ) { + self.code = code + self.sourceID = sourceID + self.safeSummary = safeSummary + } +} + +/// Entitlement state admissible inside an immutable snapshot. `unavailable` is +/// deliberately absent: it describes Mosaic's ability to answer, never the +/// customer's access, so it can never be persisted as projected state. +public enum MosaicCustomerPersistedEntitlementState: String, Sendable, Equatable, Codable { + case active + case inactive + case unknown +} + +public enum MosaicCustomerStorePlatform: String, Sendable, Equatable, Codable { + case appleAppStore = "apple_app_store" + case googlePlay = "google_play" +} + +public enum MosaicCustomerSourceType: String, Sendable, Equatable, CaseIterable, Codable { + case activeSubscription = "active_subscription" + case trial + case gracePeriod = "grace_period" + case billingRetry = "billing_retry" + case oneTimeNonConsumable = "one_time_non_consumable" + case familyShared = "family_shared" +} + +public enum MosaicCustomerSourceState: String, Sendable, Equatable, Codable { + case granting + case notGranting = "not_granting" + case unknown +} + +/// One reason a Billing Customer holds, or may hold, access. Mosaic Product and +/// Subscription Instance identity live here and nowhere else. +public struct MosaicCustomerEntitlementSource: Sendable, Equatable, Codable { + public let sourceID: String + public let sourceType: MosaicCustomerSourceType + public let subscriptionInstanceID: String? + public let oneTimePurchaseInstanceID: String? + public let mosaicProductID: String + public let grantVersionID: String + public let sourceSnapshotID: String + public let storePlatform: MosaicCustomerStorePlatform? + public let start: Date + /// Absent means this source has no finite end Mosaic can state. + public let end: Date? + public let sourceState: MosaicCustomerSourceState + public let uncertainty: MosaicCustomerUncertainty + public let explanationCode: MosaicCustomerExplanationCode + /// True when this source derives from a provider test transaction. On Google + /// this flag is the only thing separating a license-tester grant from a paid + /// one, so every surface that reports access reports it. + public let isTestSource: Bool +} + +/// The authoritative state of one Entitlement for one Billing Customer. +public struct MosaicCustomerEntitlementEntry: Sendable, Equatable, Codable { + public let entitlementID: String + public let entitlementKey: String + public let state: MosaicCustomerPersistedEntitlementState + public let effectiveStart: Date? + /// Present only when `endKnown` is true. `endKnown == true` with this absent + /// means the Entitlement is permanent. + public let effectiveEnd: Date? + public let endKnown: Bool + public let refreshRecommendedAt: Date? + public let sourceIDs: [String] + public let sourceCount: Int + public let primaryExplanation: MosaicCustomerExplanation + public let uncertainty: MosaicCustomerUncertainty? +} + +public enum MosaicCustomerProjectionState: String, Sendable, Equatable, Codable { + case current + case pending + case stale + case degraded + case failed +} + +public struct MosaicCustomerProjectionStatus: Sendable, Equatable, Codable { + public let state: MosaicCustomerProjectionState + public let lastProjectedAt: Date + public let pendingFactCount: Int? + public let diagnosticCode: String? +} + +public enum MosaicCustomerChangeReason: String, Sendable, Equatable, CaseIterable, Codable { + case initialProjection = "initial_projection" + case subscriptionStateChanged = "subscription_state_changed" + case subscriptionPeriodChanged = "subscription_period_changed" + case renewalIntentChanged = "renewal_intent_changed" + case sourceAdded = "source_added" + case sourceEnded = "source_ended" + case refundApplied = "refund_applied" + case revocationApplied = "revocation_applied" + case grantVersionChanged = "grant_version_changed" + case identityChanged = "identity_changed" + case identityConflictOpened = "identity_conflict_opened" + case identityConflictResolved = "identity_conflict_resolved" + case projectionReplayed = "projection_replayed" + case projectionRuleUpgraded = "projection_rule_upgraded" + case projectionRecovered = "projection_recovered" + case projectionFailed = "projection_failed" + case manualReprojection = "manual_reprojection" +} + +/// A safe diagnostic carried by a contract record. Distinct from +/// ``MosaicDiagnostic``, which is the SDK's own local diagnostic shape. +public struct MosaicCustomerRecordDiagnostic: Sendable, Equatable, Codable { + public let code: String + public let safeMessage: String + public let severity: String + public let retryable: Bool + public let retryAfterSeconds: Int? + public let correlationID: String + public let recoveryAction: String? +} + +/// Immutable authoritative state for one Billing Customer in one Environment at +/// one snapshot version. +/// +/// It is a read model, never a bearer credential: possessing it authorizes +/// nothing, and an application backend must never accept one presented by a +/// client as proof of access. +public struct MosaicCustomerEntitlementSnapshot: Sendable, Equatable, Codable { + public let snapshotID: String + public let billingCustomerID: String + public let projectID: String + public let environmentID: String + public let snapshotVersion: Int64 + public let previousSnapshotVersion: Int64? + public let projectionRuleVersion: Int + public let issuedAt: Date + public let asOf: Date + public let refreshAfter: Date + public let validUntil: Date + /// Absent on the wire means zero. Zero is the strict policy expressed through + /// the same fields rather than as a separate mode. + public let staleGraceSeconds: Int + public let entityTag: String + public let contentDigest: String + public let entries: [MosaicCustomerEntitlementEntry] + public let sources: [MosaicCustomerEntitlementSource] + public let projectionStatus: MosaicCustomerProjectionStatus + public let changeReason: MosaicCustomerChangeReason + public let correlationID: String + public let diagnostics: [MosaicCustomerRecordDiagnostic] + + public func entry(forKey key: String) -> MosaicCustomerEntitlementEntry? { + entries.first { $0.entitlementKey == key } + } + + public func source(id: String) -> MosaicCustomerEntitlementSource? { + sources.first { $0.sourceID == id } + } +} + +/// The answer to a conditional sync whose cached snapshot is still current. It +/// carries no entries: it confirms the cached snapshot and slides its freshness +/// window. +public struct MosaicCustomerSnapshotConfirmation: Sendable, Equatable, Codable { + public let billingCustomerID: String + public let projectID: String + public let environmentID: String + public let snapshotVersion: Int64 + public let entityTag: String + public let issuedAt: Date + public let asOf: Date + public let refreshAfter: Date + public let validUntil: Date + public let staleGraceSeconds: Int + public let projectionStatus: MosaicCustomerProjectionStatus + public let correlationID: String + public let diagnostics: [MosaicCustomerRecordDiagnostic] +} + +// MARK: - SDK-facing state + +/// Why Mosaic could not answer. This is never customer state: it says the +/// authoritative service could not answer, so it is reported instead of +/// `inactive`, never as `inactive`. +public enum MosaicCustomerUnavailableReason: String, Sendable, Equatable, CaseIterable { + /// The host never supplied a customer token provider. + case notConfigured + /// The host's token provider reported a signed-out user. + case signedOut + /// The host's token provider failed or is in its failure cooldown. + case tokenProviderFailed + /// Mosaic refused the token twice in one generation, or the token is expired + /// or revoked. + case notAuthorized + /// The sync surface could not be reached and no usable cache exists. + case serviceUnavailable + /// Billing is disabled for this Environment. + case billingDisabled + /// The cached snapshot is past its bounded-grace window, so the cache's age + /// can no longer support an answer. + case cacheExpired + /// No snapshot has ever been accepted for this customer. + case noSnapshot +} + +/// The state of one Entitlement as far as the SDK can honestly report it. +public enum MosaicCustomerEntitlementState: Sendable, Equatable { + case active + case inactive + case unknown(reason: MosaicCustomerUncertainty) + case unavailable(reason: MosaicCustomerUnavailableReason) + + public var isActive: Bool { self == .active } +} + +/// The freshness of the locally held snapshot. +/// +/// Clock unreliability is deliberately not a member: a cache whose age cannot +/// be measured cannot be trusted to be young, so it takes the `expired` path. +public enum MosaicCustomerEntitlementCacheState: Sendable, Equatable { + /// Before `refreshAfter`. Serve; do not refresh. + case fresh + /// At or after `refreshAfter`, before `validUntil`. Fully valid. + case refreshRecommended + /// Past `validUntil` and inside the bounded-grace window. Previously active + /// Entitlements stay active and **must be surfaced as stale**. + case staleWithinGrace(until: Date) + /// Past the grace window. Report `unknown`, never `inactive`. + case expired + case missing + /// The cached bytes could not be read or no longer satisfy the contract. + case invalid + /// The cache belongs to another Billing Customer, Project, or Environment. + case differentCustomer + + public var servesAccess: Bool { + switch self { + case .fresh, .refreshRecommended, .staleWithinGrace: true + case .expired, .missing, .invalid, .differentCustomer: false + } + } + + public var isStale: Bool { + if case .staleWithinGrace = self { return true } + return false + } +} + +/// The answer to one focused access question. Never a bare boolean. +public struct MosaicCustomerEntitlementCheck: Sendable, Equatable { + public let entitlementKey: String + public let state: MosaicCustomerEntitlementState + public let explanation: MosaicCustomerExplanation? + public let sourceCount: Int + public let endKnown: Bool + public let effectiveStart: Date? + public let effectiveEnd: Date? + /// True when every contributing source is a provider test transaction. + public let isTestSource: Bool + public let snapshotVersion: Int64? + public let asOf: Date? + public let cacheState: MosaicCustomerEntitlementCacheState + + public var isStale: Bool { cacheState.isStale } + + init( + entitlementKey: String, + state: MosaicCustomerEntitlementState, + explanation: MosaicCustomerExplanation? = nil, + sourceCount: Int = 0, + endKnown: Bool = false, + effectiveStart: Date? = nil, + effectiveEnd: Date? = nil, + isTestSource: Bool = false, + snapshotVersion: Int64? = nil, + asOf: Date? = nil, + cacheState: MosaicCustomerEntitlementCacheState + ) { + self.entitlementKey = entitlementKey + self.state = state + self.explanation = explanation + self.sourceCount = sourceCount + self.endKnown = endKnown + self.effectiveStart = effectiveStart + self.effectiveEnd = effectiveEnd + self.isTestSource = isTestSource + self.snapshotVersion = snapshotVersion + self.asOf = asOf + self.cacheState = cacheState + } +} + +/// The snapshot plus how fresh it is, as handed to observers. +public struct MosaicCustomerEntitlementSnapshotUpdate: Sendable, Equatable { + public let snapshot: MosaicCustomerEntitlementSnapshot + public let cacheState: MosaicCustomerEntitlementCacheState +} + +public enum MosaicCustomerEntitlementClearReason: String, Sendable, Equatable { + case identityChanged + case differentCustomer + case hostRequested +} + +/// What an observer of ``Mosaic/customerEntitlementUpdates()`` sees. +/// +/// Identity transitions are observable as their own members so a consumer never +/// has to infer "the previous user's grants no longer apply" from the absence +/// of an emission. +public enum MosaicCustomerEntitlementUpdate: Sendable, Equatable { + case loading + case signedOut + case cleared(MosaicCustomerEntitlementClearReason) + case snapshot(MosaicCustomerEntitlementSnapshotUpdate) + case unavailable(MosaicCustomerUnavailableReason) +} + +public struct MosaicCustomerEntitlementDiagnostics: Sendable, Equatable { + public let isConfigured: Bool + public let hasCustomerToken: Bool + public let tokenGeneration: UInt64 + public let cacheState: MosaicCustomerEntitlementCacheState + public let snapshotVersion: Int64? + public let billingCustomerID: String? + public let projectionState: MosaicCustomerProjectionState? + public let entryCount: Int + public let acceptedSnapshotCount: UInt64 + public let rejectedSnapshotCount: UInt64 + public let lastRejectionReason: String? + public let lastSafeCode: String? + public let isRefreshInFlight: Bool + + static let notConfigured = MosaicCustomerEntitlementDiagnostics( + isConfigured: false, hasCustomerToken: false, tokenGeneration: 0, cacheState: .missing, + snapshotVersion: nil, billingCustomerID: nil, projectionState: nil, entryCount: 0, + acceptedSnapshotCount: 0, rejectedSnapshotCount: 0, lastRejectionReason: nil, + lastSafeCode: "entitlement_not_configured", isRefreshInFlight: false) +} + +// MARK: - Cross-platform policy constants + +/// The constants five implementations must agree on. They are pinned in +/// `protocol/compatibility/authoritative-entitlement/v1.json` and asserted +/// against the shared reference vectors. +public enum MosaicCustomerEntitlementPolicy: Sendable { + public static let clockSkewToleranceSeconds: TimeInterval = 60 + public static let defaultStaleGraceSeconds = 86_400 + public static let maxValidUntilSeconds = 2_592_000 + public static let maxStaleGraceSeconds = 2_592_000 + /// `(validUntil - issuedAt) + staleGraceSeconds` may never exceed this. + /// Bounding each field alone would let a 30-day validity and a 30-day grace + /// window compose into 60 days of unconfirmed offline access. + public static let maxCacheHorizonSeconds = 2_592_000 + public static let restorePollAttempts = 3 + public static let restorePollBudgetSeconds: TimeInterval = 6 + public static let maxRecordBytes = 65_536 +} + +// MARK: - Freshness + +/// The bounded-grace freshness evaluation (OD-5). +/// +/// Conformance is asserted against +/// `packages/test-fixtures/src/entitlement-freshness-vectors.json`, which every +/// Mosaic implementation shares. +enum MosaicCustomerEntitlementFreshness { + static func evaluate( + issuedAt: Date, + refreshAfter: Date, + validUntil: Date, + staleGraceSeconds: Int, + deviceNow: Date, + tolerance: TimeInterval = MosaicCustomerEntitlementPolicy.clockSkewToleranceSeconds + ) -> MosaicCustomerEntitlementCacheState { + // A device clock earlier than issuance by more than the tolerance makes the + // cache's age unmeasurable. Computing a negative age and concluding "fresh" + // would hand unlimited offline access to anyone willing to change their + // device time. + if deviceNow < issuedAt.addingTimeInterval(-tolerance) { return .expired } + // Boundaries are crossed only once `deviceNow` exceeds them by more than the + // tolerance, so a phone a few seconds fast does not flap between states. + if deviceNow <= refreshAfter.addingTimeInterval(tolerance) { return .fresh } + if deviceNow <= validUntil.addingTimeInterval(tolerance) { return .refreshRecommended } + guard staleGraceSeconds > 0 else { return .expired } + let graceEnd = validUntil.addingTimeInterval(TimeInterval(staleGraceSeconds)) + if deviceNow <= graceEnd.addingTimeInterval(tolerance) { + return .staleWithinGrace(until: graceEnd) + } + return .expired + } +} + +// MARK: - Cache acceptance + +public enum MosaicCustomerCacheAction: String, Sendable, Equatable { + case replace + case preserve + case clear +} + +enum MosaicCustomerSnapshotAcceptanceReason: String, Sendable, Equatable { + case newerSnapshotVersion = "newer_snapshot_version" + case noCachedSnapshot = "no_cached_snapshot" +} + +enum MosaicCustomerSnapshotRejectionReason: String, Sendable, Equatable { + case unsupportedContractVersion = "unsupported_contract_version" + case customerMismatch = "customer_mismatch" + case projectMismatch = "project_mismatch" + case environmentMismatch = "environment_mismatch" + case contentDigestMismatch = "content_digest_mismatch" + case snapshotVersionNotNewer = "snapshot_version_not_newer" + case asOfRegression = "as_of_regression" + + /// A binding mismatch is the one rejection that clears rather than preserves: + /// continuing to serve the previous customer's access after an identity + /// change is precisely the leak the rule exists to prevent. + var cacheAction: MosaicCustomerCacheAction { + switch self { + case .customerMismatch, .projectMismatch, .environmentMismatch: .clear + default: .preserve + } + } + + var isBindingMismatch: Bool { cacheAction == .clear } + + var diagnosticCode: String { "entitlement_snapshot_\(rawValue)" } +} + +enum MosaicCustomerSnapshotAcceptance: Sendable, Equatable { + case accepted(reason: MosaicCustomerSnapshotAcceptanceReason) + case rejected(reason: MosaicCustomerSnapshotRejectionReason) + + var cacheAction: MosaicCustomerCacheAction { + switch self { + case .accepted: .replace + case .rejected(let reason): reason.cacheAction + } + } +} + +/// The binding and ordering members the acceptance gate reads. Kept separate +/// from the decoded snapshot so a cached record can be compared without +/// re-decoding it. +struct MosaicCustomerSnapshotBinding: Sendable, Equatable { + let contractVersion: String + let billingCustomerID: String + let projectID: String + let environmentID: String + let snapshotVersion: Int64 + let asOf: Date + let contentDigestValid: Bool +} + +/// The normative cache-acceptance order. +/// +/// Conformance is asserted against +/// `packages/test-fixtures/src/entitlement-cache-decision-vectors.json`. +enum MosaicCustomerEntitlementCacheDecision { + static func evaluate( + cached: MosaicCustomerSnapshotBinding?, + incoming: MosaicCustomerSnapshotBinding + ) -> MosaicCustomerSnapshotAcceptance { + // 1. A document in an unknown version cannot be trusted to have + // interpretable binding fields, so version is checked first. + guard incoming.contractVersion == mosaicAuthoritativeEntitlementContractVersion else { + return .rejected(reason: .unsupportedContractVersion) + } + // 2. Binding runs before version because snapshot versions are monotonic + // per customer PER ENVIRONMENT: a staging snapshot legitimately starts + // at 1, and diagnosing that as a version regression would preserve a + // production cache under a staging identity. + if let cached { + if cached.billingCustomerID != incoming.billingCustomerID { + return .rejected(reason: .customerMismatch) + } + if cached.projectID != incoming.projectID { + return .rejected(reason: .projectMismatch) + } + if cached.environmentID != incoming.environmentID { + return .rejected(reason: .environmentMismatch) + } + } + // 3. Corruption in transit or at rest. Discarded whole, never partially + // applied. + guard incoming.contentDigestValid else { + return .rejected(reason: .contentDigestMismatch) + } + guard let cached else { return .accepted(reason: .noCachedSnapshot) } + // 4. Equal is not newer. A 304 is the correct way to confirm a current + // snapshot; it slides freshness without re-accepting anything. + guard incoming.snapshotVersion > cached.snapshotVersion else { + return .rejected(reason: .snapshotVersionNotNewer) + } + // 5. A higher version evaluated at an earlier instant means the server + // projected from a stale read. + guard incoming.asOf >= cached.asOf else { + return .rejected(reason: .asOfRegression) + } + return .accepted(reason: .newerSnapshotVersion) + } +} + +// MARK: - Reading a snapshot + +extension MosaicCustomerEntitlementSnapshot { + /// Answers one key from this snapshot under the supplied cache freshness. + /// + /// A cache that no longer serves access yields `unknown` or `unavailable` and + /// never `inactive`: an unheard-from Mosaic is not a cancelled subscription. + func check( + key: String, cacheState: MosaicCustomerEntitlementCacheState + ) -> MosaicCustomerEntitlementCheck { + guard cacheState.servesAccess else { + return MosaicCustomerEntitlementCheck( + entitlementKey: key, + state: .unavailable(reason: .cacheExpired), + snapshotVersion: snapshotVersion, + asOf: asOf, + cacheState: cacheState) + } + guard let entry = entry(forKey: key) else { + // Absence is not a statement Mosaic made. A snapshot can omit a key + // because the Project does not define it, because the projection could + // not resolve it, or because the request narrowed the response with + // `requestedEntitlementKeys` — and none of those is Mosaic saying the + // customer does not have it. `inactive` requires an entry that says so. + return MosaicCustomerEntitlementCheck( + entitlementKey: key, + state: .unknown(reason: MosaicCustomerUncertainty(reason: .missingFact, since: asOf)), + snapshotVersion: snapshotVersion, + asOf: asOf, + cacheState: cacheState) + } + let contributing = entry.sourceIDs.compactMap { source(id: $0) } + let state: MosaicCustomerEntitlementState + switch entry.state { + case .active: state = .active + case .inactive: state = .inactive + case .unknown: + state = .unknown(reason: entry.uncertainty ?? MosaicCustomerUncertainty(reason: .missingFact)) + } + return MosaicCustomerEntitlementCheck( + entitlementKey: key, + state: state, + explanation: entry.primaryExplanation, + sourceCount: entry.sourceCount, + endKnown: entry.endKnown, + effectiveStart: entry.effectiveStart, + effectiveEnd: entry.effectiveEnd, + isTestSource: !contributing.isEmpty && contributing.allSatisfy(\.isTestSource), + snapshotVersion: snapshotVersion, + asOf: asOf, + cacheState: cacheState) + } +} diff --git a/sdk/ios/Sources/MosaicSDK/CustomerTokenStore.swift b/sdk/ios/Sources/MosaicSDK/CustomerTokenStore.swift new file mode 100644 index 00000000..98fc9138 --- /dev/null +++ b/sdk/ios/Sources/MosaicSDK/CustomerTokenStore.swift @@ -0,0 +1,178 @@ +import Foundation + +/// A token plus the generation it belongs to. +/// +/// The generation is what makes "exactly one retry per 401" enforceable: a 401 +/// carries back the lease it was issued under, so a response that raced an +/// identity change cannot consume the new identity's single retry. +struct MosaicCustomerTokenLease: Sendable, Equatable { + let token: MosaicCustomerAccessToken + let generation: UInt64 +} + +/// Reads whatever Customer Access Token is currently held, without causing one +/// to be fetched. +/// +/// Deliberately narrow. A caller on a delivery path must be able to attach a +/// token when one happens to exist without ever making the absence of one into a +/// network call, a suspension, or a failure. +protocol MosaicCustomerTokenSource: Sendable { + func heldCustomerToken() async -> MosaicCustomerAccessToken? +} + +enum MosaicCustomerTokenOutcome: Sendable, Equatable { + case lease(MosaicCustomerTokenLease) + case signedOut + case unavailable(MosaicCustomerUnavailableReason) +} + +/// Holds the Customer Access Token **in memory only**. +/// +/// Nothing here writes to disk, the keychain, or user defaults, and the token is +/// never logged. A token is short-lived by contract and the host backend can +/// always mint another, so persisting one would add a durable secret to the +/// device in exchange for nothing. +actor MosaicCustomerTokenStore: MosaicCustomerTokenSource { + private let provider: (any MosaicCustomerTokenProvider)? + private let clock: @Sendable () -> Date + /// A provider that just failed is not asked again immediately: a backend + /// outage must not become a request storm from every device at once. + private let failureCooldown: TimeInterval + + private var cached: MosaicCustomerAccessToken? + private var generation: UInt64 = 0 + /// The generation that was minted *by* a forced refresh. A 401 on a freshly + /// minted token is a real failure, not something to retry. + private var forcedRefreshGeneration: UInt64? + private var cooldownUntil: Date? + private var signedOut = false + private var inFlight: (id: UInt64, task: Task)? + private var fetchSequence: UInt64 = 0 + + init( + provider: (any MosaicCustomerTokenProvider)?, + failureCooldown: TimeInterval = 30, + clock: @escaping @Sendable () -> Date = Date.init + ) { + self.provider = provider + self.failureCooldown = failureCooldown + self.clock = clock + } + + var isConfigured: Bool { provider != nil } + var hasToken: Bool { cached != nil } + + /// The token already held, or `nil`. Never fetches and never refreshes: a + /// caller using this must treat absence as "carry on without it". + func heldCustomerToken() -> MosaicCustomerAccessToken? { + signedOut ? nil : cached + } + var currentGeneration: UInt64 { generation } + + /// The token to attach to a sync request, fetching one if none is held. + func token() async -> MosaicCustomerTokenOutcome { + guard provider != nil else { return .unavailable(.notConfigured) } + if signedOut { return .signedOut } + if let cached { return .lease(.init(token: cached, generation: generation)) } + if let cooldownUntil, clock() < cooldownUntil { + return .unavailable(.tokenProviderFailed) + } + return await fetch(forceRefresh: false) + } + + /// Handles a refusal from Mosaic for the token issued under `lease`. + /// + /// Exactly one forced refresh per generation. A second 401 on a freshly minted + /// token means the token is not the problem, and retrying forever turns an + /// outage into a request storm. + func recoverFromUnauthorized(_ lease: MosaicCustomerTokenLease) async + -> MosaicCustomerTokenOutcome + { + guard provider != nil else { return .unavailable(.notConfigured) } + if signedOut { return .signedOut } + // The refusal belongs to an identity the SDK has already moved past. It + // says nothing about the token now held. + guard lease.generation == generation else { return await token() } + guard forcedRefreshGeneration != lease.generation else { + return .unavailable(.notAuthorized) + } + // A forced refresh mints a new generation, so anything still holding the + // refused lease is recognisably stale and cannot spend this generation's + // retry a second time. + cached = nil + generation &+= 1 + inFlight?.task.cancel() + inFlight = nil + let outcome = await fetch(forceRefresh: true) + if case .lease(let refreshed) = outcome { + forcedRefreshGeneration = refreshed.generation + } + return outcome + } + + /// Discards the token and moves to a new generation, cancelling any in-flight + /// fetch. Callers use this on identity change and on host-requested clears. + func invalidate() { + cached = nil + cooldownUntil = nil + signedOut = false + forcedRefreshGeneration = nil + generation &+= 1 + inFlight?.task.cancel() + inFlight = nil + } + + /// Logout semantics: the token is discarded and every later read reports + /// signed out until the host identifies someone. + func signOut() { + invalidate() + signedOut = true + } + + private func fetch(forceRefresh: Bool) async -> MosaicCustomerTokenOutcome { + // One in-flight fetch at a time. Ten screens asking at once must produce one + // call into the host's backend, not ten. + if let inFlight { return await inFlight.task.value } + guard let provider else { return .unavailable(.notConfigured) } + fetchSequence &+= 1 + let id = fetchSequence + let requestedGeneration = generation + let task = Task { [provider] in + await provider.customerAccessToken(forceRefresh: forceRefresh) + } + let holder = Task { [weak self] in + let result = await task.value + guard let self else { return .unavailable(.tokenProviderFailed) } + return await self.apply(result, requestedGeneration: requestedGeneration) + } + inFlight = (id, holder) + let outcome = await holder.value + if inFlight?.id == id { inFlight = nil } + return outcome + } + + private func apply( + _ result: MosaicCustomerTokenResult, requestedGeneration: UInt64 + ) -> MosaicCustomerTokenOutcome { + // An answer for an identity the SDK has moved past is discarded rather than + // stored: caching it would attach the previous user's token to the new one. + guard requestedGeneration == generation, !signedOut else { + if signedOut { return .signedOut } + return .unavailable(.tokenProviderFailed) + } + switch result { + case .token(let token): + cached = token + cooldownUntil = nil + return .lease(.init(token: token, generation: generation)) + case .signedOut: + cached = nil + signedOut = true + return .signedOut + case .unavailable: + cached = nil + cooldownUntil = clock().addingTimeInterval(failureCooldown) + return .unavailable(.tokenProviderFailed) + } + } +} diff --git a/sdk/ios/Sources/MosaicSDK/EntitlementCacheStore.swift b/sdk/ios/Sources/MosaicSDK/EntitlementCacheStore.swift new file mode 100644 index 00000000..f210e0c0 --- /dev/null +++ b/sdk/ios/Sources/MosaicSDK/EntitlementCacheStore.swift @@ -0,0 +1,199 @@ +import CryptoKit +import Foundation + +/// One accepted snapshot at rest. +/// +/// The raw record bytes are kept alongside the binding members so the cache can +/// be compared and pruned without re-decoding, and re-decoded verbatim on load +/// so the cached snapshot is the exact document Mosaic issued rather than a +/// re-encoding of this SDK's understanding of it. +struct MosaicCustomerEntitlementCacheRecord: Codable, Sendable, Equatable { + var formatVersion = 1 + var recordData: Data + var billingCustomerID: String + var projectID: String + var environmentID: String + var snapshotVersion: Int64 + var issuedAt: Date + var asOf: Date + var refreshAfter: Date + var validUntil: Date + var staleGraceSeconds: Int + var entityTag: String + var storedAt: Date + var serverTime: Date? + var localReceiptTime: Date? + var systemUptime: TimeInterval? + /// Corruption detection, **not** authentication. It catches a truncated + /// write, a half-flushed page, or a file edited on a jailbroken device; it + /// proves nothing about origin, because anyone can recompute it. The + /// snapshot's own `contentDigest` is what binds the document to a customer. + var checksum: String + + static func checksum( + recordData: Data, billingCustomerID: String, projectID: String, environmentID: String, + snapshotVersion: Int64 + ) -> String { + var material = Data() + material.append(recordData) + material.append( + Data( + "\n\(billingCustomerID)\n\(projectID)\n\(environmentID)\n\(snapshotVersion)".utf8)) + return SHA256.hash(data: material).map { String(format: "%02x", $0) }.joined() + } + + var isIntact: Bool { + checksum + == Self.checksum( + recordData: recordData, billingCustomerID: billingCustomerID, projectID: projectID, + environmentID: environmentID, snapshotVersion: snapshotVersion) + } + + var binding: MosaicCustomerSnapshotBinding { + MosaicCustomerSnapshotBinding( + contractVersion: mosaicAuthoritativeEntitlementContractVersion, + billingCustomerID: billingCustomerID, projectID: projectID, environmentID: environmentID, + snapshotVersion: snapshotVersion, asOf: asOf, contentDigestValid: true) + } +} + +protocol MosaicCustomerEntitlementCacheStore: Sendable { + func load() async throws -> MosaicCustomerEntitlementCacheRecord? + func save(_ record: MosaicCustomerEntitlementCacheRecord) async throws + func clear() async throws +} + +/// Degraded persistence, and the store the tests use. The SDK stays correct for +/// the process lifetime; nothing survives relaunch. +actor MosaicCustomerEntitlementMemoryCacheStore: MosaicCustomerEntitlementCacheStore { + private var record: MosaicCustomerEntitlementCacheRecord? + private(set) var saveCount = 0 + private(set) var clearCount = 0 + + init(record: MosaicCustomerEntitlementCacheRecord? = nil) { self.record = record } + + func load() -> MosaicCustomerEntitlementCacheRecord? { record } + + func save(_ record: MosaicCustomerEntitlementCacheRecord) { + self.record = record + saveCount += 1 + } + + func clear() { + record = nil + clearCount += 1 + } +} + +/// The on-disk entitlement cache. +/// +/// Three properties matter and each is deliberate: +/// +/// - The **customer binding digest is part of the file name**, so two customers +/// on one device can never share a file and a stale read cannot return the +/// previous person's grants. +/// - The directory and the file are **excluded from backup**. An entitlement +/// cache restored onto a second device from an iCloud backup would carry one +/// person's access into another device's session, and it is derived state +/// that Mosaic can always reissue. +/// - The file is protected `completeUntilFirstUserAuthentication`, which is the +/// strongest class compatible with a launch-time read on a locked device. +actor MosaicCustomerEntitlementFileCacheStore: MosaicCustomerEntitlementCacheStore { + /// Two customers' files are kept: the current one and the previous one, so + /// the common "switch back to my other account" flow does not re-sync from + /// nothing. Anything older is a device that has hosted several people and has + /// no business holding their billing state. + static let retainedCustomerFiles = 2 + + private let directory: URL + private let fileURL: URL + private let fileManager: FileManager + + init( + baseURL: URL, + publicSDKKey: String, + customerBindingDigest: String, + rootDirectory: URL? = nil, + fileManager: FileManager = .default + ) throws { + guard + let root = rootDirectory + ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + else { throw CocoaError(.fileNoSuchFile) } + directory = + root + .appendingPathComponent("MosaicSDK", isDirectory: true) + .appendingPathComponent("entitlements-v1", isDirectory: true) + let normalizedURL = baseURL.absoluteString.trimmingCharacters( + in: CharacterSet(charactersIn: "/")) + let material = Data("\(normalizedURL)\n\(publicSDKKey)\n\(customerBindingDigest)".utf8) + let name = SHA256.hash(data: material).map { String(format: "%02x", $0) }.joined() + fileURL = directory.appendingPathComponent(name + ".json", isDirectory: false) + self.fileManager = fileManager + } + + /// Digests the host's identity for a customer into a cache namespace. The raw + /// user identifier never reaches the file system. + static func bindingDigest(userID: String?) -> String { + let material = Data("mosaic.entitlements.v1\n\(userID ?? "")".utf8) + return SHA256.hash(data: material).map { String(format: "%02x", $0) }.joined() + } + + func load() throws -> MosaicCustomerEntitlementCacheRecord? { + guard fileManager.fileExists(atPath: fileURL.path) else { return nil } + let record = try JSONDecoder().decode( + MosaicCustomerEntitlementCacheRecord.self, + from: Data(contentsOf: fileURL, options: .mappedIfSafe)) + guard record.formatVersion == 1 else { throw CocoaError(.fileReadCorruptFile) } + // A corrupt cache is not a customer state. It is discarded and reported as + // `invalid`, which resolves to `unknown`, never `inactive`. + guard record.isIntact else { throw CocoaError(.fileReadCorruptFile) } + return record + } + + func save(_ record: MosaicCustomerEntitlementCacheRecord) throws { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + var resource = URLResourceValues() + resource.isExcludedFromBackup = true + var mutableDirectory = directory + try? mutableDirectory.setResourceValues(resource) + // `.atomic` writes to a temporary file and renames, so an interrupted write + // leaves the previous accepted snapshot intact rather than a half-file. + try JSONEncoder().encode(record).write(to: fileURL, options: .atomic) + var mutableFile = fileURL + try? mutableFile.setResourceValues(resource) + #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + try? fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path) + #endif + pruneOtherCustomers() + } + + func clear() throws { + guard fileManager.fileExists(atPath: fileURL.path) else { return } + try fileManager.removeItem(at: fileURL) + } + + /// Keeps the most recently written customer files and removes the rest, so a + /// shared or resold device does not accumulate every person who ever signed + /// in to the app. + private func pruneOtherCustomers() { + guard + let names = try? fileManager.contentsOfDirectory( + at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) + else { return } + let files = names.filter { $0.pathExtension == "json" } + guard files.count > Self.retainedCustomerFiles else { return } + let dated = files.map { url -> (URL, Date) in + let modified = + (try? url.resourceValues(forKeys: [.contentModificationDateKey]))? + .contentModificationDate ?? .distantPast + return (url, modified) + } + .sorted { $0.1 > $1.1 } + for (url, _) in dated.dropFirst(Self.retainedCustomerFiles) { + try? fileManager.removeItem(at: url) + } + } +} diff --git a/sdk/ios/Sources/MosaicSDK/EntitlementLifecycle.swift b/sdk/ios/Sources/MosaicSDK/EntitlementLifecycle.swift new file mode 100644 index 00000000..ed50e395 --- /dev/null +++ b/sdk/ios/Sources/MosaicSDK/EntitlementLifecycle.swift @@ -0,0 +1,49 @@ +import Foundation + +#if canImport(UIKit) + import UIKit + + /// Refreshes authoritative entitlements when the app comes to the foreground. + /// + /// Deliberately limited. Phase 9B adds **no background execution**: no + /// `BGTaskScheduler`, no silent push, no timers. A refresh happens at + /// `configure` and on foreground, and nothing guarantees one while the app is + /// backgrounded or terminated. That is a documented property, not an + /// oversight — a device that has been offline for days is exactly what the + /// bounded-grace window and the `expired` cache state exist to describe, and + /// scheduling background work to paper over it would spend the host's + /// background budget without making the answer any more authoritative. + @MainActor + enum MosaicCustomerEntitlementLifecycleRegistry { + private static var observers: [String: MosaicCustomerEntitlementLifecycleObserver] = [:] + + static func install(client: MosaicCustomerEntitlementClient, namespace: String) { + guard observers[namespace] == nil else { return } + observers[namespace] = MosaicCustomerEntitlementLifecycleObserver(client: client) + } + } + + @MainActor + private final class MosaicCustomerEntitlementLifecycleObserver { + private let client: MosaicCustomerEntitlementClient + private var tokens: [NSObjectProtocol] = [] + + init(client: MosaicCustomerEntitlementClient) { + self.client = client + tokens.append( + NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main + ) { [client] _ in + // `refreshIfNeeded` and not `refresh`: a foreground while the snapshot + // is still fresh costs nothing and asks nothing of the network. + Task { _ = await client.refreshIfNeeded() } + }) + } + } +#else + enum MosaicCustomerEntitlementLifecycleRegistry { + static func install( + client: MosaicCustomerEntitlementClient, namespace: String + ) async {} + } +#endif diff --git a/sdk/ios/Sources/MosaicSDK/EntitlementSnapshotCodec.swift b/sdk/ios/Sources/MosaicSDK/EntitlementSnapshotCodec.swift new file mode 100644 index 00000000..ab659d74 --- /dev/null +++ b/sdk/ios/Sources/MosaicSDK/EntitlementSnapshotCodec.swift @@ -0,0 +1,843 @@ +import CryptoKit +import Foundation + +/// Errors from reading an Authoritative Entitlement v1 record. +/// +/// These are internal on purpose. They never reach the host as an error type: +/// every one of them resolves to `unknown` plus a stable diagnostic code, so a +/// decoding detail can never become unstable public API. +enum MosaicCustomerEntitlementDecodingError: Error, Sendable, Equatable { + case invalidJSON + case recordTooLarge + case unsupportedContractVersion + case unsupportedRecordType + case invalidShape(path: String, reason: String) + case invalidSemantics(code: String) + + var diagnosticCode: String { + switch self { + case .invalidJSON: "entitlement_invalid_json" + case .recordTooLarge: "entitlement_record_too_large" + case .unsupportedContractVersion: "entitlement_unsupported_contract_version" + case .unsupportedRecordType: "entitlement_unsupported_record_type" + case .invalidShape: "entitlement_invalid_shape" + case .invalidSemantics(let code): "entitlement_\(code)" + } + } +} + +enum MosaicCustomerEntitlementRecord: Sendable, Equatable { + case snapshot(MosaicCustomerEntitlementSnapshot) + case unchanged(MosaicCustomerSnapshotConfirmation) +} + +/// A record that decoded cleanly, plus the two things the acceptance gate needs +/// without re-reading the bytes. +/// +/// The digest result is reported rather than thrown because digest verification +/// is step three of a normative order: a binding mismatch must be diagnosed +/// first, since it clears the cache and a digest failure does not. +struct MosaicCustomerDecodedRecord: Sendable, Equatable { + let record: MosaicCustomerEntitlementRecord + let binding: MosaicCustomerSnapshotBinding + let contentDigestValid: Bool +} + +// MARK: - Canonical serialization + +/// The canonical form `contentDigest` is computed over. +/// +/// Foundation's `JSONSerialization.sortedKeys` is deliberately **not** used: it +/// sorts with locale- and case-insensitive options, which disagrees with the +/// contract's UTF-16 code-unit ordering on any object mixing cases. Five +/// implementations must produce byte-identical input, so the ordering, escaping, +/// and number form are all spelled out here. +enum MosaicCustomerCanonicalJSON { + static func digest(_ value: Any) throws -> String { + let hash = SHA256.hash(data: try data(value)) + return "sha256:" + hash.map { String(format: "%02x", $0) }.joined() + } + + static func data(_ value: Any) throws -> Data { + var output = Data() + try append(value, to: &output) + return output + } + + private static func append(_ value: Any, to output: inout Data) throws { + switch value { + case let object as [String: Any]: + output.append(UInt8(ascii: "{")) + // Ascending by UTF-16 code unit, at every depth. + let keys = object.keys.sorted { Array($0.utf16).lexicographicallyPrecedes(Array($1.utf16)) } + for (index, key) in keys.enumerated() { + if index > 0 { output.append(UInt8(ascii: ",")) } + appendString(key, to: &output) + output.append(UInt8(ascii: ":")) + // An absent optional and a null optional are different bytes and + // therefore different digests, and null is invalid everywhere in this + // contract, so it is refused rather than normalized away. + guard let member = object[key], !(member is NSNull) else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: key, reason: "null_forbidden") + } + try append(member, to: &output) + } + output.append(UInt8(ascii: "}")) + case let array as [Any]: + // Array order is normative: a serializer that sorted one would silently + // repair a document the semantic validator exists to reject. + output.append(UInt8(ascii: "[")) + for (index, element) in array.enumerated() { + if index > 0 { output.append(UInt8(ascii: ",")) } + guard !(element is NSNull) else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "[]", reason: "null_forbidden") + } + try append(element, to: &output) + } + output.append(UInt8(ascii: "]")) + case let string as String: + appendString(string, to: &output) + case let number as NSNumber: + if isBoolean(number) { + output.append(contentsOf: Array((number.boolValue ? "true" : "false").utf8)) + } else { + // Shortest decimal, no exponent, no decimal point. This contract + // contains no non-integer numbers. + let integer = number.int64Value + guard Double(integer) == number.doubleValue else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "number", reason: "non_integer_number") + } + output.append(contentsOf: Array(String(integer).utf8)) + } + default: + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "value", reason: "unsupported_json_value") + } + } + + /// Minimal JSON escaping. Non-ASCII is never escaped into `\u` sequences: it + /// is emitted as UTF-8, which is what the digest is computed over. + private static func appendString(_ value: String, to output: inout Data) { + output.append(UInt8(ascii: "\"")) + for scalar in value.unicodeScalars { + switch scalar { + case "\"": output.append(contentsOf: Array("\\\"".utf8)) + case "\\": output.append(contentsOf: Array("\\\\".utf8)) + case "\u{08}": output.append(contentsOf: Array("\\b".utf8)) + case "\u{0C}": output.append(contentsOf: Array("\\f".utf8)) + case "\n": output.append(contentsOf: Array("\\n".utf8)) + case "\r": output.append(contentsOf: Array("\\r".utf8)) + case "\t": output.append(contentsOf: Array("\\t".utf8)) + default: + if scalar.value < 0x20 { + output.append(contentsOf: Array(String(format: "\\u%04x", scalar.value).utf8)) + } else { + output.append(contentsOf: Array(String(scalar).utf8)) + } + } + } + output.append(UInt8(ascii: "\"")) + } + + private static func isBoolean(_ number: NSNumber) -> Bool { + CFGetTypeID(number) == CFBooleanGetTypeID() + } +} + +// MARK: - Decoder + +/// The closed, pessimistic reader for Authoritative Entitlement v1. +/// +/// Every unknown version, record type, field, or enumeration member rejects the +/// whole record. The single exception is `entitlementKey`: keys are Project +/// data, so rejecting an unrecognized one would make *defining a new +/// Entitlement* a breaking change for every already-shipped SDK. +enum MosaicCustomerEntitlementCodec { + static func decode(_ data: Data) throws -> MosaicCustomerDecodedRecord { + guard data.count <= MosaicCustomerEntitlementPolicy.maxRecordBytes else { + throw MosaicCustomerEntitlementDecodingError.recordTooLarge + } + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw MosaicCustomerEntitlementDecodingError.invalidJSON + } + try Value.exactKeys( + root, + expected: ["authoritativeEntitlementContractVersion", "recordType", "payload"], + path: "$") + // Exact-match reading. A "2" document is as unreadable to a "1" reader as a + // "9.9" document; numeric ordering never implies support. + guard + root["authoritativeEntitlementContractVersion"] as? String + == mosaicAuthoritativeEntitlementContractVersion + else { throw MosaicCustomerEntitlementDecodingError.unsupportedContractVersion } + + switch root["recordType"] as? String { + case "customerEntitlementSnapshot": + return try decodeSnapshot(Value.object(root["payload"], path: "payload")) + case "snapshotUnchanged": + return try decodeUnchanged(Value.object(root["payload"], path: "payload")) + default: + throw MosaicCustomerEntitlementDecodingError.unsupportedRecordType + } + } + + // MARK: Snapshot + + private static func decodeSnapshot(_ payload: [String: Any]) throws -> MosaicCustomerDecodedRecord + { + try Value.keys( + payload, + required: [ + "snapshotId", "billingCustomerId", "projectId", "environmentId", "snapshotVersion", + "projectionRuleVersion", "issuedAt", "asOf", "refreshAfter", "validUntil", "entityTag", + "contentDigest", "entries", "sources", "projectionStatus", "changeReason", "correlationId", + ], + optional: ["previousSnapshotVersion", "staleGraceSeconds", "diagnostics"], + path: "payload") + + let issuedAt = try Value.timestamp(payload["issuedAt"], path: "payload.issuedAt") + let asOf = try Value.timestamp(payload["asOf"], path: "payload.asOf") + let refreshAfter = try Value.timestamp(payload["refreshAfter"], path: "payload.refreshAfter") + let validUntil = try Value.timestamp(payload["validUntil"], path: "payload.validUntil") + let staleGraceSeconds = + try Value.optionalInt( + payload["staleGraceSeconds"], + range: 0...MosaicCustomerEntitlementPolicy.maxStaleGraceSeconds, + path: "payload.staleGraceSeconds") ?? 0 + + guard refreshAfter <= validUntil else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "refresh_after_later_than_valid_until") + } + // The 30-day maximum is on the *combined* horizon. Bounding each field + // alone would let a 30-day validity and a 30-day grace window compose into + // 60 days during which a device serves access Mosaic never confirmed. + let horizon = validUntil.timeIntervalSince(issuedAt) + TimeInterval(staleGraceSeconds) + guard horizon <= TimeInterval(MosaicCustomerEntitlementPolicy.maxCacheHorizonSeconds) else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "cache_horizon_exceeds_maximum") + } + + let entries = try Value.array(payload["entries"], count: 0...200, path: "payload.entries") + .enumerated().map { try decodeEntry(Value.object($1, path: "payload.entries[\($0)]")) } + let sources = try Value.array(payload["sources"], count: 0...200, path: "payload.sources") + .enumerated().map { try decodeSource(Value.object($1, path: "payload.sources[\($0)]")) } + + try validateGraph(entries: entries, sources: sources) + + let snapshotVersion = try Value.snapshotVersionOrPlaceholder( + payload["snapshotVersion"], path: "payload.snapshotVersion") + let previousSnapshotVersion = try Value.optionalInt64( + payload["previousSnapshotVersion"], range: 0...999_999_999_999, + path: "payload.previousSnapshotVersion") + let projectionStatus = try decodeProjectionStatus( + Value.object(payload["projectionStatus"], path: "payload.projectionStatus")) + let changeReason = try Value.member( + MosaicCustomerChangeReason.self, payload["changeReason"], path: "payload.changeReason") + + // Version zero is not projected state. It is the never-projected + // placeholder and is admissible only in the exact pending/empty shape the + // contract defines. Keeping this check beside the ordinary snapshot decoder + // preserves fail-closed behaviour without teaching the cache a sentinel. + if snapshotVersion == 0 { + guard projectionStatus.state == .pending, entries.isEmpty, sources.isEmpty, + changeReason == .initialProjection, previousSnapshotVersion == nil + else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "invalid_never_projected_placeholder") + } + } + + let snapshot = MosaicCustomerEntitlementSnapshot( + snapshotID: try Value.identifier(payload["snapshotId"], path: "payload.snapshotId"), + billingCustomerID: try Value.identifier( + payload["billingCustomerId"], path: "payload.billingCustomerId"), + projectID: try Value.identifier(payload["projectId"], path: "payload.projectId"), + environmentID: try Value.identifier(payload["environmentId"], path: "payload.environmentId"), + snapshotVersion: snapshotVersion, + previousSnapshotVersion: previousSnapshotVersion, + projectionRuleVersion: try Value.int( + payload["projectionRuleVersion"], range: 1...1_000_000, + path: "payload.projectionRuleVersion"), + issuedAt: issuedAt, + asOf: asOf, + refreshAfter: refreshAfter, + validUntil: validUntil, + staleGraceSeconds: staleGraceSeconds, + entityTag: try Value.entityTag(payload["entityTag"], path: "payload.entityTag"), + contentDigest: try Value.digest(payload["contentDigest"], path: "payload.contentDigest"), + entries: entries, + sources: sources, + projectionStatus: projectionStatus, + changeReason: changeReason, + correlationID: try Value.identifier( + payload["correlationId"], path: "payload.correlationId"), + diagnostics: try decodeDiagnostics(payload["diagnostics"])) + + var digestInput = payload + digestInput.removeValue(forKey: "contentDigest") + let computed = try MosaicCustomerCanonicalJSON.digest(digestInput) + + return MosaicCustomerDecodedRecord( + record: .snapshot(snapshot), + binding: MosaicCustomerSnapshotBinding( + contractVersion: mosaicAuthoritativeEntitlementContractVersion, + billingCustomerID: snapshot.billingCustomerID, + projectID: snapshot.projectID, + environmentID: snapshot.environmentID, + snapshotVersion: snapshot.snapshotVersion, + asOf: snapshot.asOf, + contentDigestValid: computed == snapshot.contentDigest), + contentDigestValid: computed == snapshot.contentDigest) + } + + private static func decodeEntry(_ value: [String: Any]) throws + -> MosaicCustomerEntitlementEntry + { + try Value.keys( + value, + required: [ + "entitlementId", "entitlementKey", "state", "endKnown", "sourceIds", "sourceCount", + "primaryExplanation", + ], + optional: ["effectiveStart", "effectiveEnd", "refreshRecommendedAt", "uncertainty"], + path: "entry") + + let state = try Value.member( + MosaicCustomerPersistedEntitlementState.self, value["state"], path: "entry.state") + let endKnown = try Value.bool(value["endKnown"], path: "entry.endKnown") + let effectiveEnd = try Value.optionalTimestamp( + value["effectiveEnd"], path: "entry.effectiveEnd") + // `endKnown: false` means the end is genuinely uncertain, so a reader must + // not display or enforce any expiry. Carrying one anyway is a defect. + guard endKnown || effectiveEnd == nil else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "entry.effectiveEnd", reason: "end_known_false_with_effective_end") + } + let uncertainty = try decodeOptionalUncertainty(value["uncertainty"], path: "entry.uncertainty") + if state == .unknown { + guard let uncertainty, uncertainty.reason != .none else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "entry.uncertainty", reason: "unknown_without_uncertainty") + } + } + let effectiveStart = try Value.optionalTimestamp( + value["effectiveStart"], path: "entry.effectiveStart") + if state == .active, effectiveStart == nil { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "entry.effectiveStart", reason: "active_without_effective_start") + } + + let sourceIDs = try Value.array(value["sourceIds"], count: 0...64, path: "entry.sourceIds") + .enumerated().map { try Value.identifier($1, path: "entry.sourceIds[\($0)]") } + guard Set(sourceIDs).count == sourceIDs.count else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "entry.sourceIds", reason: "duplicate_source_id") + } + let sourceCount = try Value.int(value["sourceCount"], range: 0...64, path: "entry.sourceCount") + guard sourceCount == sourceIDs.count else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "entry_source_count_disagrees") + } + + return MosaicCustomerEntitlementEntry( + entitlementID: try Value.identifier(value["entitlementId"], path: "entry.entitlementId"), + // Project data, not contract vocabulary: an unrecognized key is accepted. + entitlementKey: try Value.patternString( + value["entitlementKey"], length: 1...64, pattern: "^[a-z][a-z0-9_.-]*$", + path: "entry.entitlementKey"), + state: state, + effectiveStart: effectiveStart, + effectiveEnd: effectiveEnd, + endKnown: endKnown, + refreshRecommendedAt: try Value.optionalTimestamp( + value["refreshRecommendedAt"], path: "entry.refreshRecommendedAt"), + sourceIDs: sourceIDs, + sourceCount: sourceCount, + primaryExplanation: try decodeExplanation( + Value.object(value["primaryExplanation"], path: "entry.primaryExplanation")), + uncertainty: uncertainty) + } + + private static func decodeSource(_ value: [String: Any]) throws + -> MosaicCustomerEntitlementSource + { + try Value.keys( + value, + required: [ + "sourceId", "sourceType", "mosaicProductId", "grantVersionId", "sourceSnapshotId", "start", + "sourceState", "uncertainty", "explanationCode", "isTestSource", + ], + optional: ["subscriptionInstanceId", "oneTimePurchaseInstanceId", "storePlatform", "end"], + path: "source") + + let sourceType = try Value.member( + MosaicCustomerSourceType.self, value["sourceType"], path: "source.sourceType") + let subscriptionInstanceID = try Value.optionalIdentifier( + value["subscriptionInstanceId"], path: "source.subscriptionInstanceId") + let oneTimeInstanceID = try Value.optionalIdentifier( + value["oneTimePurchaseInstanceId"], path: "source.oneTimePurchaseInstanceId") + // Exactly one instance reference, chosen by source type. A source naming + // both, or neither, cannot be reconciled with a purchase lineage. + if sourceType == .oneTimeNonConsumable { + guard oneTimeInstanceID != nil, subscriptionInstanceID == nil else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "source", reason: "instance_reference_mismatch") + } + } else { + guard subscriptionInstanceID != nil, oneTimeInstanceID == nil else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "source", reason: "instance_reference_mismatch") + } + } + + let sourceState = try Value.member( + MosaicCustomerSourceState.self, value["sourceState"], path: "source.sourceState") + let uncertainty = try decodeUncertainty( + Value.object(value["uncertainty"], path: "source.uncertainty")) + if sourceState == .unknown, uncertainty.reason == .none { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "source.uncertainty", reason: "unknown_without_uncertainty") + } + + return MosaicCustomerEntitlementSource( + sourceID: try Value.identifier(value["sourceId"], path: "source.sourceId"), + sourceType: sourceType, + subscriptionInstanceID: subscriptionInstanceID, + oneTimePurchaseInstanceID: oneTimeInstanceID, + mosaicProductID: try Value.identifier( + value["mosaicProductId"], path: "source.mosaicProductId"), + grantVersionID: try Value.identifier( + value["grantVersionId"], path: "source.grantVersionId"), + sourceSnapshotID: try Value.identifier( + value["sourceSnapshotId"], path: "source.sourceSnapshotId"), + storePlatform: try Value.optionalMember( + MosaicCustomerStorePlatform.self, value["storePlatform"], path: "source.storePlatform"), + start: try Value.timestamp(value["start"], path: "source.start"), + end: try Value.optionalTimestamp(value["end"], path: "source.end"), + sourceState: sourceState, + uncertainty: uncertainty, + explanationCode: try Value.member( + MosaicCustomerExplanationCode.self, value["explanationCode"], + path: "source.explanationCode"), + isTestSource: try Value.bool(value["isTestSource"], path: "source.isTestSource")) + } + + /// The entry-to-source graph rules the schema cannot express. + private static func validateGraph( + entries: [MosaicCustomerEntitlementEntry], sources: [MosaicCustomerEntitlementSource] + ) throws { + let keys = entries.map(\.entitlementKey) + guard Set(keys).count == keys.count, keys == keys.sorted() else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "entries_not_in_canonical_order") + } + let sourceIDs = sources.map(\.sourceID) + guard Set(sourceIDs).count == sourceIDs.count, sourceIDs == sourceIDs.sorted() else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "sources_not_in_canonical_order") + } + let byID = Dictionary(uniqueKeysWithValues: sources.map { ($0.sourceID, $0) }) + var referenced = Set() + for entry in entries { + let contributing = try entry.sourceIDs.map { id -> MosaicCustomerEntitlementSource in + guard let source = byID[id] else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "entry_references_absent_source") + } + referenced.insert(id) + return source + } + switch entry.state { + case .active: + // An active Entitlement always has a reason. + guard contributing.contains(where: { $0.sourceState == .granting }) else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "entry_active_without_granting_source") + } + case .inactive: + // Unresolved evidence yields unknown, never inactive — the top rule, + // applied inside the projection rather than only at the reader. + guard !contributing.contains(where: { $0.sourceState != .notGranting }) else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "entry_inactive_with_unresolved_source") + } + case .unknown: + break + } + } + guard referenced.count == sources.count else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "snapshot_carries_orphan_source") + } + } + + // MARK: Unchanged + + private static func decodeUnchanged(_ payload: [String: Any]) throws + -> MosaicCustomerDecodedRecord + { + try Value.keys( + payload, + required: [ + "billingCustomerId", "projectId", "environmentId", "snapshotVersion", "entityTag", + "issuedAt", "asOf", "refreshAfter", "validUntil", "projectionStatus", "correlationId", + ], + optional: ["staleGraceSeconds", "diagnostics"], + path: "payload") + + let issuedAt = try Value.timestamp(payload["issuedAt"], path: "payload.issuedAt") + let validUntil = try Value.timestamp(payload["validUntil"], path: "payload.validUntil") + let refreshAfter = try Value.timestamp(payload["refreshAfter"], path: "payload.refreshAfter") + let staleGraceSeconds = + try Value.optionalInt( + payload["staleGraceSeconds"], + range: 0...MosaicCustomerEntitlementPolicy.maxStaleGraceSeconds, + path: "payload.staleGraceSeconds") ?? 0 + guard refreshAfter <= validUntil else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "refresh_after_later_than_valid_until") + } + // Enforced on the unchanged response too: otherwise the combined-horizon + // bound could be evaded by confirming a snapshot rather than reissuing it. + let horizon = validUntil.timeIntervalSince(issuedAt) + TimeInterval(staleGraceSeconds) + guard horizon <= TimeInterval(MosaicCustomerEntitlementPolicy.maxCacheHorizonSeconds) else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "cache_horizon_exceeds_maximum") + } + + let confirmation = MosaicCustomerSnapshotConfirmation( + billingCustomerID: try Value.identifier( + payload["billingCustomerId"], path: "payload.billingCustomerId"), + projectID: try Value.identifier(payload["projectId"], path: "payload.projectId"), + environmentID: try Value.identifier(payload["environmentId"], path: "payload.environmentId"), + snapshotVersion: try Value.snapshotVersion( + payload["snapshotVersion"], path: "payload.snapshotVersion"), + entityTag: try Value.entityTag(payload["entityTag"], path: "payload.entityTag"), + issuedAt: issuedAt, + asOf: try Value.timestamp(payload["asOf"], path: "payload.asOf"), + refreshAfter: refreshAfter, + validUntil: validUntil, + staleGraceSeconds: staleGraceSeconds, + projectionStatus: try decodeProjectionStatus( + Value.object(payload["projectionStatus"], path: "payload.projectionStatus")), + correlationID: try Value.identifier(payload["correlationId"], path: "payload.correlationId"), + diagnostics: try decodeDiagnostics(payload["diagnostics"])) + + return MosaicCustomerDecodedRecord( + record: .unchanged(confirmation), + binding: MosaicCustomerSnapshotBinding( + contractVersion: mosaicAuthoritativeEntitlementContractVersion, + billingCustomerID: confirmation.billingCustomerID, + projectID: confirmation.projectID, + environmentID: confirmation.environmentID, + snapshotVersion: confirmation.snapshotVersion, + asOf: confirmation.asOf, + // An unchanged response carries no entries and therefore no digest; + // it is a confirmation of bytes already verified when they were + // accepted. + contentDigestValid: true), + contentDigestValid: true) + } + + // MARK: Shared members + + private static func decodeProjectionStatus(_ value: [String: Any]) throws + -> MosaicCustomerProjectionStatus + { + try Value.keys( + value, required: ["state", "lastProjectedAt"], + optional: ["pendingFactCount", "diagnosticCode"], path: "projectionStatus") + let state = try Value.member( + MosaicCustomerProjectionState.self, value["state"], path: "projectionStatus.state") + let pendingFactCount = try Value.optionalInt( + value["pendingFactCount"], range: 0...1_000_000, path: "projectionStatus.pendingFactCount") + let diagnosticCode = try Value.optionalDiagnosticCode( + value["diagnosticCode"], path: "projectionStatus.diagnosticCode") + if state == .pending, pendingFactCount == nil { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "projectionStatus.pendingFactCount", reason: "required_for_pending") + } + if state == .degraded || state == .failed, diagnosticCode == nil { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "projectionStatus.diagnosticCode", reason: "required_for_degraded_or_failed") + } + return MosaicCustomerProjectionStatus( + state: state, + lastProjectedAt: try Value.timestamp( + value["lastProjectedAt"], path: "projectionStatus.lastProjectedAt"), + pendingFactCount: pendingFactCount, + diagnosticCode: diagnosticCode) + } + + private static func decodeExplanation(_ value: [String: Any]) throws + -> MosaicCustomerExplanation + { + try Value.keys( + value, required: ["code"], optional: ["sourceId", "safeSummary"], path: "primaryExplanation") + return MosaicCustomerExplanation( + code: try Value.member( + MosaicCustomerExplanationCode.self, value["code"], path: "primaryExplanation.code"), + sourceID: try Value.optionalIdentifier( + value["sourceId"], path: "primaryExplanation.sourceId"), + safeSummary: try Value.optionalSafeString( + value["safeSummary"], length: 1...240, path: "primaryExplanation.safeSummary")) + } + + private static func decodeOptionalUncertainty(_ value: Any?, path: String) throws + -> MosaicCustomerUncertainty? + { + guard let value else { return nil } + return try decodeUncertainty(Value.object(value, path: path)) + } + + private static func decodeUncertainty(_ value: [String: Any]) throws + -> MosaicCustomerUncertainty + { + try Value.keys( + value, required: ["reason"], optional: ["since", "expectedResolution", "diagnosticCode"], + path: "uncertainty") + let reason = try Value.member( + MosaicCustomerUncertaintyReason.self, value["reason"], path: "uncertainty.reason") + let since = try Value.optionalTimestamp(value["since"], path: "uncertainty.since") + // A definite state carries no `since`; a non-definite state requires one. + guard (reason == .none) == (since == nil) else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "uncertainty.since", reason: "since_pairing") + } + return MosaicCustomerUncertainty( + reason: reason, + since: since, + expectedResolution: try Value.optionalMember( + MosaicCustomerExpectedResolution.self, value["expectedResolution"], + path: "uncertainty.expectedResolution"), + diagnosticCode: try Value.optionalDiagnosticCode( + value["diagnosticCode"], path: "uncertainty.diagnosticCode")) + } + + private static func decodeDiagnostics(_ value: Any?) throws -> [MosaicCustomerRecordDiagnostic] { + guard let value else { return [] } + return try Value.array(value, count: 0...10, path: "diagnostics").enumerated().map { + index, raw in + let object = try Value.object(raw, path: "diagnostics[\(index)]") + try Value.keys( + object, required: ["code", "safeMessage", "severity", "retryable", "correlationId"], + optional: ["retryAfterSeconds", "recoveryAction"], path: "diagnostics") + let severity = try Value.string(object["severity"], path: "diagnostics.severity") + guard ["info", "warning", "error"].contains(severity) else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "diagnostics.severity", reason: "unknown_member") + } + var recoveryAction: String? + if let raw = object["recoveryAction"] { + let value = try Value.string(raw, path: "diagnostics.recoveryAction") + guard + [ + "retry", "refreshCustomerAccessToken", "requestAuthoritativeSync", + "resolveIdentityConflict", "fixProductMapping", "contactProvider", "none", + ].contains(value) + else { + throw MosaicCustomerEntitlementDecodingError.invalidShape( + path: "diagnostics.recoveryAction", reason: "unknown_member") + } + recoveryAction = value + } + return MosaicCustomerRecordDiagnostic( + code: try Value.diagnosticCode(object["code"], path: "diagnostics.code"), + safeMessage: try Value.safeString( + object["safeMessage"], length: 1...240, path: "diagnostics.safeMessage"), + severity: severity, + retryable: try Value.bool(object["retryable"], path: "diagnostics.retryable"), + retryAfterSeconds: try Value.optionalInt( + object["retryAfterSeconds"], range: 1...86_400, path: "diagnostics.retryAfterSeconds"), + correlationID: try Value.identifier( + object["correlationId"], path: "diagnostics.correlationId"), + recoveryAction: recoveryAction) + } + } +} + +// MARK: - Primitive readers + +/// Closed readers for the contract's shared primitives. Every one of them +/// throws rather than coercing: a value that does not satisfy the contract +/// rejects the whole record. +private enum Value { + static func object(_ value: Any?, path: String) throws -> [String: Any] { + guard let object = value as? [String: Any] else { throw shape(path, "expected_object") } + return object + } + + static func array(_ value: Any?, count: ClosedRange, path: String) throws -> [Any] { + guard let array = value as? [Any], count.contains(array.count) else { + throw shape(path, "expected_array") + } + return array + } + + static func exactKeys(_ object: [String: Any], expected: Set, path: String) throws { + guard Set(object.keys) == expected else { throw shape(path, "unexpected_or_missing_property") } + } + + static func keys( + _ object: [String: Any], required: Set, optional: Set, path: String + ) throws { + let present = Set(object.keys) + guard required.isSubset(of: present), present.isSubset(of: required.union(optional)) else { + throw shape(path, "unexpected_or_missing_property") + } + } + + static func string(_ value: Any?, path: String) throws -> String { + guard let value = value as? String else { throw shape(path, "expected_string") } + return value + } + + static func bool(_ value: Any?, path: String) throws -> Bool { + guard let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else { + throw shape(path, "expected_boolean") + } + return number.boolValue + } + + static func safeString(_ value: Any?, length: ClosedRange, path: String) throws -> String { + let value = try string(value, path: path) + guard length.contains(value.count), + value.unicodeScalars.allSatisfy({ $0.value >= 0x20 && $0.value != 0x7F }) + else { throw shape(path, "unsafe_or_out_of_bounds_string") } + return value + } + + static func optionalSafeString(_ value: Any?, length: ClosedRange, path: String) throws + -> String? + { + guard let value else { return nil } + return try safeString(value, length: length, path: path) + } + + static func patternString( + _ value: Any?, length: ClosedRange, pattern: String, path: String + ) throws -> String { + let value = try safeString(value, length: length, path: path) + guard value.range(of: pattern, options: .regularExpression) != nil else { + throw shape(path, "pattern_mismatch") + } + return value + } + + static func identifier(_ value: Any?, path: String) throws -> String { + try patternString(value, length: 1...128, pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$", path: path) + } + + static func optionalIdentifier(_ value: Any?, path: String) throws -> String? { + guard let value else { return nil } + return try identifier(value, path: path) + } + + static func entityTag(_ value: Any?, path: String) throws -> String { + try patternString(value, length: 8...128, pattern: "^[A-Za-z0-9._-]+$", path: path) + } + + static func digest(_ value: Any?, path: String) throws -> String { + try patternString(value, length: 71...71, pattern: "^sha256:[a-f0-9]{64}$", path: path) + } + + static func diagnosticCode(_ value: Any?, path: String) throws -> String { + try patternString( + value, length: 3...96, pattern: "^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$", path: path) + } + + static func optionalDiagnosticCode(_ value: Any?, path: String) throws -> String? { + guard let value else { return nil } + return try diagnosticCode(value, path: path) + } + + /// RFC 3339 UTC with exactly three fractional digits and a literal Z. The + /// precision is fixed because the same instant written with a different + /// precision would digest differently. + static func timestamp(_ value: Any?, path: String) throws -> Date { + let raw = try string(value, path: path) + guard + raw.range( + of: + "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\\.[0-9]{3}Z$", + options: .regularExpression) != nil + else { throw shape(path, "expected_utc_millisecond_timestamp") } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = formatter.date(from: raw) else { + throw shape(path, "expected_utc_millisecond_timestamp") + } + return date + } + + static func optionalTimestamp(_ value: Any?, path: String) throws -> Date? { + guard let value else { return nil } + return try timestamp(value, path: path) + } + + static func integer(_ value: Any?, path: String) throws -> Int64 { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), + Double(number.int64Value) == number.doubleValue + else { throw shape(path, "expected_integer") } + return number.int64Value + } + + static func int(_ value: Any?, range: ClosedRange, path: String) throws -> Int { + let value = try integer(value, path: path) + guard let narrowed = Int(exactly: value), range.contains(narrowed) else { + throw shape(path, "integer_out_of_bounds") + } + return narrowed + } + + static func optionalInt(_ value: Any?, range: ClosedRange, path: String) throws -> Int? { + guard let value else { return nil } + return try int(value, range: range, path: path) + } + + static func optionalInt64(_ value: Any?, range: ClosedRange, path: String) throws + -> Int64? + { + guard let value else { return nil } + let number = try integer(value, path: path) + guard range.contains(number) else { throw shape(path, "integer_out_of_bounds") } + return number + } + + static func snapshotVersion(_ value: Any?, path: String) throws -> Int64 { + let number = try integer(value, path: path) + guard (1...999_999_999_999).contains(number) else { throw shape(path, "integer_out_of_bounds") } + return number + } + + static func snapshotVersionOrPlaceholder(_ value: Any?, path: String) throws -> Int64 { + let number = try integer(value, path: path) + guard (0...999_999_999_999).contains(number) else { throw shape(path, "integer_out_of_bounds") } + return number + } + + static func member(_: T.Type, _ value: Any?, path: String) throws -> T + where T.RawValue == String { + guard let raw = value as? String, let member = T(rawValue: raw) else { + throw shape(path, "unknown_member") + } + return member + } + + static func optionalMember(_ type: T.Type, _ value: Any?, path: String) + throws -> T? + where T.RawValue == String { + guard let value else { return nil } + return try member(type, value, path: path) + } + + private static func shape(_ path: String, _ reason: String) + -> MosaicCustomerEntitlementDecodingError + { + .invalidShape(path: path, reason: reason) + } +} diff --git a/sdk/ios/Sources/MosaicSDK/EntitlementSyncClient.swift b/sdk/ios/Sources/MosaicSDK/EntitlementSyncClient.swift new file mode 100644 index 00000000..7b451de3 --- /dev/null +++ b/sdk/ios/Sources/MosaicSDK/EntitlementSyncClient.swift @@ -0,0 +1,609 @@ +import Foundation + +public enum MosaicCustomerEntitlementRefreshResult: Sendable, Equatable { + case updated(snapshotVersion: Int64) + /// The cached snapshot was confirmed current and its freshness window slid. + case unchanged(snapshotVersion: Int64) + case skippedFresh(snapshotVersion: Int64) + /// The refresh failed and the previously accepted snapshot still stands. + case preserved(snapshotVersion: Int64, diagnostic: MosaicDiagnostic) + case unavailable(reason: MosaicCustomerUnavailableReason, diagnostics: [MosaicDiagnostic]) + case signedOut +} + +/// Synchronizes and holds the authoritative Entitlement snapshot. +/// +/// The invariant this actor exists to keep: **a rejection yields `unknown` and +/// preserves the cache, never `inactive`.** A reader that collapsed "I could not +/// find out" into "you do not have it" would turn every Mosaic outage into a +/// mass revocation experienced by paying customers, and would do it most +/// reliably at exactly the moment Mosaic is least able to notice. +actor MosaicCustomerEntitlementClient { + private struct AcceptedSnapshot: Sendable { + var snapshot: MosaicCustomerEntitlementSnapshot + var recordData: Data + var entityTag: String + var issuedAt: Date + var refreshAfter: Date + var validUntil: Date + var staleGraceSeconds: Int + var trustedTime: MosaicTrustedTimeAnchor? + } + + private let publicSDKKey: String + private let endpointURL: URL + private let requestTimeout: TimeInterval + private let transport: any MosaicEntitlementSyncTransport + private let tokenStore: MosaicCustomerTokenStore + private let broadcaster: MosaicCustomerEntitlementBroadcaster + private let cacheStoreFactory: @Sendable (String) -> any MosaicCustomerEntitlementCacheStore + private let clock: @Sendable () -> Date + + private var cacheStore: any MosaicCustomerEntitlementCacheStore + private var bindingDigest: String + private var accepted: AcceptedSnapshot? + private var cacheStateOverride: MosaicCustomerEntitlementCacheState? + private var diagnostics: [MosaicDiagnostic] = [] + private var acceptedCount: UInt64 = 0 + private var rejectedCount: UInt64 = 0 + private var lastRejectionReason: String? + private var inFlight: (id: UInt64, task: Task)? + private var refreshSequence: UInt64 = 0 + /// Bumped on every identity transition. A response minted for a previous + /// generation is discarded rather than applied. + private var generation: UInt64 = 0 + + init( + publicSDKKey: String, + baseURL: URL, + requestTimeout: TimeInterval, + transport: any MosaicEntitlementSyncTransport, + tokenStore: MosaicCustomerTokenStore, + broadcaster: MosaicCustomerEntitlementBroadcaster = MosaicCustomerEntitlementBroadcaster(), + bindingDigest: String, + cacheStoreFactory: @escaping @Sendable (String) -> any MosaicCustomerEntitlementCacheStore, + clock: @escaping @Sendable () -> Date = Date.init + ) { + self.publicSDKKey = publicSDKKey + endpointURL = + baseURL + .appendingPathComponent("v1", isDirectory: true) + .appendingPathComponent("sdk", isDirectory: true) + .appendingPathComponent("billing", isDirectory: true) + .appendingPathComponent("entitlements", isDirectory: false) + self.requestTimeout = requestTimeout + self.transport = transport + self.tokenStore = tokenStore + self.broadcaster = broadcaster + self.bindingDigest = bindingDigest + self.cacheStoreFactory = cacheStoreFactory + cacheStore = cacheStoreFactory(bindingDigest) + self.clock = clock + } + + // MARK: Observation + + func updates() async -> AsyncStream { + await broadcaster.updates() + } + + // MARK: Reading + + /// The device instant freshness is judged against. + /// + /// A server-anchored monotonic reading is preferred; the device wall clock is + /// the fallback, and the unreliable-clock rule in the freshness policy is what + /// keeps that fallback safe. + private func now() -> Date { + accepted?.trustedTime?.now() ?? clock() + } + + private func currentCacheState() -> MosaicCustomerEntitlementCacheState { + if let cacheStateOverride { return cacheStateOverride } + guard let accepted else { return .missing } + return MosaicCustomerEntitlementFreshness.evaluate( + issuedAt: accepted.issuedAt, + refreshAfter: accepted.refreshAfter, + validUntil: accepted.validUntil, + staleGraceSeconds: accepted.staleGraceSeconds, + deviceNow: now()) + } + + func cacheState() -> MosaicCustomerEntitlementCacheState { currentCacheState() } + + func snapshot() -> MosaicCustomerEntitlementSnapshotUpdate? { + guard let accepted else { return nil } + return .init(snapshot: accepted.snapshot, cacheState: currentCacheState()) + } + + func check(key: String) async -> MosaicCustomerEntitlementCheck { + let state = currentCacheState() + guard let accepted else { + return MosaicCustomerEntitlementCheck( + entitlementKey: key, + state: .unavailable(reason: await unavailableReasonWithoutSnapshot()), + cacheState: state) + } + return accepted.snapshot.check(key: key, cacheState: state) + } + + private func unavailableReasonWithoutSnapshot() async -> MosaicCustomerUnavailableReason { + guard await tokenStore.isConfigured else { return .notConfigured } + switch await tokenStore.token() { + case .signedOut: return .signedOut + case .unavailable(let reason): return reason + case .lease: return .noSnapshot + } + } + + func diagnosticsSnapshot() async -> MosaicCustomerEntitlementDiagnostics { + MosaicCustomerEntitlementDiagnostics( + isConfigured: await tokenStore.isConfigured, + hasCustomerToken: await tokenStore.hasToken, + tokenGeneration: await tokenStore.currentGeneration, + cacheState: currentCacheState(), + snapshotVersion: accepted?.snapshot.snapshotVersion, + billingCustomerID: accepted?.snapshot.billingCustomerID, + projectionState: accepted?.snapshot.projectionStatus.state, + entryCount: accepted?.snapshot.entries.count ?? 0, + acceptedSnapshotCount: acceptedCount, + rejectedSnapshotCount: rejectedCount, + lastRejectionReason: lastRejectionReason, + lastSafeCode: diagnostics.last?.code, + isRefreshInFlight: inFlight != nil) + } + + // MARK: Lifecycle + + /// Loads any cached snapshot without touching the network, so a launch has an + /// answer before the first request completes. + func bootstrap() async { + do { + guard let record = try await cacheStore.load() else { return } + let decoded = try MosaicCustomerEntitlementCodec.decode(record.recordData) + guard case .snapshot(let snapshot) = decoded.record, decoded.contentDigestValid else { + throw MosaicCustomerEntitlementDecodingError.invalidSemantics( + code: "cached_snapshot_rejected") + } + accepted = AcceptedSnapshot( + snapshot: snapshot, + recordData: record.recordData, + entityTag: record.entityTag, + issuedAt: record.issuedAt, + refreshAfter: record.refreshAfter, + validUntil: record.validUntil, + staleGraceSeconds: record.staleGraceSeconds, + trustedTime: MosaicTrustedTimeAnchor.cached( + serverTime: record.serverTime, localReceiptTime: record.localReceiptTime, + systemUptime: record.systemUptime, now: clock())) + await emitCurrent() + } catch { + // A cache that cannot be read is discarded, never interpreted. + cacheStateOverride = .invalid + record(code: "entitlement_cache_unreadable", stage: .entitlementCache) + try? await cacheStore.clear() + } + } + + /// Identity transition. The order is the point: bump the generation so + /// in-flight work is recognisably stale, cancel it, clear before any read can + /// return the previous person's grants, then emit an explicit state so an + /// observer sees the transition rather than inferring it from silence. + func identityChanged(bindingDigest newDigest: String, signedOut: Bool) async { + generation &+= 1 + inFlight?.task.cancel() + inFlight = nil + accepted = nil + cacheStateOverride = nil + if signedOut { + await tokenStore.signOut() + } else { + await tokenStore.invalidate() + } + if newDigest != bindingDigest { + bindingDigest = newDigest + cacheStore = cacheStoreFactory(newDigest) + } + await broadcaster.emit(signedOut ? .signedOut : .cleared(.identityChanged)) + } + + /// Host-requested clear: discards the token and deletes this customer's cache. + func clearCustomerState() async { + generation &+= 1 + inFlight?.task.cancel() + inFlight = nil + accepted = nil + cacheStateOverride = nil + try? await cacheStore.clear() + await tokenStore.invalidate() + await broadcaster.emit(.cleared(.hostRequested)) + } + + // MARK: Refresh + + func refreshIfNeeded() async -> MosaicCustomerEntitlementRefreshResult { + if let accepted, case .fresh = currentCacheState() { + return .skippedFresh(snapshotVersion: accepted.snapshot.snapshotVersion) + } + return await refresh() + } + + /// Single-flight. Ten call sites asking at once produce one request. + func refresh() async -> MosaicCustomerEntitlementRefreshResult { + if let task = inFlight?.task { return await task.value } + refreshSequence &+= 1 + let id = refreshSequence + let task = Task { await performRefresh() } + inFlight = (id, task) + let result = await task.value + if inFlight?.id == id { inFlight = nil } + return result + } + + private func performRefresh() async -> MosaicCustomerEntitlementRefreshResult { + let startGeneration = generation + switch await tokenStore.token() { + case .signedOut: + await broadcaster.emit(.signedOut) + return .signedOut + case .unavailable(let reason): + return await unavailable(reason, code: "entitlement_token_unavailable", stage: .entitlementAuthentication) + case .lease(let lease): + // `loading` is emitted only when there is nothing to serve. A background + // refresh of an already-accepted snapshot must not flick every observer + // through an indeterminate state, and must not leave the stream parked + // there if the refresh is then rejected. + if accepted == nil { await broadcaster.emit(.loading) } + return await sync(with: lease, startGeneration: startGeneration, allowRetry: true) + } + } + + private func sync( + with lease: MosaicCustomerTokenLease, startGeneration: UInt64, allowRetry: Bool + ) async -> MosaicCustomerEntitlementRefreshResult { + var headers = [ + MosaicEntitlementSyncHeader.authorization: "Bearer \(lease.token.value)", + MosaicEntitlementSyncHeader.sdkKey: publicSDKKey, + "Accept": "application/json", + "Content-Type": "application/json", + "Mosaic-SDK-Platform": "ios", + "Mosaic-SDK-Version": mosaicSDKVersion, + ] + if let entityTag = accepted?.entityTag { + headers[MosaicEntitlementSyncHeader.ifNoneMatch] = "\"\(entityTag)\"" + } + + let body: Data + do { + body = try MosaicEntitlementSyncRequestBody.encode( + knownSnapshotVersion: accepted?.snapshot.snapshotVersion, + entityTag: accepted?.entityTag, + correlationID: MosaicEntitlementSyncRequestBody.correlationID()) + } catch { + return await preserveOrUnavailable( + code: "entitlement_request_encoding_failed", stage: .entitlementValidation, + reason: .serviceUnavailable) + } + + let response: MosaicEntitlementSyncHTTPResponse + do { + response = try await transport.fetch( + MosaicEntitlementSyncHTTPRequest( + url: endpointURL, headers: headers, body: body, timeout: requestTimeout)) + } catch { + // A network failure has not revoked anyone's subscription. + return await preserveOrUnavailable( + code: "entitlement_network_unavailable", stage: .entitlementTransport, + reason: .serviceUnavailable) + } + + // A response for an identity the SDK has already moved past is discarded + // whole. Applying it would attach the previous customer's access to the new + // session. + guard generation == startGeneration else { + return .unavailable(reason: .signedOut, diagnostics: diagnostics) + } + + switch response.statusCode { + case 200: + return await accept(response) + case 304: + return await confirmUnchanged(response) + case 401, 403: + guard allowRetry else { + return await preserveOrUnavailable( + code: "entitlement_not_authorized", stage: .entitlementAuthentication, + reason: .notAuthorized) + } + switch await tokenStore.recoverFromUnauthorized(lease) { + case .lease(let refreshed): + guard generation == startGeneration else { + return .unavailable(reason: .signedOut, diagnostics: diagnostics) + } + return await sync( + with: refreshed, startGeneration: startGeneration, allowRetry: false) + case .signedOut: + await broadcaster.emit(.signedOut) + return .signedOut + case .unavailable(let reason): + return await preserveOrUnavailable( + code: "entitlement_not_authorized", stage: .entitlementAuthentication, reason: reason) + } + case 409: + // Billing disabled for this Environment maps to unavailable on every + // entitlement surface, never to inactive. + return await preserveOrUnavailable( + code: "entitlement_billing_disabled", stage: .entitlementValidation, + reason: .billingDisabled) + default: + return await preserveOrUnavailable( + code: "entitlement_http_\(response.statusCode)", stage: .entitlementTransport, + reason: .serviceUnavailable) + } + } + + // MARK: Acceptance gate + + private func accept(_ response: MosaicEntitlementSyncHTTPResponse) async + -> MosaicCustomerEntitlementRefreshResult + { + let decoded: MosaicCustomerDecodedRecord + do { + decoded = try MosaicCustomerEntitlementCodec.decode(response.data) + } catch { + let code = + (error as? MosaicCustomerEntitlementDecodingError)?.diagnosticCode + ?? "entitlement_record_rejected" + rejectedCount &+= 1 + lastRejectionReason = code + return await preserveOrUnavailable( + code: code, stage: .entitlementValidation, reason: .serviceUnavailable) + } + + if case .unchanged(let confirmation) = decoded.record { + return await confirm( + snapshotVersion: confirmation.snapshotVersion, + binding: decoded.binding, + refreshAfter: confirmation.refreshAfter, + validUntil: confirmation.validUntil, + issuedAt: confirmation.issuedAt, + staleGraceSeconds: confirmation.staleGraceSeconds, + serverDate: response.serverDate) + } + guard case .snapshot(let snapshot) = decoded.record else { + return await preserveOrUnavailable( + code: "entitlement_unsupported_record_type", stage: .entitlementValidation, + reason: .serviceUnavailable) + } + + let decision = MosaicCustomerEntitlementCacheDecision.evaluate( + cached: accepted.map { cached in + MosaicCustomerSnapshotBinding( + contractVersion: mosaicAuthoritativeEntitlementContractVersion, + billingCustomerID: cached.snapshot.billingCustomerID, + projectID: cached.snapshot.projectID, + environmentID: cached.snapshot.environmentID, + snapshotVersion: cached.snapshot.snapshotVersion, + asOf: cached.snapshot.asOf, + contentDigestValid: true) + }, + incoming: decoded.binding) + + guard case .accepted = decision else { + guard case .rejected(let reason) = decision else { preconditionFailure("unreachable") } + rejectedCount &+= 1 + lastRejectionReason = reason.rawValue + if reason.isBindingMismatch { + // The one rejection that clears. Continuing to serve the previous + // customer's access after an identity change is the leak this rule + // exists to prevent, so it is also the one that earns an error-severity + // diagnostic rather than a warning. + accepted = nil + cacheStateOverride = .differentCustomer + try? await cacheStore.clear() + record(code: reason.diagnosticCode, stage: .entitlementValidation) + await broadcaster.emit(.cleared(.differentCustomer)) + return .unavailable(reason: .noSnapshot, diagnostics: diagnostics) + } + return await preserveOrUnavailable( + code: reason.diagnosticCode, stage: .entitlementValidation, reason: .serviceUnavailable) + } + + let entityTag = response.etag.map(Self.unquoted) ?? snapshot.entityTag + let trustedTime = response.serverDate.map { + MosaicTrustedTimeAnchor.remote(serverTime: $0, localReceiptTime: clock()) + } + let next = AcceptedSnapshot( + snapshot: snapshot, + recordData: response.data, + entityTag: entityTag, + issuedAt: snapshot.issuedAt, + refreshAfter: snapshot.refreshAfter, + validUntil: snapshot.validUntil, + staleGraceSeconds: snapshot.staleGraceSeconds, + trustedTime: trustedTime ?? accepted?.trustedTime) + + // Acceptance is atomic: the cache is replaced whole, never merged. A reader + // never keeps the entries it understood from a document it rejected. + accepted = next + cacheStateOverride = nil + acceptedCount &+= 1 + await persist(next) + await emitCurrent() + return .updated(snapshotVersion: snapshot.snapshotVersion) + } + + /// A bare `304`. + /// + /// The cache is preserved but its freshness window is **not** slid. Sliding + /// requires server-issued `refreshAfter`/`validUntil`, and the only + /// contract-pinned carrier for those is a `snapshotUnchanged` record, which a + /// `304` has no body to hold. Inventing headers to carry them would put the + /// offline-access horizon on wire names no contract owns. + private func confirmUnchanged(_ response: MosaicEntitlementSyncHTTPResponse) async + -> MosaicCustomerEntitlementRefreshResult + { + guard var current = accepted else { + // Nothing to confirm. A 304 against no cache is a server or proxy defect. + return await preserveOrUnavailable( + code: "entitlement_unexpected_not_modified", stage: .entitlementTransport, + reason: .noSnapshot) + } + if let serverDate = response.serverDate { + current.trustedTime = MosaicTrustedTimeAnchor.remote( + serverTime: serverDate, localReceiptTime: clock()) + accepted = current + } + return .unchanged(snapshotVersion: current.snapshot.snapshotVersion) + } + + /// A confirmed-current snapshot must not expire merely because it was + /// confirmed instead of resent, so the freshness window slides. Nothing else + /// changes: the version does not advance and no emission claims it did. + private func confirm( + snapshotVersion: Int64, + binding: MosaicCustomerSnapshotBinding?, + refreshAfter: Date?, + validUntil: Date?, + issuedAt: Date?, + staleGraceSeconds: Int?, + serverDate: Date? + ) async -> MosaicCustomerEntitlementRefreshResult { + guard var current = accepted else { + return await preserveOrUnavailable( + code: "entitlement_unexpected_not_modified", stage: .entitlementTransport, + reason: .noSnapshot) + } + // A confirmation for another customer is a binding failure like any other. + if let binding { + if binding.billingCustomerID != current.snapshot.billingCustomerID + || binding.projectID != current.snapshot.projectID + || binding.environmentID != current.snapshot.environmentID + { + rejectedCount &+= 1 + lastRejectionReason = MosaicCustomerSnapshotRejectionReason.customerMismatch.rawValue + accepted = nil + cacheStateOverride = .differentCustomer + try? await cacheStore.clear() + record( + code: MosaicCustomerSnapshotRejectionReason.customerMismatch.diagnosticCode, + stage: .entitlementValidation) + await broadcaster.emit(.cleared(.differentCustomer)) + return .unavailable(reason: .noSnapshot, diagnostics: diagnostics) + } + guard binding.snapshotVersion == current.snapshot.snapshotVersion else { + return await preserveOrUnavailable( + code: "entitlement_unchanged_version_mismatch", stage: .entitlementValidation, + reason: .serviceUnavailable) + } + } + + if let issuedAt { current.issuedAt = issuedAt } + if let refreshAfter { current.refreshAfter = refreshAfter } + if let validUntil { current.validUntil = validUntil } + if let staleGraceSeconds { current.staleGraceSeconds = staleGraceSeconds } + if let serverDate { + current.trustedTime = MosaicTrustedTimeAnchor.remote( + serverTime: serverDate, localReceiptTime: clock()) + } + // Guard against a slid window that would exceed the combined horizon. + let horizon = + current.validUntil.timeIntervalSince(current.issuedAt) + + TimeInterval(current.staleGraceSeconds) + guard horizon <= TimeInterval(MosaicCustomerEntitlementPolicy.maxCacheHorizonSeconds) else { + return await preserveOrUnavailable( + code: "entitlement_cache_horizon_exceeds_maximum", stage: .entitlementValidation, + reason: .serviceUnavailable) + } + + accepted = current + cacheStateOverride = nil + await persist(current) + await emitCurrent() + return .unchanged(snapshotVersion: snapshotVersion) + } + + // MARK: Persistence and emission + + private func persist(_ value: AcceptedSnapshot) async { + let record = MosaicCustomerEntitlementCacheRecord( + recordData: value.recordData, + billingCustomerID: value.snapshot.billingCustomerID, + projectID: value.snapshot.projectID, + environmentID: value.snapshot.environmentID, + snapshotVersion: value.snapshot.snapshotVersion, + issuedAt: value.issuedAt, + asOf: value.snapshot.asOf, + refreshAfter: value.refreshAfter, + validUntil: value.validUntil, + staleGraceSeconds: value.staleGraceSeconds, + entityTag: value.entityTag, + storedAt: clock(), + serverTime: value.trustedTime?.serverTime, + localReceiptTime: value.trustedTime?.localReceiptTime, + systemUptime: value.trustedTime?.systemUptime, + checksum: MosaicCustomerEntitlementCacheRecord.checksum( + recordData: value.recordData, + billingCustomerID: value.snapshot.billingCustomerID, + projectID: value.snapshot.projectID, + environmentID: value.snapshot.environmentID, + snapshotVersion: value.snapshot.snapshotVersion)) + do { + try await cacheStore.save(record) + } catch { + // A cache that cannot be written costs offline continuity, not + // correctness: the accepted snapshot still stands for this process. + self.record(code: "entitlement_cache_write_failed", stage: .entitlementCache) + } + } + + private func emitCurrent() async { + guard let accepted else { return } + await broadcaster.emit( + .snapshot(.init(snapshot: accepted.snapshot, cacheState: currentCacheState()))) + } + + private func preserveOrUnavailable( + code: String, stage: MosaicDiagnosticStage, reason: MosaicCustomerUnavailableReason + ) async -> MosaicCustomerEntitlementRefreshResult { + let diagnostic = MosaicDiagnostic(code: code, stage: stage) + record(diagnostic) + // The cache is preserved on every rejection except a binding mismatch, + // which is handled at its own call site. + if let accepted, currentCacheState().servesAccess { + return .preserved( + snapshotVersion: accepted.snapshot.snapshotVersion, diagnostic: diagnostic) + } + let effective: MosaicCustomerUnavailableReason = + accepted != nil ? .cacheExpired : reason + await broadcaster.emit(.unavailable(effective)) + return .unavailable(reason: effective, diagnostics: diagnostics) + } + + private func unavailable( + _ reason: MosaicCustomerUnavailableReason, code: String, stage: MosaicDiagnosticStage + ) async -> MosaicCustomerEntitlementRefreshResult { + record(code: code, stage: stage) + if let accepted, currentCacheState().servesAccess { + return .preserved( + snapshotVersion: accepted.snapshot.snapshotVersion, + diagnostic: MosaicDiagnostic(code: code, stage: stage)) + } + await broadcaster.emit(.unavailable(reason)) + return .unavailable(reason: reason, diagnostics: diagnostics) + } + + private func record(code: String, stage: MosaicDiagnosticStage) { + record(MosaicDiagnostic(code: code, stage: stage)) + } + + private func record(_ diagnostic: MosaicDiagnostic) { + diagnostics.append(diagnostic) + if diagnostics.count > 32 { diagnostics.removeFirst(diagnostics.count - 32) } + } + + private static func unquoted(_ value: String) -> String { + guard value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 else { return value } + return String(value.dropFirst().dropLast()) + } +} diff --git a/sdk/ios/Sources/MosaicSDK/EntitlementSyncTransport.swift b/sdk/ios/Sources/MosaicSDK/EntitlementSyncTransport.swift new file mode 100644 index 00000000..f8ec5360 --- /dev/null +++ b/sdk/ios/Sources/MosaicSDK/EntitlementSyncTransport.swift @@ -0,0 +1,134 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +struct MosaicEntitlementSyncHTTPRequest: Sendable, Equatable { + let url: URL + /// Never logged and never printed: this dictionary carries the bearer token. + let headers: [String: String] + /// The canonical `entitlementSyncRequest` envelope. + /// + /// The sync surface is a POST even though it is a read, because contract + /// negotiation lives in the request record and a GET cannot carry it. + /// Conditional revalidation still rides on `If-None-Match`. + let body: Data + let timeout: TimeInterval +} + +struct MosaicEntitlementSyncHTTPResponse: Sendable, Equatable { + let statusCode: Int + let data: Data + let etag: String? + /// The `Date` header, used to anchor trusted time. A device clock is + /// attacker-controlled; a server instant is not. + let serverDate: Date? + let retryAfterSeconds: Int? + + init( + statusCode: Int, data: Data = Data(), etag: String? = nil, serverDate: Date? = nil, + retryAfterSeconds: Int? = nil + ) { + self.statusCode = statusCode + self.data = data + self.etag = etag + self.serverDate = serverDate + self.retryAfterSeconds = retryAfterSeconds + } +} + +protocol MosaicEntitlementSyncTransport: Sendable { + func fetch(_ request: MosaicEntitlementSyncHTTPRequest) async throws + -> MosaicEntitlementSyncHTTPResponse +} + +/// Header names owned by Customer Access Token Contract v1's wire form. +/// +/// Both are required: the public SDK key identifies the application, the +/// customer token selects the customer, and neither substitutes for the other. +/// The token never travels in a query string, which would put it in access logs, +/// proxy logs, and browser history. +enum MosaicEntitlementSyncHeader { + static let authorization = "Authorization" + static let sdkKey = "Mosaic-SDK-Key" + static let ifNoneMatch = "If-None-Match" +} + +/// Builds the canonical `entitlementSyncRequest` envelope. +/// +/// `billingCustomerId` is deliberately never sent. It is a hint the server +/// verifies against the Customer Access Token and refuses on mismatch, so it can +/// only narrow the answer or fail the request — it can never widen access, and +/// omitting it removes a value that would otherwise have to be kept in step with +/// the token. +enum MosaicEntitlementSyncRequestBody { + static func encode( + knownSnapshotVersion: Int64?, + entityTag: String?, + correlationID: String + ) throws -> Data { + var payload: [String: Any] = [ + "supportedAuthoritativeEntitlementContracts": [ + mosaicAuthoritativeEntitlementContractVersion + ], + "correlationId": correlationID, + ] + // Together these let the server answer `snapshotUnchanged` instead of + // resending a snapshot the device already holds. + if let knownSnapshotVersion { payload["knownSnapshotVersion"] = knownSnapshotVersion } + if let entityTag { payload["entityTag"] = entityTag } + return try MosaicCustomerCanonicalJSON.data([ + "authoritativeEntitlementContractVersion": mosaicAuthoritativeEntitlementContractVersion, + "recordType": "entitlementSyncRequest", + "payload": payload, + ]) + } + + /// A per-request identifier satisfying the contract's identifier pattern. It is + /// random per request and derived from nothing about the user or the device. + static func correlationID() -> String { + "ios_" + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + } +} + +struct MosaicURLSessionEntitlementSyncTransport: MosaicEntitlementSyncTransport { + private let session: URLSession + + init(requestTimeout: TimeInterval) { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = requestTimeout + configuration.timeoutIntervalForResource = requestTimeout + // The SDK owns conditional requests through the entity tag; a URL cache + // layered underneath would answer with bytes the acceptance gate never saw. + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + session = URLSession(configuration: configuration) + } + + func fetch(_ request: MosaicEntitlementSyncHTTPRequest) async throws + -> MosaicEntitlementSyncHTTPResponse + { + var urlRequest = URLRequest(url: request.url, timeoutInterval: request.timeout) + urlRequest.httpMethod = "POST" + urlRequest.httpBody = request.body + for (name, value) in request.headers { urlRequest.setValue(value, forHTTPHeaderField: name) } + let (data, response) = try await session.data(for: urlRequest) + guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } + return MosaicEntitlementSyncHTTPResponse( + statusCode: http.statusCode, + data: data, + etag: http.value(forHTTPHeaderField: "ETag"), + serverDate: http.value(forHTTPHeaderField: "Date").flatMap(Self.httpDate), + retryAfterSeconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init)) + } + + private static func httpDate(_ value: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return formatter.date(from: value) + } + +} diff --git a/sdk/ios/Sources/MosaicSDK/EntitlementUpdates.swift b/sdk/ios/Sources/MosaicSDK/EntitlementUpdates.swift new file mode 100644 index 00000000..643a0820 --- /dev/null +++ b/sdk/ios/Sources/MosaicSDK/EntitlementUpdates.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Fans authoritative entitlement changes out to every observer. +/// +/// `AsyncStream` is single-consumer, but several parts of an app legitimately +/// want to watch access at once — a paywall, a settings screen, a feature gate — +/// so each subscriber gets its own stream and this actor multiplies emissions +/// across them. +/// +/// A new subscriber is replayed the current state immediately. Without that, a +/// view that appears after the launch sync would sit in an indeterminate state +/// until the *next* change, which for a stable subscription may be never. +actor MosaicCustomerEntitlementBroadcaster { + /// Enough to absorb a burst — a sign-in emitting cleared, loading, and then a + /// snapshot — while still dropping the oldest rather than growing without + /// bound if a consumer stops reading. The newest state is the true one, so + /// dropping the oldest is the correct policy for a state stream. + static let bufferSize = 8 + + private var continuations: + [UUID: AsyncStream.Continuation] = [:] + private var current: MosaicCustomerEntitlementUpdate? + + var subscriberCount: Int { continuations.count } + var currentUpdate: MosaicCustomerEntitlementUpdate? { current } + + func updates() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream( + of: MosaicCustomerEntitlementUpdate.self, + bufferingPolicy: .bufferingNewest(Self.bufferSize)) + continuations[id] = continuation + if let current { continuation.yield(current) } + continuation.onTermination = { [weak self] _ in + Task { await self?.remove(id) } + } + return stream + } + + /// Emits one change. Only accepted state reaches here: a rejected snapshot + /// never emits, so an observer can treat every emission as authoritative. + func emit(_ update: MosaicCustomerEntitlementUpdate) { + current = update + for continuation in continuations.values { continuation.yield(update) } + } + + func finish() { + for continuation in continuations.values { continuation.finish() } + continuations.removeAll() + } + + private func remove(_ id: UUID) { + continuations.removeValue(forKey: id) + } +} diff --git a/sdk/ios/Sources/MosaicSDK/Identity.swift b/sdk/ios/Sources/MosaicSDK/Identity.swift index 49bd11b2..bbfe4300 100644 --- a/sdk/ios/Sources/MosaicSDK/Identity.swift +++ b/sdk/ios/Sources/MosaicSDK/Identity.swift @@ -38,7 +38,16 @@ actor MosaicIdentityFilePersistence: MosaicIdentityPersistence { } func save(_ data: Data) throws { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + // The installation identifier is meant to identify one app install. Carried + // to a second device by a backup restore it would report two installs as + // one, which silently corrupts experiment bucketing and analytics identity. + var resource = URLResourceValues() + resource.isExcludedFromBackup = true + var mutableDirectory = directory + try? mutableDirectory.setResourceValues(resource) try data.write(to: file, options: .atomic) + var mutableFile = file + try? mutableFile.setResourceValues(resource) } } diff --git a/sdk/ios/Sources/MosaicSDK/PaywallLoader.swift b/sdk/ios/Sources/MosaicSDK/PaywallLoader.swift index df0ff2ec..64a3066b 100644 --- a/sdk/ios/Sources/MosaicSDK/PaywallLoader.swift +++ b/sdk/ios/Sources/MosaicSDK/PaywallLoader.swift @@ -15,6 +15,14 @@ public enum MosaicDiagnosticStage: String, Sendable { case fallbackValidation case commerce case rendering + // Authoritative entitlement stages. Adding members to this enumeration is + // source-breaking for an exhaustive `switch` in a host app; that is accepted + // at 0.1.0-dev and recorded in the changelog. + case entitlementTransport + case entitlementValidation + case entitlementCache + case entitlementAuthentication + case entitlementRestore } /// A deliberately safe diagnostic. It contains a stable code and stage, never diff --git a/sdk/ios/Sources/MosaicSDK/RestoreAndSync.swift b/sdk/ios/Sources/MosaicSDK/RestoreAndSync.swift new file mode 100644 index 00000000..136098fc --- /dev/null +++ b/sdk/ios/Sources/MosaicSDK/RestoreAndSync.swift @@ -0,0 +1,168 @@ +import Foundation + +/// One observable step of a restore. +/// +/// A restore is genuinely two operations — ask the store what this Apple ID +/// owns, then wait for Mosaic to validate and project it — and collapsing them +/// into a single spinner is how a restore flow starts lying to the customer. +public enum MosaicRestoreAndSyncStage: Sendable, Equatable { + case providerRestoreStarted + case providerRestoreFinished(MosaicRestoreResult) + case authoritativeSyncStarted + case authoritativeSnapshotAccepted(snapshotVersion: Int64) + /// The native restore succeeded but Mosaic has not yet projected it. The + /// attempt count is bounded by the cross-platform poll budget. + case authoritativeValidationPending(attempts: Int) + case authoritativeSyncUnavailable(MosaicCustomerUnavailableReason) +} + +/// Mosaic's authoritative answer, on its own axis. +/// +/// `restored` is admissible only once an accepted snapshot reflects the restored +/// source. A successful native restore whose facts have not been validated yet +/// is `validationPending`, not `restored`: the accepted snapshot is the evidence +/// that makes the outcome authoritative rather than hopeful. +public enum MosaicRestoreAndSyncOutcome: Sendable, Equatable { + case restored(snapshotVersion: Int64) + case noAdditionalPurchases + case validationPending(attempts: Int) + case identityUnresolved + case providerUnavailable + case cancelled + case failed +} + +public struct MosaicRestoreAndSyncResult: Sendable, Equatable { + public let outcome: MosaicRestoreAndSyncOutcome + public let stages: [MosaicRestoreAndSyncStage] + /// What the native provider restore did, carried verbatim and never merged + /// into the authoritative outcome. + public let providerResult: MosaicRestoreResult + /// True only when an accepted snapshot reflects this restore. It is not a + /// restatement of `outcome`: a host uses it to decide whether to re-read + /// entitlements, and a hopeful `true` here is how a customer gets shown access + /// that then disappears. + public let authoritativeEntitlementsUpdated: Bool + public let snapshotVersion: Int64? + public let completedAt: Date +} + +/// Runs a restore across the provider and the authoritative sync surface. +struct MosaicCustomerRestoreCoordinator: Sendable { + let client: MosaicCustomerEntitlementClient + let clock: @Sendable () -> Date + /// Seconds. `Duration` would raise the package's iOS 15 deployment target. + let sleep: @Sendable (TimeInterval) async -> Void + + init( + client: MosaicCustomerEntitlementClient, + clock: @escaping @Sendable () -> Date = Date.init, + sleep: @escaping @Sendable (TimeInterval) async -> Void = { seconds in + try? await Task.sleep(nanoseconds: UInt64(max(0, seconds) * 1_000_000_000)) + } + ) { + self.client = client + self.clock = clock + self.sleep = sleep + } + + func run( + restore: @Sendable () async -> MosaicRestoreResult + ) async -> MosaicRestoreAndSyncResult { + var stages: [MosaicRestoreAndSyncStage] = [.providerRestoreStarted] + // The version to beat. An accepted snapshot only counts as reflecting this + // restore if it advanced past what was already known before it started. + let baseline = await client.snapshot()?.snapshot.snapshotVersion + + let providerResult = await restore() + stages.append(.providerRestoreFinished(providerResult)) + + switch providerResult { + case .cancelled: + return finish(.cancelled, stages, providerResult, updated: false, version: baseline) + case .providerUnavailable: + return finish( + .providerUnavailable, stages, providerResult, updated: false, version: baseline) + case .failed: + return finish(.failed, stages, providerResult, updated: false, version: baseline) + case .nothingToRestore, .restored: + break + } + + stages.append(.authoritativeSyncStarted) + // Bounded poll. Validation is asynchronous — the store confirms, Mosaic + // ingests, projects, and reissues — so the SDK waits briefly rather than + // either returning a stale answer or spinning indefinitely. + var attempts = 0 + var lastUnavailable: MosaicCustomerUnavailableReason? + let interval = + MosaicCustomerEntitlementPolicy.restorePollBudgetSeconds + / Double(max(MosaicCustomerEntitlementPolicy.restorePollAttempts, 1)) + + while attempts < MosaicCustomerEntitlementPolicy.restorePollAttempts { + attempts += 1 + switch await client.refresh() { + case .updated(let version), .unchanged(let version), .skippedFresh(let version), + .preserved(let version, _): + // A snapshot reflects this restore only if it advanced past what was + // already known. A first-ever snapshot (no baseline) is new by + // definition. + if baseline.map({ version > $0 }) ?? true { + stages.append(.authoritativeSnapshotAccepted(snapshotVersion: version)) + return finish( + .restored(snapshotVersion: version), stages, providerResult, updated: true, + version: version) + } + // Mosaic answered, but not yet with a snapshot that includes the + // restored source. + if attempts < MosaicCustomerEntitlementPolicy.restorePollAttempts { + await sleep(interval) + } + case .unavailable(let reason, _): + lastUnavailable = reason + if attempts < MosaicCustomerEntitlementPolicy.restorePollAttempts { + await sleep(interval) + } + case .signedOut: + // No customer means no authoritative answer is even possible. + stages.append(.authoritativeSyncUnavailable(.signedOut)) + return finish( + .identityUnresolved, stages, providerResult, updated: false, version: baseline) + } + } + + if let lastUnavailable { + stages.append(.authoritativeSyncUnavailable(lastUnavailable)) + let outcome: MosaicRestoreAndSyncOutcome = + lastUnavailable == .signedOut || lastUnavailable == .notAuthorized + ? .identityUnresolved : .validationPending(attempts: attempts) + return finish(outcome, stages, providerResult, updated: false, version: baseline) + } + + // The native restore found nothing and Mosaic agrees there is nothing new. + if case .nothingToRestore = providerResult { + return finish( + .noAdditionalPurchases, stages, providerResult, updated: false, version: baseline) + } + stages.append(.authoritativeValidationPending(attempts: attempts)) + return finish( + .validationPending(attempts: attempts), stages, providerResult, updated: false, + version: baseline) + } + + private func finish( + _ outcome: MosaicRestoreAndSyncOutcome, + _ stages: [MosaicRestoreAndSyncStage], + _ providerResult: MosaicRestoreResult, + updated: Bool, + version: Int64? + ) -> MosaicRestoreAndSyncResult { + MosaicRestoreAndSyncResult( + outcome: outcome, + stages: stages, + providerResult: providerResult, + authoritativeEntitlementsUpdated: updated, + snapshotVersion: version, + completedAt: clock()) + } +} diff --git a/sdk/ios/Sources/MosaicSDK/TransactionObservationRuntime.swift b/sdk/ios/Sources/MosaicSDK/TransactionObservationRuntime.swift index d0f2d667..e1c1d518 100644 --- a/sdk/ios/Sources/MosaicSDK/TransactionObservationRuntime.swift +++ b/sdk/ios/Sources/MosaicSDK/TransactionObservationRuntime.swift @@ -12,7 +12,11 @@ struct MosaicTransactionObservationHTTPResponse: Sendable { } protocol MosaicTransactionObservationTransport: Sendable { - func send(data: Data) async throws -> MosaicTransactionObservationHTTPResponse + /// `customerToken` is read fresh at send time and passed per call rather than + /// held by the transport, so a token can never outlive the request it was read + /// for and can never be captured into anything persistent. + func send(data: Data, customerToken: MosaicCustomerAccessToken?) async throws + -> MosaicTransactionObservationHTTPResponse } struct MosaicURLSessionTransactionObservationTransport: MosaicTransactionObservationTransport { @@ -32,12 +36,22 @@ struct MosaicURLSessionTransactionObservationTransport: MosaicTransactionObserva session = URLSession(configuration: configuration) } - func send(data: Data) async throws -> MosaicTransactionObservationHTTPResponse { + func send(data: Data, customerToken: MosaicCustomerAccessToken?) async throws + -> MosaicTransactionObservationHTTPResponse + { var request = URLRequest(url: endpoint, timeoutInterval: timeout) request.httpMethod = "POST" request.httpBody = data request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // Binds this purchase to the identified Billing Customer server-side. + // Without it the submission still succeeds and is validated; it just anchors + // anonymously, and the purchase has to be associated later by other + // evidence. Absent or expired is therefore not an error: the header is + // simply omitted. + if let customerToken { + request.setValue(customerToken.value, forHTTPHeaderField: "Mosaic-Customer-Token") + } let (responseData, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } return MosaicTransactionObservationHTTPResponse( @@ -148,6 +162,10 @@ actor MosaicTransactionObservationRuntime { private var state: MosaicTransactionObservationState? private var stateLoadTask: Task? private var flushTask: Task? + /// Read at flush time, never at enqueue time, and never written to the queue. + /// A queued observation can outlive many token generations, so binding one at + /// enqueue time would either persist a credential or attach a stale one. + private var customerTokenSource: (any MosaicCustomerTokenSource)? init( persistence: any MosaicTransactionObservationPersistence, @@ -163,6 +181,12 @@ actor MosaicTransactionObservationRuntime { self.jitter = jitter } + /// Attaches the source consulted for a customer token at send time. Passing + /// `nil` returns submissions to anonymous anchoring. + func attachCustomerTokenSource(_ source: (any MosaicCustomerTokenSource)?) { + customerTokenSource = source + } + /// Queues one observation and opportunistically attempts delivery. A /// submission identifier already present in the queue is dropped, so a /// replayed transaction can never enqueue twice. @@ -253,8 +277,12 @@ actor MosaicTransactionObservationRuntime { // An observation that cannot be encoded can never succeed. return (.permanentlyRejected(code: "observation_schema_invalid"), nil) } + // Read now, not when the observation was queued: a user who signs in + // between enqueue and flush gets their purchase bound correctly, and one who + // signs out does not get someone else's token attached. + let customerToken = await customerTokenSource?.heldCustomerToken() do { - let response = try await transport.send(data: body) + let response = try await transport.send(data: body, customerToken: customerToken) guard (200...202).contains(response.statusCode) else { return ( .retryableFailure(code: safeHTTPCode(response.statusCode)), response.retryAfterSeconds diff --git a/sdk/ios/StoreKit/Sources/MosaicStoreKit/MosaicStoreKitAcceptanceStore.swift b/sdk/ios/StoreKit/Sources/MosaicStoreKit/MosaicStoreKitAcceptanceStore.swift index 75d13d35..c484f34a 100644 --- a/sdk/ios/StoreKit/Sources/MosaicStoreKit/MosaicStoreKitAcceptanceStore.swift +++ b/sdk/ios/StoreKit/Sources/MosaicStoreKit/MosaicStoreKitAcceptanceStore.swift @@ -46,10 +46,22 @@ public actor MosaicStoreKitFileAcceptanceStore: MosaicStoreKitAcceptanceStore { public func insert(_ updateID: String) throws { guard accepted.insert(updateID).inserted else { return } + let directory = fileURL.deletingLastPathComponent() try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), + at: directory, withIntermediateDirectories: true ) + // Correctness, not housekeeping. This set is what stops one transaction + // being delivered to the host twice. Restored onto a second device from a + // backup it would suppress legitimate first-time delivery there, and + // restored onto a wiped device it would claim transactions the host has + // never actually seen. + var resource = URLResourceValues() + resource.isExcludedFromBackup = true + var mutableDirectory = directory + try? mutableDirectory.setResourceValues(resource) try JSONEncoder().encode(accepted).write(to: fileURL, options: .atomic) + var mutableFile = fileURL + try? mutableFile.setResourceValues(resource) } } diff --git a/sdk/ios/StoreKit/Sources/MosaicStoreKit/MosaicStoreKitProvider.swift b/sdk/ios/StoreKit/Sources/MosaicStoreKit/MosaicStoreKitProvider.swift index 49397fbd..701a2a17 100644 --- a/sdk/ios/StoreKit/Sources/MosaicStoreKit/MosaicStoreKitProvider.swift +++ b/sdk/ios/StoreKit/Sources/MosaicStoreKit/MosaicStoreKitProvider.swift @@ -305,12 +305,20 @@ public actor MosaicStoreKitProvider: ) do { try await client.synchronize() - switch await currentEntitlementKeys() { - case .success(let keys): + switch await currentEntitlements() { + case .success(let resolved): + // The restore gap this closes: without emitting here, a fresh device + // that restores a subscription submits nothing to Mosaic, so the + // purchase is never associated with the Billing Customer and the + // authoritative snapshot never learns the customer has access. The + // purchase path emits on acceptance; the restore path had no + // equivalent, because a restored transaction is usually already + // finished and so never appears in `Transaction.updates`. + observeRestored(resolved.transactions, operationID: operationID) completed = - keys.isEmpty + resolved.keys.isEmpty ? (.nothingToRestore, [], []) - : (.restored, Set(keys.map(MosaicEntitlement.init(id:))), []) + : (.restored, Set(resolved.keys.map(MosaicEntitlement.init(id:))), []) case .failure: let diagnostic = restoreFailureDiagnostic() completed = (.failed, [], [diagnostic]) @@ -507,10 +515,44 @@ public actor MosaicStoreKitProvider: observationSink.enqueue(observation) } + /// Emits one observation per restored, mapped transaction. + /// + /// Idempotence comes from the two layers that already provide it, so a + /// customer tapping Restore repeatedly cannot flood the queue or double-grant: + /// the submission identifier is the same `storekit_transaction_` the + /// purchase path uses, the observation queue drops a submission identifier it + /// already holds, and the server de-duplicates by transaction reference during + /// validation. + /// + /// The acceptance store is deliberately *not* written here. It records local + /// delivery to the host, and a restore is not a delivery; marking these + /// accepted would make a later genuine purchase of the same transaction skip + /// the acceptor. + private func observeRestored( + _ transactions: [StoreKitTransaction], operationID: String + ) { + for transaction in transactions { + observe( + transaction, + updateID: "storekit_transaction_\(transaction.id)", + operationID: operationID) + } + } + private func currentEntitlementKeys() async -> Result, Error> { + switch await currentEntitlements() { + case .success(let resolved): .success(resolved.keys) + case .failure(let error): .failure(error) + } + } + + private func currentEntitlements() async -> Result< + (keys: Set, transactions: [StoreKitTransaction]), Error + > { do { let events = try await client.currentEntitlements() var keys = Set() + var transactions: [StoreKitTransaction] = [] for event in events { guard case .verified(let transaction) = event else { return .failure(StoreKitProviderFailure.unverifiedEntitlement) @@ -519,8 +561,9 @@ public actor MosaicStoreKitProvider: continue } keys.formUnion(mapping.entitlementKeys) + transactions.append(transaction) } - return .success(keys) + return .success((keys, transactions)) } catch { return .failure(error) } diff --git a/sdk/ios/StoreKit/Tests/MosaicStoreKitTests/MosaicStoreKitProviderTests.swift b/sdk/ios/StoreKit/Tests/MosaicStoreKitTests/MosaicStoreKitProviderTests.swift index aaaea693..20546001 100644 --- a/sdk/ios/StoreKit/Tests/MosaicStoreKitTests/MosaicStoreKitProviderTests.swift +++ b/sdk/ios/StoreKit/Tests/MosaicStoreKitTests/MosaicStoreKitProviderTests.swift @@ -229,6 +229,105 @@ final class MosaicStoreKitProviderTests: XCTestCase { XCTAssertTrue(sink.captured().isEmpty) } + /// Phase 9B finding 1.7: the restore path emitted nothing. + /// + /// Risk: a customer reinstalls, taps Restore, and StoreKit hands back their + /// active subscription — but Mosaic never hears about it, so the purchase is + /// never associated with their Billing Customer and the authoritative + /// snapshot keeps reporting no access. A restored transaction is normally + /// already finished, so it never appears in `Transaction.updates` either; + /// without this emission there is no path at all. + func testRestoreEmitsObservationsForCurrentEntitlements() async throws { + let order = OrderRecorder() + let sink = ObservationSinkSpy() + let provider = MosaicStoreKitProvider( + client: StoreKitClientStub( + order: order, + purchase: .cancelled, + entitlements: [ + .verified( + .init( + id: 77, storeProductID: "com.example.pro.monthly", occurredAt: Date(), + environment: .production)) + ]), + acceptor: AcceptorStub(order: order), + acceptanceStore: AcceptanceStoreStub(order: order), + observationSink: sink) + try await provider.install(configuration: configuration, mappings: [mapping]) + + let result = await provider.restore(entitlementMappings: []) + + XCTAssertEqual(result, .restored([MosaicEntitlement(id: "pro")])) + let observations = sink.captured() + XCTAssertEqual(observations.count, 1) + // The raw decimal identifier Apple's transaction lookup accepts. + XCTAssertEqual(observations.first?.reference, "77") + // The same submission identifier the purchase path uses, which is what makes + // a restore of an already-observed purchase idempotent. + XCTAssertEqual(observations.first?.submissionID, "storekit_transaction_77") + XCTAssertEqual(observations.first?.referenceKind, .appStoreTransactionID) + } + + /// Risk: a customer tapping Restore repeatedly must not flood the queue or + /// double-grant. The submission identifier is stable across restores, which is + /// what both de-duplication layers key on. + func testRepeatedRestoresReuseTheSameSubmissionIdentifier() async throws { + let order = OrderRecorder() + let sink = ObservationSinkSpy() + let provider = MosaicStoreKitProvider( + client: StoreKitClientStub( + order: order, + purchase: .cancelled, + entitlements: [ + .verified( + .init( + id: 77, storeProductID: "com.example.pro.monthly", occurredAt: Date(), + environment: .production)) + ]), + acceptor: AcceptorStub(order: order), + acceptanceStore: AcceptanceStoreStub(order: order), + observationSink: sink) + try await provider.install(configuration: configuration, mappings: [mapping]) + + _ = await provider.restore(entitlementMappings: []) + _ = await provider.restore(entitlementMappings: []) + + let identifiers = Set(sink.captured().map(\.submissionID)) + XCTAssertEqual(identifiers, ["storekit_transaction_77"]) + // The restore path must not record local acceptance: a restore is not a + // delivery to the host, and marking it accepted would make a later genuine + // purchase of the same transaction skip the acceptor. + let events = await order.values() + XCTAssertFalse(events.contains { $0.hasPrefix("persist:") }) + } + + /// Risk: StoreKit Testing in Xcode produces no App Store record, so a restore + /// under it would submit guaranteed-rejection noise. + func testRestoreSuppressesXcodeTestingTransactions() async throws { + let order = OrderRecorder() + let sink = ObservationSinkSpy() + let provider = MosaicStoreKitProvider( + client: StoreKitClientStub( + order: order, + purchase: .cancelled, + entitlements: [ + .verified( + .init( + id: 78, storeProductID: "com.example.pro.monthly", occurredAt: Date(), + environment: .localTesting)) + ]), + acceptor: AcceptorStub(order: order), + acceptanceStore: AcceptanceStoreStub(order: order), + observationSink: sink) + try await provider.install(configuration: configuration, mappings: [mapping]) + + let result = await provider.restore(entitlementMappings: []) + + // The restore result itself is unchanged; only the observation is suppressed. + XCTAssertEqual(result, .restored([MosaicEntitlement(id: "pro")])) + XCTAssertTrue(sink.captured().isEmpty) + } + private var configuration: MosaicCommerceConfigurationReference { .init( configurationID: "commerce_configuration_storekit_42", diff --git a/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementCacheStoreTests.swift b/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementCacheStoreTests.swift new file mode 100644 index 00000000..5f243fed --- /dev/null +++ b/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementCacheStoreTests.swift @@ -0,0 +1,155 @@ +import Foundation +import XCTest + +@testable import MosaicSDK + +final class CustomerEntitlementCacheStoreTests: XCTestCase { + private var root: URL! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("mosaic-entitlement-cache-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + private func store(customer: String) throws -> MosaicCustomerEntitlementFileCacheStore { + try MosaicCustomerEntitlementFileCacheStore( + baseURL: URL(string: "https://api.example.com")!, + publicSDKKey: "pk_test", + customerBindingDigest: MosaicCustomerEntitlementFileCacheStore.bindingDigest( + userID: customer), + rootDirectory: root) + } + + private func record( + customer: String = "fixture-customer-0001", version: Int64 = 4, data: Data = Data("{}".utf8) + ) -> MosaicCustomerEntitlementCacheRecord { + MosaicCustomerEntitlementCacheRecord( + recordData: data, + billingCustomerID: customer, + projectID: "fixture-project-mosaic", + environmentID: "fixture-environment-production", + snapshotVersion: version, + issuedAt: Date(timeIntervalSince1970: 1_000), + asOf: Date(timeIntervalSince1970: 990), + refreshAfter: Date(timeIntervalSince1970: 4_600), + validUntil: Date(timeIntervalSince1970: 605_800), + staleGraceSeconds: 86_400, + entityTag: "cs-0001-v4", + storedAt: Date(timeIntervalSince1970: 1_000), + serverTime: nil, + localReceiptTime: nil, + systemUptime: nil, + checksum: MosaicCustomerEntitlementCacheRecord.checksum( + recordData: data, billingCustomerID: customer, projectID: "fixture-project-mosaic", + environmentID: "fixture-environment-production", snapshotVersion: version)) + } + + func testRoundTrip() async throws { + let store = try store(customer: "user-a") + let written = record() + try await store.save(written) + let loaded = try await store.load() + XCTAssertEqual(loaded, written) + } + + // Risk: an entitlement cache restored onto a second device from an iCloud + // backup carries one person's access into another device's session. Apple also + // rejects apps that back up regenerable caches. Both the directory and the + // file must carry the flag; setting it on only one is the common mistake. + func testCacheDirectoryAndFileAreExcludedFromBackup() async throws { + let store = try store(customer: "user-a") + try await store.save(record()) + + let directory = root.appendingPathComponent("MosaicSDK/entitlements-v1", isDirectory: true) + let files = try FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil + ).filter { $0.pathExtension == "json" } + XCTAssertEqual(files.count, 1) + + XCTAssertEqual( + try directory.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup, true, + "the entitlements directory must be excluded from backup") + XCTAssertEqual( + try files[0].resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup, true, + "the entitlement cache file must be excluded from backup") + } + + // Risk: two customers sharing a cache file is the entitlement leak the whole + // binding design exists to prevent. + func testEachCustomerGetsItsOwnFile() async throws { + try await store(customer: "user-a").save(record(customer: "customer-a")) + try await store(customer: "user-b").save(record(customer: "customer-b")) + + let loadedA = try await store(customer: "user-a").load() + let loadedB = try await store(customer: "user-b").load() + XCTAssertEqual(loadedA?.billingCustomerID, "customer-a") + XCTAssertEqual(loadedB?.billingCustomerID, "customer-b") + } + + // Risk: a device that has hosted several people should not keep every one of + // their billing states indefinitely. + func testPruningRetainsOnlyTheTwoMostRecentCustomers() async throws { + for name in ["user-a", "user-b", "user-c"] { + try await store(customer: name).save(record(customer: name)) + // Modification-time ordering needs to be observable on a coarse clock. + try await Task.sleep(for: .milliseconds(1_100)) + } + + let directory = root.appendingPathComponent("MosaicSDK/entitlements-v1", isDirectory: true) + let files = try FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil + ).filter { $0.pathExtension == "json" } + XCTAssertEqual(files.count, MosaicCustomerEntitlementFileCacheStore.retainedCustomerFiles) + + // The oldest customer is gone; the two most recent survive. + let oldest = try await store(customer: "user-a").load() + XCTAssertNil(oldest) + let newest = try await store(customer: "user-c").load() + XCTAssertEqual(newest?.billingCustomerID, "user-c") + } + + // Risk: a corrupt cache must never be read as customer state. It is discarded + // and reported as invalid, which resolves to `unknown` — never `inactive`. + func testCorruptFileIsRejectedRatherThanPartiallyRead() async throws { + let store = try store(customer: "user-a") + try await store.save(record()) + + let directory = root.appendingPathComponent("MosaicSDK/entitlements-v1", isDirectory: true) + let file = try FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil + ).first { $0.pathExtension == "json" }! + try Data("{\"formatVersion\":1,\"recordData\"".utf8).write(to: file) + + do { + _ = try await store.load() + XCTFail("a truncated cache file must not decode") + } catch {} + } + + // Risk: bit-rot or an edited file on a jailbroken device could otherwise + // promote a stale snapshot version, defeating the monotonicity gate. + func testTamperedChecksumIsRejected() async throws { + let store = try store(customer: "user-a") + var tampered = record() + tampered.snapshotVersion = 9_999 + try await store.save(tampered) + + do { + _ = try await store.load() + XCTFail("a record whose checksum does not cover its contents must not load") + } catch {} + } + + func testClearRemovesTheFile() async throws { + let store = try store(customer: "user-a") + try await store.save(record()) + try await store.clear() + let loaded = try await store.load() + XCTAssertNil(loaded) + } +} diff --git a/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementCodecTests.swift b/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementCodecTests.swift new file mode 100644 index 00000000..b519476f --- /dev/null +++ b/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementCodecTests.swift @@ -0,0 +1,300 @@ +import Foundation +import XCTest + +@testable import MosaicSDK + +final class CustomerEntitlementCodecTests: XCTestCase { + + // Risk: `contentDigest` is the binding and corruption check. If Swift's + // canonical form disagrees with the other implementations by one byte, every + // snapshot Mosaic issues is rejected on iOS and every customer sees `unknown`. + // Foundation's `sortedKeys` is case-insensitive and would fail this table, + // which is exactly why the serializer is hand-written. + func testCanonicalSerializationDigestVectorTable() throws { + let vectors = try entitlementVectorList("entitlement-snapshot-digest-vectors.json") + XCTAssertGreaterThanOrEqual(vectors.count, 8) + + for vector in vectors { + let id = vector["id"] as? String ?? "" + let payload = try XCTUnwrap(vector["payload"] as? [String: Any], "vector \(id)") + let serialized = try MosaicCustomerCanonicalJSON.data(payload) + + XCTAssertEqual( + String(data: serialized, encoding: .utf8), vector["canonicalSerialization"] as? String, + "canonical bytes for vector \(id)") + XCTAssertEqual( + serialized.count, vector["canonicalByteLength"] as? Int, "byte length for vector \(id)") + XCTAssertEqual( + try MosaicCustomerCanonicalJSON.digest(payload), vector["digest"] as? String, + "digest for vector \(id)") + } + } + + // Risk: a snapshot the server issues must decode here. Every canonical + // snapshot fixture is decoded and its digest verified, so a contract shape the + // SDK cannot read is caught in CI rather than in production. + func testEveryCanonicalSnapshotFixtureDecodesWithAValidDigest() throws { + let names = try authoritativeEntitlementFixtureNames(in: "snapshots") + XCTAssertGreaterThanOrEqual(names.count, 13) + + for name in names { + let data = try authoritativeEntitlementFixtureData("snapshots/\(name)") + let decoded = try MosaicCustomerEntitlementCodec.decode(data) + XCTAssertTrue(decoded.contentDigestValid, "digest for \(name)") + XCTAssertEqual(decoded.binding.contractVersion, "1", "binding for \(name)") + } + } + + // Risk: the never-projected answer is version zero, but it is not a snapshot + // of inactive access. Rejecting it leaves a newly identified customer with no + // cache; accepting any non-empty version-zero shape would let projected state + // masquerade as the placeholder reserved by the contract. + func testVersionZeroIsAcceptedOnlyAsTheNeverProjectedPlaceholder() throws { + let decoded = try MosaicCustomerEntitlementCodec.decode( + neverProjectedEntitlementPlaceholderData()) + guard case .snapshot(let snapshot) = decoded.record else { + return XCTFail("expected a snapshot") + } + XCTAssertTrue(decoded.contentDigestValid) + XCTAssertEqual(snapshot.snapshotVersion, 0) + XCTAssertEqual(snapshot.projectionStatus.state, .pending) + XCTAssertTrue(snapshot.entries.isEmpty) + XCTAssertTrue(snapshot.sources.isEmpty) + XCTAssertNil(snapshot.previousSnapshotVersion) + + let invalid = try authoritativeEntitlementSnapshotVariant { payload in + payload["snapshotVersion"] = 0 + } + XCTAssertThrowsError(try MosaicCustomerEntitlementCodec.decode(invalid)) { error in + XCTAssertEqual( + (error as? MosaicCustomerEntitlementDecodingError)?.diagnosticCode, + "entitlement_invalid_never_projected_placeholder") + } + } + + // Risk: version zero is admitted only on the full snapshot record where its + // pending/empty constraints can be checked. A zero-valued confirmation would + // slide freshness for a placeholder the contract says must be re-issued. + func testSnapshotUnchangedStillRejectsVersionZero() throws { + guard + var root = try JSONSerialization.jsonObject( + with: authoritativeEntitlementFixtureData("snapshots/snapshot-unchanged.json")) + as? [String: Any], + var payload = root["payload"] as? [String: Any] + else { throw CanonicalFixtureLookupError.invalidShape } + payload["snapshotVersion"] = 0 + root["payload"] = payload + + XCTAssertThrowsError( + try MosaicCustomerEntitlementCodec.decode(JSONSerialization.data(withJSONObject: root))) + } + + // Risk: the three behavioural fixtures encode product decisions that are easy + // to get wrong and expensive when wrong: telling a lifetime purchaser their + // access expires, and treating billing retry as if it granted access. + func testPermanentSourceReportsNoFiniteExpiry() throws { + let snapshot = try snapshotFixture("permanent-source-no-finite-expiry.json") + let entry = try XCTUnwrap(snapshot.entries.first) + XCTAssertEqual(entry.state, .active) + XCTAssertTrue(entry.endKnown, "a permanent Entitlement has a known end: there isn't one") + XCTAssertNil(entry.effectiveEnd, "a permanent Entitlement must not report an expiry") + } + + func testBillingRetryWithheldAccessIsNotActive() throws { + let snapshot = try snapshotFixture("billing-retry-access-withheld.json") + let entry = try XCTUnwrap(snapshot.entries.first) + XCTAssertNotEqual(entry.state, .active) + } + + func testMultipleActiveSourcesAreAllCarried() throws { + let snapshot = try snapshotFixture("multiple-active-sources.json") + let entry = try XCTUnwrap(snapshot.entries.first) + XCTAssertGreaterThan(entry.sourceCount, 1) + XCTAssertEqual(entry.sourceCount, entry.sourceIDs.count) + for id in entry.sourceIDs { XCTAssertNotNil(snapshot.source(id: id)) } + } + + // Risk: a Google license-tester purchase arrives as an ordinary production + // transaction and is distinguishable only by this flag. Losing it in the + // decoder makes a test grant indistinguishable from a paid one. + func testTestSourceFlagSurvivesToTheCheckResult() throws { + let snapshot = try snapshotFixture("test-source-sandbox-grant.json") + let key = try XCTUnwrap(snapshot.entries.first?.entitlementKey) + XCTAssertTrue(snapshot.check(key: key, cacheState: .fresh).isTestSource) + } + + // Risk: absence is not a statement Mosaic made. A snapshot can omit a key + // because the Project does not define it, because the projection could not + // resolve it, or because the request narrowed the response with + // `requestedEntitlementKeys`. Reading any of those as `inactive` would deny + // access Mosaic never denied — and would do it silently, because the snapshot + // itself looks perfectly healthy. + func testEntitlementKeyAbsentFromASnapshotReadsUnknownNeverInactive() throws { + let snapshot = try snapshotFixture("active-subscription.json") + XCTAssertNil(snapshot.entry(forKey: "pro_lifetime"), "the fixture carries only `pro`") + + let check = snapshot.check(key: "pro_lifetime", cacheState: .fresh) + + guard case .unknown(let uncertainty) = check.state else { + return XCTFail("an absent key must read unknown, got \(check.state)") + } + XCTAssertNotEqual(check.state, .inactive) + XCTAssertNotEqual( + uncertainty.reason, .none, "an unknown state must stay explainable") + // A present key on the same snapshot still answers definitely, so this is not + // a blanket downgrade of every answer. + XCTAssertEqual(snapshot.check(key: "pro", cacheState: .fresh).state, .active) + } + + // Risk: an entry that *is* present and says inactive is the one case where + // inactive is legitimate — Mosaic looked, found no qualifying source, and is + // confident. Losing this would make the state unreachable and every paywall + // decision fall back to unknown. + func testPresentInactiveEntryStillReadsInactive() throws { + let snapshot = try snapshotFixture("inactive-expired-subscription.json") + let key = try XCTUnwrap(snapshot.entries.first?.entitlementKey) + XCTAssertEqual(snapshot.check(key: key, cacheState: .fresh).state, .inactive) + } + + // Risk: the whole point of a closed reader. Each invalid fixture is a shape + // the SDK must refuse; accepting one means acting on a document the contract + // says is meaningless. + func testEveryInvalidSnapshotShapedFixtureIsRejected() throws { + // Fixtures for record types this SDK never reads on its sync surface + // (subscription snapshots, check results, restore results are server and + // backend surfaces). They are still rejected here, but by the record-type + // gate rather than by the rule each one is named for, so asserting the + // specific reason would be misleading. + let otherRecordTypes: Set = [ + "check-result-unknown-without-uncertainty.json", + "paused-on-apple-app-store.json", + "restore-restored-without-snapshot-version.json", + "revoked-subscription-missing-revocation-time.json", + "revoked-subscription-reports-active-access.json", + "subscription-carries-provider-status-string.json", + "subscription-checksum-mismatch.json", + "unknown-access-state-without-uncertainty.json", + ] + let names = try authoritativeEntitlementFixtureNames(in: "invalid") + .filter { $0 != "rejection-layers.json" } + XCTAssertGreaterThanOrEqual(names.count, 26) + + for name in names { + let data = try authoritativeEntitlementFixtureData("invalid/\(name)") + if otherRecordTypes.contains(name) { + // Still refused, just by the envelope gate. + XCTAssertThrowsError(try MosaicCustomerEntitlementCodec.decode(data), name) + continue + } + if name == "snapshot-carries-signed-payload-value.json" { + // A producer-side rule, not a reader obligation. The offending value is + // a correlationId shaped like a JWS; it satisfies the identifier + // pattern, so refusing it would mean the SDK guessing at whether an + // opaque handle "looks signed". The contract's reader sequence rejects + // unknown versions, record types, fields, and enumeration members — + // this is none of those, and the semantic validator that owns the rule + // runs against the producer. + XCTAssertNoThrow(try MosaicCustomerEntitlementCodec.decode(data), name) + continue + } + if name == "different-customer-rejected.json" + || name == "older-snapshot-version-rejected.json" + { + // Both are semantic rejections that the *acceptance gate* owns, not the + // decoder: one is a digest computed over a different customer, the other + // a version that regresses against a cached predecessor. They must + // decode so the gate can diagnose them precisely. + let decoded = try MosaicCustomerEntitlementCodec.decode(data) + if name == "different-customer-rejected.json" { + XCTAssertFalse( + decoded.contentDigestValid, + "a digest over a different billingCustomerId must not verify") + } + continue + } + XCTAssertThrowsError(try MosaicCustomerEntitlementCodec.decode(data), name) + } + } + + // Risk: keys are Project data. Rejecting an unrecognized one would make + // defining a new Entitlement a breaking change for every shipped SDK. + func testUnrecognizedEntitlementKeyIsAcceptedAsProjectData() throws { + let mutated = try mutatedSnapshot("active-subscription.json") { payload in + var entries = payload["entries"] as! [[String: Any]] + entries[0]["entitlementKey"] = "some_key_this_sdk_has_never_heard_of" + payload["entries"] = entries + } + let decoded = try MosaicCustomerEntitlementCodec.decode(mutated) + guard case .snapshot(let snapshot) = decoded.record else { return XCTFail("expected snapshot") } + XCTAssertEqual(snapshot.entries.first?.entitlementKey, "some_key_this_sdk_has_never_heard_of") + // The digest was recomputed over mutated bytes, so it must now fail: this + // also proves the digest actually covers entry content. + XCTAssertFalse(decoded.contentDigestValid) + } + + // Risk: an unknown enumeration member means the document was written by a + // producer this reader does not understand. Silently mapping it to a default + // would act on a state the SDK cannot reason about. + func testUnknownEnumerationMemberRejectsTheWholeRecord() throws { + let mutated = try mutatedSnapshot("active-subscription.json") { payload in + payload["changeReason"] = "some_future_reason" + } + XCTAssertThrowsError(try MosaicCustomerEntitlementCodec.decode(mutated)) + } + + func testUnknownFieldRejectsTheWholeRecord() throws { + let mutated = try mutatedSnapshot("active-subscription.json") { payload in + payload["somethingNew"] = "value" + } + XCTAssertThrowsError(try MosaicCustomerEntitlementCodec.decode(mutated)) + } + + // Risk: a device that accepted a 60-day offline horizon would serve access + // Mosaic never confirmed for two months. The bound is on the composition, not + // on either field alone, and it is enforced on the unchanged response too. + func testCombinedCacheHorizonIsBounded() throws { + let mutated = try mutatedSnapshot("bounded-offline-cache.json") { payload in + payload["validUntil"] = "2026-08-27T12:00:00.000Z" + payload["staleGraceSeconds"] = 2_592_000 + } + XCTAssertThrowsError(try MosaicCustomerEntitlementCodec.decode(mutated)) { error in + XCTAssertEqual( + (error as? MosaicCustomerEntitlementDecodingError)?.diagnosticCode, + "entitlement_cache_horizon_exceeds_maximum") + } + } + + func testSnapshotUnchangedDecodes() throws { + let decoded = try MosaicCustomerEntitlementCodec.decode( + try authoritativeEntitlementFixtureData("snapshots/snapshot-unchanged.json")) + guard case .unchanged(let confirmation) = decoded.record else { + return XCTFail("expected an unchanged confirmation") + } + XCTAssertEqual(confirmation.snapshotVersion, 4) + XCTAssertEqual(confirmation.billingCustomerID, "fixture-customer-0001") + } + + // MARK: Helpers + + private func snapshotFixture(_ name: String) throws -> MosaicCustomerEntitlementSnapshot { + let decoded = try MosaicCustomerEntitlementCodec.decode( + try authoritativeEntitlementFixtureData("snapshots/\(name)")) + guard case .snapshot(let snapshot) = decoded.record else { + throw CanonicalFixtureLookupError.invalidShape + } + return snapshot + } + + private func mutatedSnapshot( + _ name: String, _ mutation: (inout [String: Any]) -> Void + ) throws -> Data { + guard + var root = try JSONSerialization.jsonObject( + with: try authoritativeEntitlementFixtureData("snapshots/\(name)")) as? [String: Any], + var payload = root["payload"] as? [String: Any] + else { throw CanonicalFixtureLookupError.invalidShape } + mutation(&payload) + root["payload"] = payload + return try JSONSerialization.data(withJSONObject: root) + } +} diff --git a/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementSyncTests.swift b/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementSyncTests.swift new file mode 100644 index 00000000..10a362ca --- /dev/null +++ b/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementSyncTests.swift @@ -0,0 +1,640 @@ +import Foundation +import XCTest + +@testable import MosaicSDK + +/// Serves queued responses and records the headers each request carried. +private actor StubSyncTransport: MosaicEntitlementSyncTransport { + private var responses: [MosaicEntitlementSyncHTTPResponse] + private(set) var requests: [MosaicEntitlementSyncHTTPRequest] = [] + private let delay: Duration? + + init(_ responses: [MosaicEntitlementSyncHTTPResponse], delay: Duration? = nil) { + self.responses = responses + self.delay = delay + } + + var requestCount: Int { requests.count } + + func fetch(_ request: MosaicEntitlementSyncHTTPRequest) async throws + -> MosaicEntitlementSyncHTTPResponse + { + requests.append(request) + if let delay { try? await Task.sleep(for: delay) } + guard !responses.isEmpty else { throw URLError(.badServerResponse) } + return responses.count > 1 ? responses.removeFirst() : responses[0] + } +} + +private actor FailingSyncTransport: MosaicEntitlementSyncTransport { + private(set) var requestCount = 0 + func fetch(_: MosaicEntitlementSyncHTTPRequest) async throws + -> MosaicEntitlementSyncHTTPResponse + { + requestCount += 1 + throw URLError(.notConnectedToInternet) + } +} + +final class CustomerEntitlementSyncTests: XCTestCase { + private let baseURL = URL(string: "https://api.example.com")! + private let issuedAt = try! contractTimestamp("2026-07-28T12:00:00.000Z") + + private func snapshotData(_ name: String) throws -> Data { + try authoritativeEntitlementFixtureData("snapshots/\(name)") + } + + private func invalidData(_ name: String) throws -> Data { + try authoritativeEntitlementFixtureData("invalid/\(name)") + } + + private func mutatedSnapshotPayload( + _ name: String, _ mutation: (inout [String: Any]) -> Void + ) throws -> Data { + guard + var root = try JSONSerialization.jsonObject(with: try snapshotData(name)) + as? [String: Any], var payload = root["payload"] as? [String: Any] + else { throw CanonicalFixtureLookupError.invalidShape } + mutation(&payload) + root["payload"] = payload + return try JSONSerialization.data(withJSONObject: root) + } + + private func ok(_ data: Data, etag: String? = nil, serverDate: Date? = nil) + -> MosaicEntitlementSyncHTTPResponse + { + .init(statusCode: 200, data: data, etag: etag, serverDate: serverDate) + } + + private func makeClient( + transport: any MosaicEntitlementSyncTransport, + tokenProvider: any MosaicCustomerTokenProvider = MosaicStaticCustomerTokenProvider( + token: MosaicCustomerAccessToken("mcat_test")), + cache: MosaicCustomerEntitlementMemoryCacheStore = .init(), + broadcaster: MosaicCustomerEntitlementBroadcaster = .init(), + now: Date? = nil + ) -> (MosaicCustomerEntitlementClient, MosaicCustomerEntitlementMemoryCacheStore) { + let instant = now ?? issuedAt.addingTimeInterval(60) + let client = MosaicCustomerEntitlementClient( + publicSDKKey: "pk_test", + baseURL: baseURL, + requestTimeout: 5, + transport: transport, + tokenStore: MosaicCustomerTokenStore(provider: tokenProvider, clock: { instant }), + broadcaster: broadcaster, + bindingDigest: "digest-a", + cacheStoreFactory: { _ in cache }, + clock: { instant }) + return (client, cache) + } + + // MARK: Wire form + + // Risk: the public SDK key identifies the application and the customer token + // selects the customer; neither substitutes for the other. A request missing + // either header is refused, and a token in a query string ends up in access + // logs, proxy logs, and browser history. + func testRequestCarriesBothPinnedHeadersAndNoTokenInTheURL() async throws { + let transport = StubSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let (client, _) = makeClient(transport: transport) + + _ = await client.refresh() + + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.headers["Authorization"], "Bearer mcat_test") + XCTAssertEqual(request.headers["Mosaic-SDK-Key"], "pk_test") + XCTAssertEqual( + request.url.absoluteString, "https://api.example.com/v1/sdk/billing/entitlements") + XCTAssertFalse(request.url.absoluteString.contains("mcat_")) + } + + // Risk: contract negotiation lives in the request body, so all three SDKs POST + // the canonical `entitlementSyncRequest` envelope. A bodyless GET would ship a + // version-negotiating client that never states which versions it can read. + func testRequestBodyIsTheCanonicalSyncRequestEnvelope() async throws { + let transport = StubSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let (client, _) = makeClient(transport: transport) + + _ = await client.refresh() + + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.headers["Content-Type"], "application/json") + let envelope = try XCTUnwrap( + JSONSerialization.jsonObject(with: request.body) as? [String: Any]) + XCTAssertEqual(envelope["authoritativeEntitlementContractVersion"] as? String, "1") + XCTAssertEqual(envelope["recordType"] as? String, "entitlementSyncRequest") + let payload = try XCTUnwrap(envelope["payload"] as? [String: Any]) + XCTAssertEqual( + payload["supportedAuthoritativeEntitlementContracts"] as? [String], ["1"]) + XCTAssertNotNil(payload["correlationId"] as? String) + // Nothing already known on a first sync, so neither conditional member is sent. + XCTAssertNil(payload["knownSnapshotVersion"]) + XCTAssertNil(payload["entityTag"]) + // The customer is selected by the token alone. Asserting an identifier could + // only narrow or fail the request, so it is never sent. + XCTAssertNil(payload["billingCustomerId"]) + } + + // Risk: without these the server can never answer `snapshotUnchanged`, and + // every refresh re-sends a snapshot the device already holds. + func testConditionalRequestBodyCarriesTheKnownVersionAndEntityTag() async throws { + let transport = StubSyncTransport([ + ok(try snapshotData("active-subscription.json")), + ok(try snapshotData("newer-snapshot.json")), + ]) + let (client, _) = makeClient(transport: transport) + _ = await client.refresh() + _ = await client.refresh() + + let requests = await transport.requests + let envelope = try XCTUnwrap( + JSONSerialization.jsonObject(with: requests[1].body) as? [String: Any]) + let payload = try XCTUnwrap(envelope["payload"] as? [String: Any]) + XCTAssertEqual(payload["knownSnapshotVersion"] as? Int, 4) + XCTAssertEqual(payload["entityTag"] as? String, "cs-0001-v4") + } + + // Risk: a customer whose first projection has not run must still get a + // validated, cacheable unknown answer. Treating zero as "no cache" omits it + // from the next request and can strand the client on the placeholder; treating + // it as invalid prevents the ordinary 1 > 0 monotonic replacement. + func testNeverProjectedPlaceholderIsCachedSentAndReplacedByVersionOne() async throws { + let placeholder = try neverProjectedEntitlementPlaceholderData() + let firstProjection = try authoritativeEntitlementSnapshotVariant { payload in + payload["snapshotVersion"] = 1 + payload["previousSnapshotVersion"] = 0 + payload["entityTag"] = "cs-0001-v1" + } + let transport = StubSyncTransport([ok(placeholder), ok(firstProjection)]) + let (client, cache) = makeClient(transport: transport) + + let placeholderRefresh = await client.refresh() + let cachedPlaceholder = await client.snapshot() + let placeholderSaveCount = await cache.saveCount + XCTAssertEqual(placeholderRefresh, .updated(snapshotVersion: 0)) + XCTAssertEqual(cachedPlaceholder?.snapshot.snapshotVersion, 0) + XCTAssertEqual(placeholderSaveCount, 1, "the placeholder is an ordinary cached snapshot") + let placeholderCheck = await client.check(key: "pro") + XCTAssertNotEqual(placeholderCheck.state, .inactive) + + let firstProjectionRefresh = await client.refresh() + XCTAssertEqual(firstProjectionRefresh, .updated(snapshotVersion: 1)) + let requests = await transport.requests + let envelope = try XCTUnwrap( + JSONSerialization.jsonObject(with: requests[1].body) as? [String: Any]) + let payload = try XCTUnwrap(envelope["payload"] as? [String: Any]) + XCTAssertEqual(payload["knownSnapshotVersion"] as? Int, 0) + XCTAssertEqual(payload["entityTag"] as? String, "pending-cs-0001-v0") + let current = await client.snapshot() + let projectedCheck = await client.check(key: "pro") + let replacementSaveCount = await cache.saveCount + XCTAssertEqual(current?.snapshot.snapshotVersion, 1) + XCTAssertEqual(projectedCheck.state, .active) + XCTAssertEqual(replacementSaveCount, 2, "version one replaces the placeholder atomically") + } + + // Risk: the contract-pinned unchanged path. A `snapshotUnchanged` record slides + // the freshness window so a confirmed-current snapshot does not expire merely + // because it was confirmed instead of resent. + func testSnapshotUnchangedRecordSlidesFreshness() async throws { + let transport = StubSyncTransport([ + ok(try snapshotData("active-subscription.json")), + ok(try snapshotData("snapshot-unchanged.json")), + ]) + // Past the original refreshAfter of 13:00Z but inside validity. + let later = try contractTimestamp("2026-07-28T13:30:00.000Z") + let (client, _) = makeClient(transport: transport, now: later) + + _ = await client.refresh() + let before = await client.cacheState() + XCTAssertEqual(before, .refreshRecommended) + + let result = await client.refresh() + XCTAssertEqual(result, .unchanged(snapshotVersion: 4)) + // The confirmation carries refreshAfter 13:45Z, which is now in the future. + let after = await client.cacheState() + XCTAssertEqual(after, .fresh) + let check = await client.check(key: "pro") + XCTAssertEqual(check.state, .active) + } + + // Risk: a bare 304 has no body, so there is no contract-pinned carrier for a + // refreshed window. It must preserve the cache without silently extending the + // offline horizon on wire names no contract owns. + func testBare304PreservesTheCacheWithoutSlidingFreshness() async throws { + let transport = StubSyncTransport([ + ok(try snapshotData("active-subscription.json")), + .init(statusCode: 304), + ]) + let later = try contractTimestamp("2026-07-28T13:30:00.000Z") + let (client, _) = makeClient(transport: transport, now: later) + _ = await client.refresh() + + let result = await client.refresh() + XCTAssertEqual(result, .unchanged(snapshotVersion: 4)) + let state = await client.cacheState() + XCTAssertEqual(state, .refreshRecommended, "a bare 304 must not extend the window") + let check = await client.check(key: "pro") + XCTAssertEqual(check.state, .active, "but the cache still stands") + } + + func testConditionalRequestSendsTheCachedEntityTag() async throws { + let transport = StubSyncTransport([ + ok(try snapshotData("active-subscription.json")), + ok(try snapshotData("newer-snapshot.json")), + ]) + let (client, _) = makeClient(transport: transport) + + _ = await client.refresh() + _ = await client.refresh() + + let requests = await transport.requests + XCTAssertNil(requests[0].headers["If-None-Match"]) + XCTAssertEqual(requests[1].headers["If-None-Match"], "\"cs-0001-v4\"") + } + + // MARK: Acceptance + + func testAcceptedSnapshotIsCachedAndAnswersAChecK() async throws { + let transport = StubSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let (client, cache) = makeClient(transport: transport) + + let result = await client.refresh() + XCTAssertEqual(result, .updated(snapshotVersion: 4)) + + let check = await client.check(key: "pro") + XCTAssertEqual(check.state, .active) + XCTAssertEqual(check.snapshotVersion, 4) + XCTAssertFalse(check.isStale) + + let saves = await cache.saveCount + XCTAssertEqual(saves, 1) + } + + // Risk: a late or replayed response must not roll state backwards. This is + // the monotonicity guarantee the whole cache design rests on. + func testOlderSnapshotIsRejectedAndTheCacheIsPreserved() async throws { + let transport = StubSyncTransport([ + ok(try snapshotData("newer-snapshot.json")), + ok(try invalidData("older-snapshot-version-rejected.json")), + ]) + // `newer-snapshot` is issued at 14:00Z, so the device clock has to sit + // inside its validity window for the cache to be servable at all. + let (client, _) = makeClient( + transport: transport, now: try contractTimestamp("2026-07-28T14:30:00.000Z")) + + let firstRefresh = await client.refresh() + XCTAssertEqual(firstRefresh, .updated(snapshotVersion: 14)) + guard case .preserved(let version, let diagnostic) = await client.refresh() else { + return XCTFail("an older snapshot must preserve the cache") + } + XCTAssertEqual(version, 14, "the newer accepted state must still stand") + XCTAssertEqual(diagnostic.code, "entitlement_snapshot_snapshot_version_not_newer") + + let check = await client.check(key: "pro") + XCTAssertEqual(check.snapshotVersion, 14) + } + + // Risk: equal is not newer. Re-accepting would make "accepted" stop meaning + // "the state advanced", which the restore flow depends on. + func testEqualVersionIsNotAccepted() async throws { + let transport = StubSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let (client, _) = makeClient(transport: transport) + + let firstRefresh = await client.refresh() + XCTAssertEqual(firstRefresh, .updated(snapshotVersion: 4)) + guard case .preserved = await client.refresh() else { + return XCTFail("an identical version must not be re-accepted") + } + } + + // Risk: this is the entitlement leak the binding rule exists to prevent. + // A digest computed over a different billingCustomerId must clear the cache, + // not preserve it, and must be observable so a host can react. + func testBindingMismatchClearsTheCacheAndEmitsCleared() async throws { + let broadcaster = MosaicCustomerEntitlementBroadcaster() + // The canonical `different-customer-rejected.json` carries a digest computed + // over another customer, so a reader observes it as a digest mismatch (the + // codec tests assert exactly that). The binding mismatch this test is about + // is a payload that *names* another customer, which the acceptance order + // catches before it ever reaches the digest step. + let otherCustomer = try mutatedSnapshotPayload("active-subscription.json") { payload in + payload["billingCustomerId"] = "fixture-customer-0002" + payload["snapshotVersion"] = 9 + } + let transport = StubSyncTransport([ + ok(try snapshotData("active-subscription.json")), + ok(otherCustomer), + ]) + let (client, cache) = makeClient(transport: transport, broadcaster: broadcaster) + + _ = await client.refresh() + let stream = await broadcaster.updates() + var iterator = stream.makeAsyncIterator() + _ = await iterator.next() // replayed current snapshot + + _ = await client.refresh() + + let cleared = await cache.clearCount + XCTAssertGreaterThanOrEqual(cleared, 1, "a binding mismatch must clear the cache") + let state = await client.cacheState() + XCTAssertEqual(state, .differentCustomer) + + let check = await client.check(key: "pro") + if case .unavailable = check.state { + } else { + XCTFail("a cleared cache must not keep answering active") + } + XCTAssertNotEqual(check.state, .inactive, "never inactive") + } + + // Risk: a corrupted or tampered payload must be discarded whole. Partial + // acceptance is forbidden: a reader never keeps the entries it understood + // from a document it rejected. + func testDigestMismatchPreservesTheCacheAndNeverEmits() async throws { + let broadcaster = MosaicCustomerEntitlementBroadcaster() + var mutated = + try JSONSerialization.jsonObject( + with: try snapshotData("newer-snapshot.json")) as! [String: Any] + var payload = mutated["payload"] as! [String: Any] + var entries = payload["entries"] as! [[String: Any]] + entries[0]["state"] = "inactive" + payload["entries"] = entries + mutated["payload"] = payload + + let transport = StubSyncTransport([ + ok(try snapshotData("active-subscription.json")), + ok(try JSONSerialization.data(withJSONObject: mutated)), + ]) + let (client, _) = makeClient(transport: transport, broadcaster: broadcaster) + _ = await client.refresh() + + let result = await client.refresh() + if case .preserved = result { + } else { + XCTFail("a tampered payload must preserve the previously accepted snapshot") + } + let check = await client.check(key: "pro") + XCTAssertEqual(check.state, .active, "the last good snapshot still stands") + XCTAssertEqual(check.snapshotVersion, 4) + } + + // MARK: 304 + + // MARK: Failure behaviour + + // Risk: the single most important behaviour in the whole contract. A network + // failure is not a cancelled subscription. + func testNetworkFailurePreservesAccessAndNeverReportsInactive() async throws { + let cache = MosaicCustomerEntitlementMemoryCacheStore() + let good = StubSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let (primed, _) = makeClient(transport: good, cache: cache) + _ = await primed.refresh() + + let failing = FailingSyncTransport() + let (client, _) = makeClient(transport: failing, cache: cache) + await client.bootstrap() + + let result = await client.refresh() + guard case .preserved = result else { + return XCTFail("an offline device inside validity keeps its access") + } + let check = await client.check(key: "pro") + XCTAssertEqual(check.state, .active) + XCTAssertNotEqual(check.state, .inactive) + } + + // Risk: past the bounded-grace window the cache's age can no longer support an + // answer. It must degrade to unavailable, never to inactive. + func testExpiredCacheReportsUnavailableNeverInactive() async throws { + let cache = MosaicCustomerEntitlementMemoryCacheStore() + let good = StubSyncTransport([ok(try snapshotData("bounded-offline-cache.json"))]) + let (primed, _) = makeClient(transport: good, cache: cache) + _ = await primed.refresh() + + // validUntil 2026-08-04T12:00Z plus a 24 h grace window, well past. + let farFuture = try contractTimestamp("2026-09-01T12:00:00.000Z") + let (client, _) = makeClient( + transport: FailingSyncTransport(), cache: cache, now: farFuture) + await client.bootstrap() + + let state = await client.cacheState() + XCTAssertEqual(state, .expired) + let check = await client.check(key: "pro") + XCTAssertEqual(check.state, .unavailable(reason: .cacheExpired)) + XCTAssertNotEqual(check.state, .inactive) + } + + // Risk: inside the grace band a previously active Entitlement stays active but + // must be surfaced as stale, so a host can tell the difference between + // confirmed and merely remembered access. + func testStaleWithinGraceKeepsAccessAndMarksItStale() async throws { + let cache = MosaicCustomerEntitlementMemoryCacheStore() + let good = StubSyncTransport([ok(try snapshotData("bounded-offline-cache.json"))]) + let (primed, _) = makeClient(transport: good, cache: cache) + _ = await primed.refresh() + + let inGrace = try contractTimestamp("2026-08-04T18:00:00.000Z") + let (client, _) = makeClient(transport: FailingSyncTransport(), cache: cache, now: inGrace) + await client.bootstrap() + + let check = await client.check(key: "pro") + XCTAssertEqual(check.state, .active) + XCTAssertTrue(check.isStale) + } + + // Risk: billing disabled for an Environment is a service state, not a + // customer state. Reporting it as inactive would tell every customer of that + // Environment their subscription ended. + func testBillingDisabledIsUnavailableNeverInactive() async { + let transport = StubSyncTransport([.init(statusCode: 409)]) + let (client, _) = makeClient(transport: transport) + + guard case .unavailable(let reason, _) = await client.refresh() else { + return XCTFail("expected unavailable") + } + XCTAssertEqual(reason, .billingDisabled) + let check = await client.check(key: "pro") + XCTAssertNotEqual(check.state, .inactive) + } + + // MARK: Authorization + + // Risk: exactly one forced refresh per generation. The second refusal on a + // freshly minted token is a real failure; retrying forever turns an outage + // into a request storm. + func testOneRetryOnUnauthorizedThenUnavailable() async throws { + let transport = StubSyncTransport([ + .init(statusCode: 401), .init(statusCode: 401), .init(statusCode: 401), + ]) + let provider = MosaicClosureCustomerTokenProvider { _ in + .token(MosaicCustomerAccessToken("mcat_test")) + } + let (client, _) = makeClient(transport: transport, tokenProvider: provider) + + let result = await client.refresh() + guard case .unavailable(let reason, _) = result else { + return XCTFail("expected unavailable after the retry budget") + } + XCTAssertEqual(reason, .notAuthorized) + let count = await transport.requestCount + XCTAssertEqual(count, 2, "the original request plus exactly one retry") + } + + func testUnauthorizedRetrySucceeds() async throws { + let transport = StubSyncTransport([ + .init(statusCode: 401), + ok(try snapshotData("active-subscription.json")), + ]) + let provider = MosaicClosureCustomerTokenProvider { _ in + .token(MosaicCustomerAccessToken("mcat_test")) + } + let (client, _) = makeClient(transport: transport, tokenProvider: provider) + + let result = await client.refresh() + XCTAssertEqual(result, .updated(snapshotVersion: 4)) + } + + // Risk: a signed-out user must never be served the previous session's grants. + func testSignedOutProviderYieldsSignedOutNotInactive() async { + let transport = StubSyncTransport([.init(statusCode: 200)]) + let (client, _) = makeClient( + transport: transport, tokenProvider: MosaicStaticCustomerTokenProvider(result: .signedOut)) + + let result = await client.refresh() + XCTAssertEqual(result, .signedOut) + let check = await client.check(key: "pro") + XCTAssertEqual(check.state, .unavailable(reason: .signedOut)) + } + + // MARK: Concurrency and identity + + func testConcurrentRefreshesMakeOneRequest() async throws { + let transport = StubSyncTransport( + [ok(try snapshotData("active-subscription.json"))], delay: .milliseconds(40)) + let (client, _) = makeClient(transport: transport) + + async let first = client.refresh() + async let second = client.refresh() + async let third = client.refresh() + let results = await [first, second, third] + + XCTAssertEqual(results, Array(repeating: .updated(snapshotVersion: 4), count: 3)) + let count = await transport.requestCount + XCTAssertEqual(count, 1) + } + + // Risk: a response that lands after an identity change would attach the + // previous customer's access to the new session. This is the leak test for the + // in-flight path specifically. + func testResponseArrivingAfterIdentityChangeIsDiscarded() async throws { + let transport = StubSyncTransport( + [ok(try snapshotData("active-subscription.json"))], delay: .milliseconds(80)) + let (client, _) = makeClient(transport: transport) + + let pending = Task { await client.refresh() } + try await Task.sleep(for: .milliseconds(10)) + await client.identityChanged(bindingDigest: "digest-b", signedOut: false) + _ = await pending.value + + let snapshot = await client.snapshot() + XCTAssertNil(snapshot, "a snapshot for the previous identity must not survive the change") + let check = await client.check(key: "pro") + XCTAssertNotEqual(check.state, .active) + } + + func testIdentityChangeEmitsClearedAndSwapsTheCacheNamespace() async throws { + let broadcaster = MosaicCustomerEntitlementBroadcaster() + let transport = StubSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let (client, _) = makeClient(transport: transport, broadcaster: broadcaster) + _ = await client.refresh() + + let stream = await broadcaster.updates() + var iterator = stream.makeAsyncIterator() + guard case .snapshot = await iterator.next() else { + return XCTFail("a new subscriber must be replayed the current snapshot") + } + + await client.identityChanged(bindingDigest: "digest-b", signedOut: false) + let next = await iterator.next() + XCTAssertEqual(next, .cleared(.identityChanged)) + } + + func testClearCustomerStateRemovesTheCache() async throws { + let transport = StubSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let (client, cache) = makeClient(transport: transport) + _ = await client.refresh() + + await client.clearCustomerState() + + let cleared = await cache.clearCount + XCTAssertEqual(cleared, 1) + let snapshot = await client.snapshot() + XCTAssertNil(snapshot) + } + + // MARK: Bootstrap + + // Risk: a launch must have an answer before the first network round trip, or + // every cold start shows a paying customer a paywall. + func testBootstrapServesTheCachedSnapshotWithoutTheNetwork() async throws { + let cache = MosaicCustomerEntitlementMemoryCacheStore() + let good = StubSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let (primed, _) = makeClient(transport: good, cache: cache) + _ = await primed.refresh() + + let offline = FailingSyncTransport() + let (client, _) = makeClient(transport: offline, cache: cache) + await client.bootstrap() + + let check = await client.check(key: "pro") + XCTAssertEqual(check.state, .active) + let requests = await offline.requestCount + XCTAssertEqual(requests, 0) + } + + // MARK: Observation + + // Risk: a rejected snapshot that emitted would tell observers the state + // advanced when it did not. + func testRejectedSnapshotsNeverEmit() async throws { + let broadcaster = MosaicCustomerEntitlementBroadcaster() + let transport = StubSyncTransport([ + ok(try snapshotData("newer-snapshot.json")), + ok(try invalidData("older-snapshot-version-rejected.json")), + ]) + let (client, _) = makeClient( + transport: transport, broadcaster: broadcaster, + now: try contractTimestamp("2026-07-28T14:30:00.000Z")) + _ = await client.refresh() + _ = await client.refresh() + + let current = await broadcaster.currentUpdate + guard case .snapshot(let update) = current else { + return XCTFail("the last emission must be the accepted snapshot") + } + XCTAssertEqual(update.snapshot.snapshotVersion, 14) + } + + func testEveryObserverSeesTheSameChange() async throws { + let broadcaster = MosaicCustomerEntitlementBroadcaster() + var firstIterator = await broadcaster.updates().makeAsyncIterator() + var secondIterator = await broadcaster.updates().makeAsyncIterator() + + let transport = StubSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let (client, _) = makeClient(transport: transport, broadcaster: broadcaster) + _ = await client.refresh() + + // With nothing cached, both streams see `loading` and then the accepted + // snapshot — and both see the same sequence, which is the fan-out property + // a single-consumer AsyncStream cannot provide on its own. + let firstLoading = await firstIterator.next() + let secondLoading = await secondIterator.next() + XCTAssertEqual(firstLoading, .loading) + XCTAssertEqual(secondLoading, .loading) + guard case .snapshot = await firstIterator.next(), case .snapshot = await secondIterator.next() + else { return XCTFail("both observers must receive the accepted snapshot") } + } +} diff --git a/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementTests.swift b/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementTests.swift new file mode 100644 index 00000000..a7f82c80 --- /dev/null +++ b/sdk/ios/Tests/MosaicSDKTests/CustomerEntitlementTests.swift @@ -0,0 +1,117 @@ +import Foundation +import XCTest + +@testable import MosaicSDK + +// Conformance to the shared cross-implementation reference vectors. +// +// These two tables are the contract between Swift, Kotlin, Dart, and Go. Every +// case below is driven by the file in `packages/test-fixtures/src/`, so a +// disagreement about offline access or cache acceptance fails here rather than +// on a paying customer's device. +final class CustomerEntitlementVectorTests: XCTestCase { + + // Risk: an offline-access policy that disagrees across platforms grants a + // customer access on one device and withholds it on another, and a naive + // clock comparison hands unlimited offline access to anyone who moves their + // device time backwards. + func testFreshnessVectorTable() throws { + let root = try entitlementReferenceVectors("entitlement-freshness-vectors.json") + XCTAssertEqual(root["contractVersion"] as? String, "1") + let vectors = try entitlementVectorList("entitlement-freshness-vectors.json") + XCTAssertGreaterThanOrEqual(vectors.count, 12) + + for vector in vectors { + let id = vector["id"] as? String ?? "" + guard let snapshot = vector["snapshot"] as? [String: Any], + let expected = vector["state"] as? String, + let deviceNow = vector["deviceNow"] as? String, + let tolerance = vector["clockSkewToleranceSeconds"] as? Int + else { return XCTFail("malformed freshness vector \(id)") } + + let state = MosaicCustomerEntitlementFreshness.evaluate( + issuedAt: try contractTimestamp(XCTUnwrap(snapshot["issuedAt"] as? String)), + refreshAfter: try contractTimestamp(XCTUnwrap(snapshot["refreshAfter"] as? String)), + validUntil: try contractTimestamp(XCTUnwrap(snapshot["validUntil"] as? String)), + staleGraceSeconds: snapshot["staleGraceSeconds"] as? Int ?? 0, + deviceNow: try contractTimestamp(deviceNow), + tolerance: TimeInterval(tolerance)) + + XCTAssertEqual(vectorName(for: state), expected, "freshness vector \(id)") + } + } + + // Risk: the acceptance order is normative. Checking version before binding, or + // binding after monotonicity, either preserves one customer's cache under + // another customer's identity or misdiagnoses a legitimate per-Environment + // version restart as a rollback attack. + func testCacheDecisionVectorTable() throws { + let root = try entitlementReferenceVectors("entitlement-cache-decision-vectors.json") + XCTAssertEqual( + root["evaluationOrder"] as? [String], + [ + "unsupportedContractVersion", "customerBindingMismatch", "contentDigestMismatch", + "snapshotVersionNotNewer", "asOfRegression", "accept", + ]) + let vectors = try entitlementVectorList("entitlement-cache-decision-vectors.json") + XCTAssertGreaterThanOrEqual(vectors.count, 10) + + for vector in vectors { + let id = vector["id"] as? String ?? "" + guard let incoming = vector["incoming"] as? [String: Any], + let expectedDecision = vector["decision"] as? String, + let expectedReason = vector["reason"] as? String, + let expectedAction = vector["cacheAction"] as? String + else { return XCTFail("malformed cache-decision vector \(id)") } + + let acceptance = MosaicCustomerEntitlementCacheDecision.evaluate( + cached: try binding(vector["cached"] as? [String: Any]), + incoming: try XCTUnwrap(binding(incoming))) + + switch acceptance { + case .accepted(let reason): + XCTAssertEqual(expectedDecision, "accept", "vector \(id)") + XCTAssertEqual(reason.rawValue, expectedReason, "vector \(id)") + case .rejected(let reason): + XCTAssertEqual(expectedDecision, "reject", "vector \(id)") + XCTAssertEqual(reason.rawValue, expectedReason, "vector \(id)") + } + XCTAssertEqual(acceptance.cacheAction.rawValue, expectedAction, "vector \(id)") + } + } + + // Risk: the vector file states the rule the whole contract exists to protect. + // A refactor that let any rejection resolve to `inactive` would turn a Mosaic + // outage into a mass revocation experienced by paying customers. + func testNoRejectionEverResolvesToInactive() throws { + for vector in try entitlementVectorList("entitlement-cache-decision-vectors.json") { + guard vector["decision"] as? String == "reject" else { continue } + let resulting = vector["resultingAccessState"] as? String + XCTAssertNotEqual(resulting, "inactive", "vector \(vector["id"] as? String ?? "")") + } + } + + private func binding(_ value: [String: Any]?) throws -> MosaicCustomerSnapshotBinding? { + guard let value else { return nil } + return MosaicCustomerSnapshotBinding( + contractVersion: try XCTUnwrap(value["contractVersion"] as? String), + billingCustomerID: try XCTUnwrap(value["billingCustomerId"] as? String), + projectID: try XCTUnwrap(value["projectId"] as? String), + environmentID: try XCTUnwrap(value["environmentId"] as? String), + snapshotVersion: Int64(try XCTUnwrap(value["snapshotVersion"] as? Int)), + asOf: try contractTimestamp(XCTUnwrap(value["asOf"] as? String)), + contentDigestValid: try XCTUnwrap(value["contentDigestValid"] as? Bool)) + } + + private func vectorName(for state: MosaicCustomerEntitlementCacheState) -> String { + switch state { + case .fresh: "fresh" + case .refreshRecommended: "refresh_recommended" + case .staleWithinGrace: "stale_within_grace" + case .expired: "expired" + case .missing: "missing" + case .invalid: "invalid" + case .differentCustomer: "different_customer" + } + } +} diff --git a/sdk/ios/Tests/MosaicSDKTests/CustomerTokenStoreTests.swift b/sdk/ios/Tests/MosaicSDKTests/CustomerTokenStoreTests.swift new file mode 100644 index 00000000..f66f229b --- /dev/null +++ b/sdk/ios/Tests/MosaicSDKTests/CustomerTokenStoreTests.swift @@ -0,0 +1,175 @@ +import Foundation +import XCTest + +@testable import MosaicSDK + +/// Counts calls so single-flight and retry-budget behaviour is observable. +private actor RecordingTokenProvider: MosaicCustomerTokenProvider { + private var results: [MosaicCustomerTokenResult] + private(set) var calls: [Bool] = [] + private let delay: Duration? + + init(results: [MosaicCustomerTokenResult], delay: Duration? = nil) { + self.results = results + self.delay = delay + } + + var callCount: Int { calls.count } + var forcedCallCount: Int { calls.filter { $0 }.count } + + func customerAccessToken(forceRefresh: Bool) async -> MosaicCustomerTokenResult { + calls.append(forceRefresh) + if let delay { try? await Task.sleep(for: delay) } + return results.count > 1 ? results.removeFirst() : (results.first ?? .unavailable) + } +} + +final class CustomerTokenStoreTests: XCTestCase { + + private func token(_ value: String) -> MosaicCustomerTokenResult { + .token(MosaicCustomerAccessToken(value)) + } + + // Risk: a token in a log, a crash report, or a telemetry payload is a leaked + // credential for someone's billing state. The type is the only thing standing + // between an ordinary string interpolation and that outcome. + func testTokenNeverPrintsItsValue() { + let token = MosaicCustomerAccessToken("mcat_thisisasecretvalue") + XCTAssertFalse("\(token)".contains("thisisasecret")) + XCTAssertFalse(String(reflecting: token).contains("thisisasecret")) + XCTAssertFalse(String(describing: token).contains("thisisasecret")) + } + + // Risk: ten screens asking for entitlements at launch must produce one call + // into the host's backend, not ten. A token endpoint is authenticated and + // rate-limited; a thundering herd from every app launch is a real outage mode. + func testConcurrentRequestsMakeOneProviderCall() async { + let provider = RecordingTokenProvider(results: [token("a")], delay: .milliseconds(40)) + let store = MosaicCustomerTokenStore(provider: provider) + + async let first = store.token() + async let second = store.token() + async let third = store.token() + let outcomes = await [first, second, third] + + for outcome in outcomes { + guard case .lease(let lease) = outcome else { return XCTFail("expected a lease") } + XCTAssertEqual(lease.generation, 0) + } + let calls = await provider.callCount + XCTAssertEqual(calls, 1) + } + + // Risk: retrying a refused token forever turns a Mosaic outage into a request + // storm. Exactly one forced refresh per generation is the contract obligation. + func testExactlyOneForcedRefreshPerGeneration() async { + let provider = RecordingTokenProvider(results: [token("a"), token("b"), token("c")]) + let store = MosaicCustomerTokenStore(provider: provider) + + guard case .lease(let first) = await store.token() else { return XCTFail("expected a lease") } + + // First 401: the token is refreshed and a new lease issued. + guard case .lease(let refreshed) = await store.recoverFromUnauthorized(first) else { + return XCTFail("expected a refreshed lease") + } + XCTAssertGreaterThan(refreshed.generation, first.generation) + let forcedAfterFirst = await provider.forcedCallCount + XCTAssertEqual(forcedAfterFirst, 1) + + // Second 401, now on a freshly minted token: a real failure, not a retry. + let secondRefusal = await store.recoverFromUnauthorized(refreshed) + XCTAssertEqual(secondRefusal, .unavailable(.notAuthorized)) + let forcedAfterSecond = await provider.forcedCallCount + XCTAssertEqual(forcedAfterSecond, 1) + } + + // Risk: a 401 that raced an identity change must not consume the new + // identity's single retry, or one unlucky interleaving locks a signed-in user + // out of their own entitlements. + func testStaleUnauthorizedDoesNotConsumeTheNewGenerationsRetry() async { + let provider = RecordingTokenProvider(results: [token("a"), token("b"), token("c")]) + let store = MosaicCustomerTokenStore(provider: provider) + guard case .lease(let stale) = await store.token() else { return XCTFail("expected a lease") } + + await store.invalidate() + + // The refusal belongs to the previous identity. + guard case .lease(let current) = await store.recoverFromUnauthorized(stale) else { + return XCTFail("expected the current lease") + } + let forced = await provider.forcedCallCount + XCTAssertEqual(forced, 0, "a stale refusal must not force a refresh") + + // The new generation still has its own retry available. + guard case .lease = await store.recoverFromUnauthorized(current) else { + return XCTFail("the current generation must still have its retry") + } + } + + // Risk: a host backend that cannot mint a token has not revoked anyone's + // subscription. Reporting `inactive` here would cancel every paying customer + // whenever the host's auth service hiccups. + func testProviderFailureReportsUnavailableAndBacksOff() async { + let provider = RecordingTokenProvider(results: [.unavailable]) + let store = MosaicCustomerTokenStore(provider: provider, failureCooldown: 60) + + let first = await store.token() + let second = await store.token() + XCTAssertEqual(first, .unavailable(.tokenProviderFailed)) + XCTAssertEqual(second, .unavailable(.tokenProviderFailed)) + let calls = await provider.callCount + XCTAssertEqual(calls, 1, "the cooldown must suppress the second call") + } + + func testSignedOutIsDistinctFromUnavailable() async { + let provider = RecordingTokenProvider(results: [.signedOut]) + let store = MosaicCustomerTokenStore(provider: provider) + + let first = await store.token() + // Still signed out without asking again. + let second = await store.token() + XCTAssertEqual(first, .signedOut) + XCTAssertEqual(second, .signedOut) + let calls = await provider.callCount + XCTAssertEqual(calls, 1) + } + + // Risk: a token that outlives a logout is the previous user's credential in + // the next user's session. + func testSignOutDiscardsTheTokenAndBumpsTheGeneration() async { + let provider = RecordingTokenProvider(results: [token("a")]) + let store = MosaicCustomerTokenStore(provider: provider) + _ = await store.token() + + let before = await store.currentGeneration + await store.signOut() + + let held = await store.hasToken + XCTAssertFalse(held) + let after = await store.currentGeneration + XCTAssertGreaterThan(after, before) + let afterSignOut = await store.token() + XCTAssertEqual(afterSignOut, .signedOut) + } + + // Risk: an in-flight token fetch that resolves after an identity change would + // otherwise cache the previous user's token under the new identity. + func testInFlightFetchResolvingAfterInvalidationIsDiscarded() async { + let provider = RecordingTokenProvider(results: [token("a")], delay: .milliseconds(60)) + let store = MosaicCustomerTokenStore(provider: provider) + + let pending = Task { await store.token() } + try? await Task.sleep(for: .milliseconds(10)) + await store.invalidate() + _ = await pending.value + + let held = await store.hasToken + XCTAssertFalse(held, "a token fetched for a discarded identity must not be retained") + } + + func testNoProviderReportsNotConfigured() async { + let store = MosaicCustomerTokenStore(provider: nil) + let outcome = await store.token() + XCTAssertEqual(outcome, .unavailable(.notConfigured)) + } +} diff --git a/sdk/ios/Tests/MosaicSDKTests/EntitlementRestoreSyncTests.swift b/sdk/ios/Tests/MosaicSDKTests/EntitlementRestoreSyncTests.swift new file mode 100644 index 00000000..50259150 --- /dev/null +++ b/sdk/ios/Tests/MosaicSDKTests/EntitlementRestoreSyncTests.swift @@ -0,0 +1,216 @@ +import Foundation +import XCTest + +@testable import MosaicSDK + +private actor ScriptedSyncTransport: MosaicEntitlementSyncTransport { + private var responses: [MosaicEntitlementSyncHTTPResponse] + private(set) var requestCount = 0 + + init(_ responses: [MosaicEntitlementSyncHTTPResponse]) { self.responses = responses } + + func fetch(_: MosaicEntitlementSyncHTTPRequest) async throws + -> MosaicEntitlementSyncHTTPResponse + { + requestCount += 1 + guard !responses.isEmpty else { throw URLError(.badServerResponse) } + return responses.count > 1 ? responses.removeFirst() : responses[0] + } +} + +final class EntitlementRestoreSyncTests: XCTestCase { + private let baseURL = URL(string: "https://api.example.com")! + + private func snapshotData(_ name: String) throws -> Data { + try authoritativeEntitlementFixtureData("snapshots/\(name)") + } + + private func ok(_ data: Data) -> MosaicEntitlementSyncHTTPResponse { + .init(statusCode: 200, data: data) + } + + private func makeClient( + transport: any MosaicEntitlementSyncTransport, now: Date + ) -> MosaicCustomerEntitlementClient { + MosaicCustomerEntitlementClient( + publicSDKKey: "pk_test", + baseURL: baseURL, + requestTimeout: 5, + transport: transport, + tokenStore: MosaicCustomerTokenStore( + provider: MosaicStaticCustomerTokenProvider( + token: MosaicCustomerAccessToken("mcat_test")), + clock: { now }), + bindingDigest: "digest-a", + cacheStoreFactory: { _ in MosaicCustomerEntitlementMemoryCacheStore() }, + clock: { now }) + } + + private func coordinator(_ client: MosaicCustomerEntitlementClient) + -> MosaicCustomerRestoreCoordinator + { + // No real sleeping: the poll budget is a product decision, not something a + // test should spend six seconds proving. + MosaicCustomerRestoreCoordinator(client: client, sleep: { _ in }) + } + + private static let proEntitlement: Set = [MosaicEntitlement(id: "pro")] + private var proEntitlement: Set { Self.proEntitlement } + + // Risk: the central rule of the restore contract. A successful native restore + // whose facts Mosaic has not validated yet is not restored access, and + // reporting it as restored is how a restore flow starts lying: the customer is + // told they have access, the next snapshot says otherwise, and it looks like + // Mosaic took it away. + func testRestoredRequiresAnAcceptedSnapshotThatReflectsTheRestore() async throws { + // Mosaic keeps answering with the same pre-restore snapshot version. + let transport = ScriptedSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let now = try contractTimestamp("2026-07-28T12:30:00.000Z") + let client = makeClient(transport: transport, now: now) + _ = await client.refresh() + + let result = await coordinator(client).run { .restored(Self.proEntitlement) } + + XCTAssertEqual(result.outcome, .validationPending(attempts: 3)) + XCTAssertFalse( + result.authoritativeEntitlementsUpdated, + "no accepted snapshot reflects the restore, so nothing was authoritatively updated") + // The provider's own answer is carried verbatim, never merged into the + // authoritative outcome. + XCTAssertEqual(result.providerResult, .restored(proEntitlement)) + } + + // Risk: the poll must be bounded. An unbounded wait blocks the restore button + // forever when validation is slow or Mosaic is down. + func testValidationPollIsBoundedByTheCrossPlatformBudget() async throws { + let transport = ScriptedSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let now = try contractTimestamp("2026-07-28T12:30:00.000Z") + let client = makeClient(transport: transport, now: now) + _ = await client.refresh() + + let result = await coordinator(client).run { .restored(Self.proEntitlement) } + + XCTAssertEqual(result.outcome, .validationPending(attempts: 3)) + let requests = await transport.requestCount + // One priming refresh plus exactly the budgeted attempts. + XCTAssertEqual(requests, 1 + MosaicCustomerEntitlementPolicy.restorePollAttempts) + } + + // Risk: when Mosaic *does* project the restore, the SDK must say so, and the + // evidence is the advanced snapshot version. + func testAcceptedNewerSnapshotReportsRestoredWithItsVersion() async throws { + let transport = ScriptedSyncTransport([ + ok(try snapshotData("active-subscription.json")), + ok(try snapshotData("newer-snapshot.json")), + ]) + let now = try contractTimestamp("2026-07-28T14:30:00.000Z") + let client = makeClient(transport: transport, now: now) + _ = await client.refresh() + + let result = await coordinator(client).run { .restored(Self.proEntitlement) } + + XCTAssertEqual(result.outcome, .restored(snapshotVersion: 14)) + XCTAssertTrue(result.authoritativeEntitlementsUpdated) + XCTAssertEqual(result.snapshotVersion, 14) + XCTAssertTrue( + result.stages.contains(.authoritativeSnapshotAccepted(snapshotVersion: 14)), + "the accepted snapshot must appear as an observable stage") + } + + // Risk: a cancelled or failed native restore must not trigger a validation + // wait, and must never be reported as restored. + func testProviderCancellationShortCircuitsWithoutSyncing() async throws { + let transport = ScriptedSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let now = try contractTimestamp("2026-07-28T12:30:00.000Z") + let client = makeClient(transport: transport, now: now) + + let result = await coordinator(client).run { .cancelled } + + XCTAssertEqual(result.outcome, .cancelled) + XCTAssertFalse(result.authoritativeEntitlementsUpdated) + XCTAssertEqual(result.providerResult, .cancelled) + let requests = await transport.requestCount + XCTAssertEqual(requests, 0, "a cancelled restore must not wait on validation") + XCTAssertFalse(result.stages.contains(.authoritativeSyncStarted)) + } + + func testProviderFailureIsCarriedVerbatimAndNeverBecomesRestored() async throws { + let transport = ScriptedSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let now = try contractTimestamp("2026-07-28T12:30:00.000Z") + let client = makeClient(transport: transport, now: now) + let diagnostic = MosaicCommerceDiagnostic( + code: "commerce.restoreFailed", safeMessage: "StoreKit could not synchronize purchases.", + severity: .error, retryable: true, correlationID: "ios_storekit_1", + providerCode: "storekit_error", recoveryAction: .retry) + + let result = await coordinator(client).run { + .failed(diagnosticCode: diagnostic.code, diagnostic: diagnostic) + } + + XCTAssertEqual(result.outcome, .failed) + XCTAssertEqual( + result.providerResult, .failed(diagnosticCode: diagnostic.code, diagnostic: diagnostic)) + XCTAssertFalse(result.authoritativeEntitlementsUpdated) + } + + // Risk: "nothing to restore" is a real, common answer — a customer who never + // purchased. It must be distinguishable from a failure and from a pending + // validation, or the UI shows an error to someone who simply has no purchases. + func testNothingToRestoreIsItsOwnOutcome() async throws { + let transport = ScriptedSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let now = try contractTimestamp("2026-07-28T12:30:00.000Z") + let client = makeClient(transport: transport, now: now) + _ = await client.refresh() + + let result = await coordinator(client).run { .nothingToRestore } + + XCTAssertEqual(result.outcome, .noAdditionalPurchases) + XCTAssertFalse(result.authoritativeEntitlementsUpdated) + } + + // Risk: with no customer there is no authoritative answer possible, and + // pretending otherwise would report someone else's access or a false failure. + func testSignedOutDuringRestoreReportsIdentityUnresolved() async throws { + let transport = ScriptedSyncTransport([ok(try snapshotData("active-subscription.json"))]) + let now = try contractTimestamp("2026-07-28T12:30:00.000Z") + let client = MosaicCustomerEntitlementClient( + publicSDKKey: "pk_test", + baseURL: baseURL, + requestTimeout: 5, + transport: transport, + tokenStore: MosaicCustomerTokenStore( + provider: MosaicStaticCustomerTokenProvider(result: .signedOut), clock: { now }), + bindingDigest: "digest-a", + cacheStoreFactory: { _ in MosaicCustomerEntitlementMemoryCacheStore() }, + clock: { now }) + + let result = await coordinator(client).run { .restored(Self.proEntitlement) } + + XCTAssertEqual(result.outcome, .identityUnresolved) + XCTAssertFalse(result.authoritativeEntitlementsUpdated) + } + + // Risk: the stage list is what a host renders as restore progress. It must + // record both axes in order, so a customer sees "the store said yes, Mosaic is + // still confirming" rather than an unexplained wait. + func testStagesRecordBothAxesInOrder() async throws { + let transport = ScriptedSyncTransport([ + ok(try snapshotData("active-subscription.json")), + ok(try snapshotData("newer-snapshot.json")), + ]) + let now = try contractTimestamp("2026-07-28T14:30:00.000Z") + let client = makeClient(transport: transport, now: now) + _ = await client.refresh() + + let result = await coordinator(client).run { .restored(Self.proEntitlement) } + + XCTAssertEqual( + result.stages, + [ + .providerRestoreStarted, + .providerRestoreFinished(.restored(proEntitlement)), + .authoritativeSyncStarted, + .authoritativeSnapshotAccepted(snapshotVersion: 14), + ]) + } +} diff --git a/sdk/ios/Tests/MosaicSDKTests/TestSupport.swift b/sdk/ios/Tests/MosaicSDKTests/TestSupport.swift index 7176433c..6717eeb6 100644 --- a/sdk/ios/Tests/MosaicSDKTests/TestSupport.swift +++ b/sdk/ios/Tests/MosaicSDKTests/TestSupport.swift @@ -171,6 +171,108 @@ func billingReferenceVectors() throws -> [String: Any] { throw CanonicalFixtureLookupError.notFound } +/// One shared cross-implementation reference-vector file from +/// `packages/test-fixtures/src/`. +/// +/// Reading the file the backend, Flutter, and Android also read is the only way +/// the four implementations can be shown to agree; copying the values into this +/// target would let Swift drift silently. +func entitlementReferenceVectors(_ name: String) throws -> [String: Any] { + let fileManager = FileManager.default + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + while directory.path != "/" { + let candidate = directory.appendingPathComponent( + "packages/test-fixtures/src/\(name)") + if fileManager.fileExists(atPath: candidate.path) { + guard + let root = try JSONSerialization.jsonObject(with: Data(contentsOf: candidate)) + as? [String: Any] + else { throw CanonicalFixtureLookupError.invalidShape } + return root + } + directory.deleteLastPathComponent() + } + throw CanonicalFixtureLookupError.notFound +} + +func entitlementVectorList(_ file: String) throws -> [[String: Any]] { + guard let vectors = try entitlementReferenceVectors(file)["vectors"] as? [[String: Any]], + !vectors.isEmpty + else { throw CanonicalFixtureLookupError.invalidShape } + return vectors +} + +/// One canonical Authoritative Entitlement v1 fixture. +func authoritativeEntitlementFixtureData(_ relativePath: String) throws -> Data { + try phase5FixtureData("authoritative-entitlement/v1/\(relativePath)") +} + +func authoritativeEntitlementFixtureNames(in subdirectory: String) throws -> [String] { + let fileManager = FileManager.default + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + while directory.path != "/" { + let candidate = directory.appendingPathComponent( + "protocol/fixtures/authoritative-entitlement/v1/\(subdirectory)") + if fileManager.fileExists(atPath: candidate.path) { + return try fileManager.contentsOfDirectory(atPath: candidate.path) + .filter { $0.hasSuffix(".json") } + .sorted() + } + directory.deleteLastPathComponent() + } + throw CanonicalFixtureLookupError.notFound +} + +/// Builds an iOS-local, contract-valid snapshot variant without adding or +/// modifying a canonical shared fixture. The content digest is recomputed after +/// the mutation so sync tests exercise the acceptance gate rather than the +/// corruption path. +func authoritativeEntitlementSnapshotVariant( + _ fixtureName: String = "active-subscription.json", + mutation: (inout [String: Any]) -> Void +) throws -> Data { + guard + var root = try JSONSerialization.jsonObject( + with: authoritativeEntitlementFixtureData("snapshots/\(fixtureName)")) as? [String: Any], + var payload = root["payload"] as? [String: Any] + else { throw CanonicalFixtureLookupError.invalidShape } + + mutation(&payload) + var digestInput = payload + digestInput.removeValue(forKey: "contentDigest") + payload["contentDigest"] = try MosaicCustomerCanonicalJSON.digest(digestInput) + root["payload"] = payload + return try MosaicCustomerCanonicalJSON.data(root) +} + +func neverProjectedEntitlementPlaceholderData() throws -> Data { + try authoritativeEntitlementSnapshotVariant { payload in + payload["snapshotId"] = "pending.fixture-customer-0001" + payload["snapshotVersion"] = 0 + payload.removeValue(forKey: "previousSnapshotVersion") + payload["entries"] = [] + payload["sources"] = [] + payload["projectionStatus"] = [ + "state": "pending", + "lastProjectedAt": "2026-07-28T11:00:00.000Z", + "pendingFactCount": 0, + ] + payload["changeReason"] = "initial_projection" + payload["entityTag"] = "pending-cs-0001-v0" + } +} + +/// Parses a contract timestamp in a test without going through the decoder +/// under test. +func contractTimestamp(_ value: String) throws -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = formatter.date(from: value) else { + throw CanonicalFixtureLookupError.invalidShape + } + return date +} + func commerceConfigurationFixtureData( named name: String = "revenuecat-configuration.json" ) throws -> Data { diff --git a/sdk/ios/Tests/MosaicSDKTests/TransactionObservationTests.swift b/sdk/ios/Tests/MosaicSDKTests/TransactionObservationTests.swift index 6b845f87..b47191be 100644 --- a/sdk/ios/Tests/MosaicSDKTests/TransactionObservationTests.swift +++ b/sdk/ios/Tests/MosaicSDKTests/TransactionObservationTests.swift @@ -8,18 +8,55 @@ import XCTest private actor ObservationTestTransport: MosaicTransactionObservationTransport { private var responses: [MosaicTransactionObservationHTTPResponse] private(set) var bodies: [Data] = [] + private(set) var customerTokens: [MosaicCustomerAccessToken?] = [] init(_ responses: [MosaicTransactionObservationHTTPResponse]) { self.responses = responses } - func send(data: Data) async throws -> MosaicTransactionObservationHTTPResponse { + func send(data: Data, customerToken: MosaicCustomerAccessToken?) async throws + -> MosaicTransactionObservationHTTPResponse + { bodies.append(data) + customerTokens.append(customerToken) guard !responses.isEmpty else { throw URLError(.notConnectedToInternet) } return responses.removeFirst() } func recordedBodies() -> [Data] { bodies } + func recordedCustomerTokens() -> [MosaicCustomerAccessToken?] { customerTokens } +} + +/// A token source under direct test control, so "a token arrived between +/// enqueue and flush" is expressible without driving a real provider. +/// A clock the test can move forward, so retry eligibility is reachable without +/// sleeping. +private final class MutableClock: @unchecked Sendable { + private let lock = NSLock() + private var current: Date + + init(start: Date) { current = start } + + func now() -> Date { + lock.lock() + defer { lock.unlock() } + return current + } + + func advance(_ seconds: TimeInterval) { + lock.lock() + current = current.addingTimeInterval(seconds) + lock.unlock() + } +} + +private actor StubCustomerTokenSource: MosaicCustomerTokenSource { + private var token: MosaicCustomerAccessToken? + + init(token: MosaicCustomerAccessToken? = nil) { self.token = token } + + func set(_ token: MosaicCustomerAccessToken?) { self.token = token } + func heldCustomerToken() -> MosaicCustomerAccessToken? { token } } extension MosaicTransactionObservationHTTPResponse { @@ -58,6 +95,128 @@ final class TransactionObservationTests: XCTestCase { reference: reference, observedAt: observedAt)) } + // MARK: Customer binding + + // Risk: without the customer token an identified user's purchase anchors + // anonymously and has to be associated to their Billing Customer later by + // other evidence — which is exactly the association gap Phase 9B exists to + // close. + func testSubmissionCarriesTheCustomerTokenWhenOneIsHeld() async throws { + let transport = ObservationTestTransport([ + .result("accepted_for_validation", submissionID: "s1") + ]) + let runtime = runtime( + persistence: MosaicMemoryTransactionObservationPersistence(), transport: transport) + await runtime.attachCustomerTokenSource( + StubCustomerTokenSource(token: MosaicCustomerAccessToken("mcat_bound"))) + + await runtime.enqueue(try observation(submissionID: "s1")) + + let tokens = await transport.recordedCustomerTokens() + XCTAssertEqual(tokens, [MosaicCustomerAccessToken("mcat_bound")]) + } + + // Risk: attaching a token for a signed-out session would bind a purchase to + // whoever was signed in last. Anonymous submission stays valid, so absence is + // not an error and must not suppress delivery. + func testSubmissionOmitsTheTokenWhenSignedOutAndStillDelivers() async throws { + let transport = ObservationTestTransport([ + .result("accepted_for_validation", submissionID: "s1") + ]) + let runtime = runtime( + persistence: MosaicMemoryTransactionObservationPersistence(), transport: transport) + await runtime.attachCustomerTokenSource(StubCustomerTokenSource(token: nil)) + + await runtime.enqueue(try observation(submissionID: "s1")) + + let tokens = await transport.recordedCustomerTokens() + XCTAssertEqual(tokens, [nil]) + let diagnostics = await runtime.diagnostics() + XCTAssertEqual( + diagnostics.acceptedForValidationCount, 1, + "an anonymous submission is still a valid submission") + } + + // Risk: a queued observation can outlive many token generations. Binding at + // enqueue time would either persist a credential or attach a stale one; the + // token must be read at send time. + func testTokenArrivingBetweenEnqueueAndFlushIsUsed() async throws { + let transport = ObservationTestTransport([ + // The first attempt fails, so the observation stays queued. + .init(statusCode: 503, data: Data(), retryAfterSeconds: nil), + .result("accepted", submissionID: "s1"), + ]) + let source = StubCustomerTokenSource(token: nil) + // The clock has to advance past the retry backoff, so this runtime is built + // directly rather than with the fixed-clock helper. + let start = Date(timeIntervalSince1970: 1_785_500_010) + let elapsed = MutableClock(start: start) + let runtime = MosaicTransactionObservationRuntime( + persistence: MosaicMemoryTransactionObservationPersistence(), transport: transport, + context: MosaicTransactionObservationContext(applicationVersion: "1.4.2"), + clock: { elapsed.now() }, jitter: { $0.upperBound }) + await runtime.attachCustomerTokenSource(source) + + await runtime.enqueue(try observation(submissionID: "s1")) + + // The user signs in after the purchase was already queued, and enough time + // passes for the retry to become eligible. + await source.set(MosaicCustomerAccessToken("mcat_signed_in_later")) + elapsed.advance(60) + _ = await runtime.flush() + + let tokens = await transport.recordedCustomerTokens() + XCTAssertEqual(tokens, [nil, MosaicCustomerAccessToken("mcat_signed_in_later")]) + } + + // Risk: a credential written into the observation queue would sit on disk far + // longer than the token's own lifetime, in a file that survives relaunch. It + // must appear in neither the persisted queue nor the diagnostics a host might + // log. + func testTokenIsNeverPersistedWithTheQueueOrSurfacedInDiagnostics() async throws { + let persistence = MosaicMemoryTransactionObservationPersistence() + // No response, so the observation fails and stays queued to be persisted. + let transport = ObservationTestTransport([]) + let runtime = runtime(persistence: persistence, transport: transport) + await runtime.attachCustomerTokenSource( + StubCustomerTokenSource(token: MosaicCustomerAccessToken("mcat_secret_value"))) + + await runtime.enqueue(try observation(submissionID: "s1")) + + let loaded = await persistence.load() + let stored = try XCTUnwrap(loaded) + XCTAssertEqual(stored.queue.count, 1, "the observation is retained for retry") + let encoded = try JSONEncoder().encode(stored) + let text = try XCTUnwrap(String(data: encoded, encoding: .utf8)) + XCTAssertFalse(text.contains("mcat_secret_value")) + XCTAssertFalse(text.contains("Mosaic-Customer-Token")) + + let diagnostics = await runtime.diagnostics() + XCTAssertFalse(String(describing: diagnostics).contains("mcat_secret_value")) + } + + // Risk: the 9A observation record is a frozen contract. Customer binding is a + // transport concern and must not have leaked into the submitted document. + func testSubmittedRecordIsUnchangedByCustomerBinding() async throws { + let transport = ObservationTestTransport([ + .result("accepted_for_validation", submissionID: "s1") + ]) + let runtime = runtime( + persistence: MosaicMemoryTransactionObservationPersistence(), transport: transport) + await runtime.attachCustomerTokenSource( + StubCustomerTokenSource(token: MosaicCustomerAccessToken("mcat_secret_value"))) + + await runtime.enqueue(try observation(submissionID: "s1")) + + let bodies = await transport.recordedBodies() + let body = try XCTUnwrap(bodies.first) + XCTAssertEqual( + body, try MosaicTransactionObservationCodec.encode( + try observation(submissionID: "s1"), context: context)) + XCTAssertFalse( + try XCTUnwrap(String(data: body, encoding: .utf8)).contains("mcat_secret_value")) + } + /// A runtime with a fixed clock and a deterministic worst-case backoff, so /// retry scheduling is reproducible. private func runtime(